From d7be884674d813b5b6e5cc641371db89d84428c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 19 Dec 2025 13:28:37 +0800 Subject: [PATCH] Add DocumentsProvider for working directory Expose the service's running directory to file managers via Storage Access Framework, allowing users to browse and manage files directly. --- app/src/main/AndroidManifest.xml | 11 ++ .../sfa/WorkingDirectoryProvider.kt | 187 ++++++++++++++++++ .../screen/settings/CoreSettingsScreen.kt | 61 +++++- app/src/main/res/values/strings.xml | 2 + 4 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f159e8..800f7f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -225,6 +225,17 @@ android:resource="@xml/cache_paths" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt b/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt new file mode 100644 index 0000000..dde9466 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt @@ -0,0 +1,187 @@ +package io.nekohasekai.sfa + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import java.io.File +import java.io.FileNotFoundException + +class WorkingDirectoryProvider : DocumentsProvider() { + + companion object { + private const val ROOT_ID = "working_directory" + private const val ROOT_DOC_ID = "root" + + private val DEFAULT_ROOT_PROJECTION = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + ) + + private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE, + ) + } + + private val baseDir: File + get() = context!!.getExternalFilesDir(null)!! + + override fun onCreate(): Boolean = true + + override fun queryRoots(projection: Array?): Cursor { + val result = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) + result.newRow().apply { + add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + add( + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or + DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD + ) + add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher) + add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) + add(DocumentsContract.Root.COLUMN_SUMMARY, context!!.getString(R.string.working_directory)) + add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOC_ID) + } + return result + } + + override fun queryDocument(documentId: String, projection: Array?): Cursor { + val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + val file = getFileForDocId(documentId) + includeFile(result, documentId, file) + return result + } + + override fun queryChildDocuments( + parentDocumentId: String, + projection: Array?, + sortOrder: String? + ): Cursor { + val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + val parent = getFileForDocId(parentDocumentId) + parent.listFiles()?.forEach { file -> + includeFile(result, getDocIdForFile(file), file) + } + return result + } + + override fun openDocument( + documentId: String, + mode: String, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val file = getFileForDocId(documentId) + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } + + override fun createDocument( + parentDocumentId: String, + mimeType: String, + displayName: String + ): String { + val parent = getFileForDocId(parentDocumentId) + val file = File(parent, displayName) + + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + file.mkdirs() + } else { + file.createNewFile() + } + + return getDocIdForFile(file) + } + + override fun deleteDocument(documentId: String) { + val file = getFileForDocId(documentId) + if (file.isDirectory) { + file.deleteRecursively() + } else { + file.delete() + } + } + + override fun renameDocument(documentId: String, displayName: String): String { + val file = getFileForDocId(documentId) + val newFile = File(file.parentFile, displayName) + file.renameTo(newFile) + return getDocIdForFile(newFile) + } + + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { + val parent = getFileForDocId(parentDocumentId) + val child = getFileForDocId(documentId) + return child.absolutePath.startsWith(parent.absolutePath) + } + + private fun getFileForDocId(documentId: String): File { + if (documentId == ROOT_DOC_ID) { + return baseDir + } + return File(baseDir, documentId) + } + + private fun getDocIdForFile(file: File): String { + val path = file.absolutePath + val basePath = baseDir.absolutePath + + return if (path == basePath) { + ROOT_DOC_ID + } else { + path.removePrefix("$basePath/") + } + } + + private fun includeFile(result: MatrixCursor, documentId: String, file: File) { + var flags = 0 + + if (file.isDirectory) { + flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE + } + + if (file.parentFile?.canWrite() == true) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + + val mimeType = if (file.isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + getMimeType(file) + } + + result.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType) + add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()) + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + add(DocumentsContract.Document.COLUMN_SIZE, file.length()) + } + } + + private fun getMimeType(file: File): String { + val extension = file.extension.lowercase() + if (extension.isNotEmpty()) { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mimeType != null) { + return mimeType + } + } + return "application/octet-stream" + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt index 9c74a9e..10bade2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt @@ -11,8 +11,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract +import android.widget.Toast import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.WarningAmber @@ -217,7 +223,7 @@ fun CoreSettingsScreen(navController: NavController) { modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), ) - // Destroy Data Card - No dialog, immediate deletion + // Working Directory Card Card( modifier = Modifier @@ -225,9 +231,37 @@ fun CoreSettingsScreen(navController: NavController) { .padding(horizontal = 16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + containerColor = MaterialTheme.colorScheme.surfaceContainer, ), ) { + // Browse + ListItem( + headlineContent = { + Text( + stringResource(R.string.browse), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { + openInFileManager(context) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + // Destroy ListItem( headlineContent = { Text( @@ -245,7 +279,7 @@ fun CoreSettingsScreen(navController: NavController) { }, modifier = Modifier - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) .clickable { scope.launch(Dispatchers.IO) { val filesDir = context.getExternalFilesDir(null) ?: context.filesDir @@ -272,3 +306,24 @@ fun CoreSettingsScreen(navController: NavController) { Spacer(modifier = Modifier.height(16.dp)) } } + +private fun openInFileManager(context: Context) { + val authority = "${context.packageName}.workingdir" + val rootUri = DocumentsContract.buildRootUri(authority, "working_directory") + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(rootUri, DocumentsContract.Document.MIME_TYPE_DIR) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + context, + context.getString(R.string.no_file_manager), + Toast.LENGTH_SHORT + ).show() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 861af6c..7295ff2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -348,4 +348,6 @@ Clear Logs %d selected Not selected + Browse + No file manager found \ No newline at end of file