diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt new file mode 100644 index 000000000..1076c58be --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt @@ -0,0 +1,20 @@ +package com.internxt.cloud.documents + +object DocumentId { + + enum class Kind { FOLDER, FILE } + + data class Decoded(val kind: Kind, val uuid: String) + + private const val FOLDER_PREFIX = "f:" + private const val FILE_PREFIX = "d:" + + fun encodeFolder(uuid: String): String = FOLDER_PREFIX + uuid + fun encodeFile(uuid: String): String = FILE_PREFIX + uuid + + fun decode(id: String): Decoded? = when { + id.startsWith(FOLDER_PREFIX) -> Decoded(Kind.FOLDER, id.removePrefix(FOLDER_PREFIX)) + id.startsWith(FILE_PREFIX) -> Decoded(Kind.FILE, id.removePrefix(FILE_PREFIX)) + else -> null + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt new file mode 100644 index 000000000..f5eff52d8 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt @@ -0,0 +1,46 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import com.internxt.cloud.documents.api.model.DriveFile +import com.internxt.cloud.documents.api.model.DriveFolder +import java.time.Instant +import java.time.format.DateTimeParseException + +object DocumentRowBuilder { + + private const val FOLDER_FLAGS = Document.FLAG_DIR_SUPPORTS_CREATE + private const val FILE_FLAGS = 0 + + fun folderRow(folder: DriveFolder): Map = folderRow( + uuid = folder.uuid, + displayName = folder.plainName, + lastModified = parseIsoToMillis(folder.updatedAt), + ) + + fun folderRow(uuid: String, displayName: String, lastModified: Long? = null): Map = mapOf( + Document.COLUMN_DOCUMENT_ID to DocumentId.encodeFolder(uuid), + Document.COLUMN_MIME_TYPE to Document.MIME_TYPE_DIR, + Document.COLUMN_DISPLAY_NAME to displayName, + Document.COLUMN_LAST_MODIFIED to lastModified, + Document.COLUMN_FLAGS to FOLDER_FLAGS, + Document.COLUMN_SIZE to null, + ) + + fun fileRow(file: DriveFile): Map = mapOf( + Document.COLUMN_DOCUMENT_ID to DocumentId.encodeFile(file.uuid), + Document.COLUMN_MIME_TYPE to MimeTypes.fromExtension(file.type), + Document.COLUMN_DISPLAY_NAME to file.plainName, + Document.COLUMN_LAST_MODIFIED to parseIsoToMillis(file.updatedAt), + Document.COLUMN_FLAGS to FILE_FLAGS, + Document.COLUMN_SIZE to file.size, + ) + + internal fun parseIsoToMillis(iso: String?): Long? { + if (iso.isNullOrBlank()) return null + return try { + Instant.parse(iso).toEpochMilli() + } catch (_: DateTimeParseException) { + null + } + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt index cd337c892..41e2f8d55 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt @@ -2,19 +2,40 @@ package com.internxt.cloud.documents import android.database.Cursor import android.database.MatrixCursor +import android.net.Uri +import android.os.Bundle import android.os.CancellationSignal import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.DocumentsContract.Document import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider +import android.util.Log import com.internxt.cloud.R +import com.internxt.cloud.documents.api.InternxtApiClient +import com.internxt.cloud.documents.api.InternxtApiException import com.internxt.cloud.documents.auth.InternxtAuthManager +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors class InternxtDocumentsProvider : DocumentsProvider() { private lateinit var authManager: InternxtAuthManager + private val loaderExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } + } + + private val folderLoads = ConcurrentHashMap() + + private enum class LoadState { LOADING, DONE, ERROR } + + private class FolderLoad { + @Volatile var state: LoadState = LoadState.LOADING + @Volatile var errorMessage: String? = null + val rows = mutableListOf>() + } + override fun onCreate(): Boolean { val ctx = context ?: return false authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false @@ -26,11 +47,13 @@ class InternxtDocumentsProvider : DocumentsProvider() { val ctx = context ?: return cursor cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildRootsUri(AUTHORITY)) - val rootUuid = authManager.authenticatedRootUuid() ?: return cursor + val rootUuid = authManager.authenticatedRootUuid() + Log.d(TAG, "queryRoots: isLoggedIn=${authManager.isLoggedIn()} rootUuid=$rootUuid") + if (rootUuid == null) return cursor cursor.newRow().apply { add(Root.COLUMN_ROOT_ID, ROOT_ID) - add(Root.COLUMN_DOCUMENT_ID, rootUuid) + add(Root.COLUMN_DOCUMENT_ID, DocumentId.encodeFolder(rootUuid)) add(Root.COLUMN_TITLE, ctx.getString(R.string.documents_provider_label)) authManager.userEmail()?.let { add(Root.COLUMN_SUMMARY, it) } add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD) @@ -39,14 +62,143 @@ class InternxtDocumentsProvider : DocumentsProvider() { return cursor } - override fun queryDocument(documentId: String?, projection: Array?): Cursor = - MatrixCursor(resolveDocumentProjection(projection)) + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val cursor = MatrixCursor(resolveDocumentProjection(projection)) + val ctx = context ?: return cursor + val id = documentId ?: return cursor + cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildDocumentUri(AUTHORITY, id)) + Log.d(TAG, "queryDocument id=$id") + + val decoded = DocumentId.decode(id) + val uuid = decoded?.uuid ?: id + + if (decoded?.kind == DocumentId.Kind.FOLDER && uuid == authManager.authenticatedRootUuid()) { + cursor.addDocumentRow( + DocumentRowBuilder.folderRow(uuid, ctx.getString(R.string.documents_provider_label)) + ) + return cursor + } + + val api = apiClient(op = "queryDocument") + val row = if (api == null) null else try { + when (decoded?.kind) { + DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.let { DocumentRowBuilder.folderRow(it) } + DocumentId.Kind.FILE -> api.getFile(uuid)?.let { DocumentRowBuilder.fileRow(it) } + null -> api.getFolder(uuid)?.let { DocumentRowBuilder.folderRow(it) } + ?: api.getFile(uuid)?.let { DocumentRowBuilder.fileRow(it) } + } + } catch (e: InternxtApiException) { + Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") + null + } + + cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = uuid, displayName = uuid)) + return cursor + } override fun queryChildDocuments( parentDocumentId: String?, projection: Array?, sortOrder: String? - ): Cursor = MatrixCursor(resolveDocumentProjection(projection)) + ): Cursor { + val cursor = MatrixCursor(resolveDocumentProjection(projection)) + val ctx = context ?: return cursor + val parent = parentDocumentId ?: return cursor + val notifyUri = DocumentsContract.buildChildDocumentsUri(AUTHORITY, parent) + cursor.setNotificationUri(ctx.contentResolver, notifyUri) + Log.d(TAG, "queryChildDocuments parent=$parent") + + val decoded = DocumentId.decode(parent) + if (decoded?.kind == DocumentId.Kind.FILE) { + Log.w(TAG, "queryChildDocuments called with file id=$parent") + return cursor + } + val parentUuid = decoded?.uuid ?: parent + + val load = folderLoads.computeIfAbsent(parent) { + FolderLoad().also { startBackgroundLoad(parentUuid, it, notifyUri) } + } + + val snapshot: List> + val state: LoadState + val errorMessage: String? + synchronized(load) { + snapshot = load.rows.toList() + state = load.state + errorMessage = load.errorMessage + } + snapshot.forEach { cursor.addDocumentRow(it) } + + cursor.extras = Bundle().apply { + putBoolean(DocumentsContract.EXTRA_LOADING, state == LoadState.LOADING) + if (state == LoadState.ERROR && errorMessage != null) { + putString(DocumentsContract.EXTRA_ERROR, errorMessage) + } + } + return cursor + } + + private fun startBackgroundLoad(parent: String, load: FolderLoad, notifyUri: Uri) { + loaderExecutor.execute { + val api = apiClient(op = "queryChildDocuments[bg]") + if (api == null) { + finishLoad(load, notifyUri, LoadState.ERROR, "Not authenticated") + return@execute + } + try { + streamPages({ offset, size -> api.listFolderFolders(parent, offset, size) }) { page -> + appendRows(load, notifyUri, page.map { DocumentRowBuilder.folderRow(it) }) + } + streamPages({ offset, size -> api.listFolderFiles(parent, offset, size) }) { page -> + appendRows(load, notifyUri, page.map { DocumentRowBuilder.fileRow(it) }) + } + finishLoad(load, notifyUri, LoadState.DONE, null) + Log.d(TAG, "queryChildDocuments parent=$parent loaded rows=${load.rows.size}") + } catch (e: InternxtApiException) { + Log.w(TAG, "queryChildDocuments parent=$parent failed: ${e.javaClass.simpleName}: ${e.message}") + finishLoad(load, notifyUri, LoadState.ERROR, e.message) + } + } + } + + private fun appendRows(load: FolderLoad, notifyUri: Uri, rows: List>) { + if (rows.isEmpty()) return + synchronized(load) { load.rows.addAll(rows) } + context?.contentResolver?.notifyChange(notifyUri, null) + } + + private fun finishLoad(load: FolderLoad, notifyUri: Uri, state: LoadState, errorMessage: String?) { + synchronized(load) { + load.state = state + load.errorMessage = errorMessage + } + context?.contentResolver?.notifyChange(notifyUri, null) + } + + private fun apiClient(op: String): InternxtApiClient? { + val cfg = authManager.loadAuthConfig() + if (cfg == null) { + Log.w(TAG, "$op: loadAuthConfig() returned null") + return null + } + return InternxtApiClient(cfg) + } + + private inline fun streamPages(fetch: (offset: Int, size: Int) -> List, onPage: (List) -> Unit) { + val pageSize = InternxtApiClient.DEFAULT_PAGE_SIZE + var offset = 0 + while (true) { + val page = fetch(offset, pageSize) + onPage(page) + if (page.size < pageSize) break + offset += pageSize + } + } + + private fun MatrixCursor.addDocumentRow(row: Map) { + val builder = newRow() + row.forEach { (column, value) -> builder.add(column, value) } + } override fun openDocument( documentId: String?, @@ -65,6 +217,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { companion object { const val AUTHORITY = "com.internxt.cloud.documents" private const val ROOT_ID = "internxt-root" + private const val TAG = "InternxtDocsProvider" private val DEFAULT_ROOT_PROJECTION = arrayOf( Root.COLUMN_ROOT_ID, diff --git a/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt b/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt new file mode 100644 index 000000000..7fe17a7c8 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt @@ -0,0 +1,45 @@ +package com.internxt.cloud.documents + +import android.webkit.MimeTypeMap + +object MimeTypes { + + const val DEFAULT = "application/octet-stream" + + private val TABLE = mapOf( + "pdf" to "application/pdf", + "png" to "image/png", + "jpg" to "image/jpeg", + "jpeg" to "image/jpeg", + "gif" to "image/gif", + "webp" to "image/webp", + "mp4" to "video/mp4", + "mov" to "video/quicktime", + "mp3" to "audio/mpeg", + "wav" to "audio/wav", + "txt" to "text/plain", + "csv" to "text/csv", + "json" to "application/json", + "xml" to "application/xml", + "html" to "text/html", + "zip" to "application/zip", + "doc" to "application/msword", + "docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" to "application/vnd.ms-excel", + "xlsx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" to "application/vnd.ms-powerpoint", + "pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + + fun fromExtension(type: String?): String { + val key = type?.trim()?.lowercase().orEmpty() + if (key.isEmpty()) return DEFAULT + return TABLE[key] ?: lookupSystem(key) ?: DEFAULT + } + + private fun lookupSystem(extension: String): String? = try { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } catch (_: Throwable) { + null + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt index d97aedf52..e6d864fe1 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt @@ -29,6 +29,16 @@ class InternxtApiClient( fun listFolderFiles(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List = listChildren(parentUuid, kind = "files", jsonKey = "files", offset, limit, ::parseFile) + fun getFolder(uuid: String): DriveFolder? = getMeta("folders/$uuid/meta", ::parseFolder) + + fun getFile(uuid: String): DriveFile? = getMeta("files/$uuid/meta", ::parseFile) + + private fun getMeta(path: String, parse: (JSONObject) -> T): T? = try { + parse(executeApiRequest(driveRequest(driveUrl(path)).get().build())) + } catch (_: InternxtApiException.NotFoundException) { + null + } + private fun listChildren( parentUuid: String, kind: String, diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt new file mode 100644 index 000000000..97495ec8b --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt @@ -0,0 +1,107 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import com.internxt.cloud.documents.api.model.DriveFile +import com.internxt.cloud.documents.api.model.DriveFolder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DocumentRowBuilderTest { + + @Test + fun folderRowFields() { + val folder = DriveFolder( + uuid = "folder-uuid", + plainName = "Documents", + parentUuid = "parent-uuid", + bucket = null, + createdAt = null, + updatedAt = "2026-01-11T00:00:00.000Z", + ) + + val row = DocumentRowBuilder.folderRow(folder) + + assertEquals("f:folder-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) + assertEquals("Documents", row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) + assertEquals(Document.FLAG_DIR_SUPPORTS_CREATE, row[Document.COLUMN_FLAGS]) + assertNull(row[Document.COLUMN_SIZE]) + } + + @Test + fun fileRowFields() { + val file = DriveFile( + uuid = "file-uuid", + plainName = "report.pdf", + type = "pdf", + size = 102400L, + bucket = "bucket-id", + folderUuid = "parent-uuid", + createdAt = null, + updatedAt = "2026-01-11T00:00:00.000Z", + fileId = "file-id-1", + ) + + val row = DocumentRowBuilder.fileRow(file) + + assertEquals("d:file-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals("application/pdf", row[Document.COLUMN_MIME_TYPE]) + assertEquals("report.pdf", row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) + assertEquals(0, row[Document.COLUMN_FLAGS]) + assertEquals(102400L, row[Document.COLUMN_SIZE]) + } + + @Test + fun fileRowUnknownExtensionFallsBackToOctetStream() { + val file = DriveFile( + uuid = "file-uuid", + plainName = "weird.xyz", + type = "xyz", + size = 0L, + bucket = null, + folderUuid = null, + createdAt = null, + updatedAt = null, + fileId = null, + ) + + val row = DocumentRowBuilder.fileRow(file) + + assertEquals("application/octet-stream", row[Document.COLUMN_MIME_TYPE]) + assertEquals("weird.xyz", row[Document.COLUMN_DISPLAY_NAME]) + assertNull(row[Document.COLUMN_LAST_MODIFIED]) + } + + @Test + fun folderRowOverloadFields() { + val row = DocumentRowBuilder.folderRow("root-uuid", "Internxt Drive") + + assertEquals("f:root-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) + assertEquals("Internxt Drive", row[Document.COLUMN_DISPLAY_NAME]) + assertNull(row[Document.COLUMN_LAST_MODIFIED]) + assertEquals(Document.FLAG_DIR_SUPPORTS_CREATE, row[Document.COLUMN_FLAGS]) + assertNull(row[Document.COLUMN_SIZE]) + } + + @Test + fun parseIsoToMillisHandlesValidInput() { + assertEquals(1768089600000L, DocumentRowBuilder.parseIsoToMillis("2026-01-11T00:00:00.000Z")) + } + + @Test + fun parseIsoToMillisHandlesNullAndBlank() { + assertNull(DocumentRowBuilder.parseIsoToMillis(null)) + assertNull(DocumentRowBuilder.parseIsoToMillis("")) + assertNull(DocumentRowBuilder.parseIsoToMillis(" ")) + } + + @Test + fun parseIsoToMillisHandlesMalformed() { + assertNull(DocumentRowBuilder.parseIsoToMillis("not-a-date")) + assertNull(DocumentRowBuilder.parseIsoToMillis("2026-99-99")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt new file mode 100644 index 000000000..78b01faa2 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt @@ -0,0 +1,47 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MimeTypesTest { + + @Test + fun mapsKnownExtensions() { + assertEquals("application/pdf", MimeTypes.fromExtension("pdf")) + assertEquals("image/jpeg", MimeTypes.fromExtension("jpg")) + assertEquals("image/jpeg", MimeTypes.fromExtension("jpeg")) + assertEquals("image/png", MimeTypes.fromExtension("png")) + assertEquals("video/mp4", MimeTypes.fromExtension("mp4")) + assertEquals("audio/mpeg", MimeTypes.fromExtension("mp3")) + assertEquals("application/zip", MimeTypes.fromExtension("zip")) + assertEquals( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + MimeTypes.fromExtension("xlsx") + ) + } + + @Test + fun isCaseInsensitive() { + assertEquals("application/pdf", MimeTypes.fromExtension("PDF")) + assertEquals("image/jpeg", MimeTypes.fromExtension("JPG")) + assertEquals("image/jpeg", MimeTypes.fromExtension("Jpeg")) + } + + @Test + fun trimsWhitespace() { + assertEquals("application/pdf", MimeTypes.fromExtension(" pdf ")) + } + + @Test + fun unknownExtensionFallsBackToOctetStream() { + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension("xyz")) + assertEquals("application/octet-stream", MimeTypes.fromExtension("xyz")) + } + + @Test + fun nullAndBlankFallBackToOctetStream() { + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension(null)) + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension("")) + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension(" ")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt index b13d5947b..31a4f3681 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt @@ -263,6 +263,49 @@ class InternxtApiClientTest { assertEquals(9999999999L, file.size) } + @Test + fun getFolderHitsMetaEndpointAndReturnsParsedFolder() { + enqueueJson("""{"uuid":"folder-uuid-1","plainName":"Documents"}""") + + val folder = client.getFolder("folder-uuid-1") + + assertEquals("folder-uuid-1", folder?.uuid) + assertEquals("Documents", folder?.plainName) + + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertEquals("/folders/folder-uuid-1/meta", recorded.path) + } + + @Test + fun getFolderReturnsNullWhenNotFound() { + enqueueJson("", code = 404) + + assertNull(client.getFolder("missing-uuid")) + } + + @Test + fun getFileHitsMetaEndpointAndReturnsParsedFile() { + enqueueJson("""{"uuid":"file-uuid-1","plainName":"report.pdf","type":"pdf","size":102400}""") + + val file = client.getFile("file-uuid-1") + + assertEquals("file-uuid-1", file?.uuid) + assertEquals("report.pdf", file?.plainName) + assertEquals(102400L, file?.size) + + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertEquals("/files/file-uuid-1/meta", recorded.path) + } + + @Test + fun getFileReturnsNullWhenNotFound() { + enqueueJson("", code = 404) + + assertNull(client.getFile("missing-uuid")) + } + @Test fun serverErrorSurfacesAsApiError() { enqueueJson("""{"error":"boom"}""", code = 500) diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 853435d7a..7085e3d6b 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -147,7 +147,7 @@ export const signInThunk = createAsyncThunk< // Reset this, in case we logged out during the pull process await asyncStorageService.deleteItem(AsyncStorageKey.LastPhotoPulledDate); - await syncNativeCredentials(payload.token, userToSave); + await syncNativeCredentials(payload.newToken, userToSave); dispatch( authActions.setSignInData({ @@ -188,7 +188,7 @@ export const refreshTokensThunk = createAsyncThunk