Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Any?> = folderRow(
uuid = folder.uuid,
displayName = folder.plainName,
lastModified = parseIsoToMillis(folder.updatedAt),
)

fun folderRow(uuid: String, displayName: String, lastModified: Long? = null): Map<String, Any?> = 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<String, Any?> = 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, FolderLoad>()

private enum class LoadState { LOADING, DONE, ERROR }

private class FolderLoad {
@Volatile var state: LoadState = LoadState.LOADING
@Volatile var errorMessage: String? = null
val rows = mutableListOf<Map<String, Any?>>()
}

override fun onCreate(): Boolean {
val ctx = context ?: return false
authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false
Expand All @@ -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)
Expand All @@ -39,14 +62,143 @@ class InternxtDocumentsProvider : DocumentsProvider() {
return cursor
}

override fun queryDocument(documentId: String?, projection: Array<String>?): Cursor =
MatrixCursor(resolveDocumentProjection(projection))
override fun queryDocument(documentId: String?, projection: Array<String>?): 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<String>?,
sortOrder: String?
): Cursor = MatrixCursor(resolveDocumentProjection(projection))
): Cursor {
val cursor = MatrixCursor(resolveDocumentProjection(projection))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say that the current implementation blocks the provider thread until all pages have been fetched, so the user sees nothing until the entire folder has been loaded. This works, but it isn’t scalable for large folders. The correct approach is to return the first page immediately, abd tgeb load the remaining pages in a background coroutine

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<Map<String, Any?>>
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<Map<String, Any?>>) {
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 <T> streamPages(fetch: (offset: Int, size: Int) -> List<T>, onPage: (List<T>) -> 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<String, Any?>) {
val builder = newRow()
row.forEach { (column, value) -> builder.add(column, value) }
}

override fun openDocument(
documentId: String?,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ class InternxtApiClient(
fun listFolderFiles(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List<DriveFile> =
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 <T> getMeta(path: String, parse: (JSONObject) -> T): T? = try {
parse(executeApiRequest(driveRequest(driveUrl(path)).get().build()))
} catch (_: InternxtApiException.NotFoundException) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens here if exceptions other than NotFoundException are thrown?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unauthorized, ApiError, NetworkException, or a JSONException from a malformed response, is passed back to the caller. That keeps “missing” separate from actual failures, so the provider can log, retry, or report
EXTRA_ERROR instead of quietly treating network or auth problems as “not found.”

null
}

private fun <T> listChildren(
parentUuid: String,
kind: String,
Expand Down
Loading
Loading