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.
This commit is contained in:
世界
2025-12-19 13:28:37 +08:00
parent 72c7794ba9
commit d7be884674
4 changed files with 258 additions and 3 deletions

View File

@@ -225,6 +225,17 @@
android:resource="@xml/cache_paths" /> android:resource="@xml/cache_paths" />
</provider> </provider>
<provider
android:name=".WorkingDirectoryProvider"
android:authorities="${applicationId}.workingdir"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -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<out String>?): 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<out String>?): 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<out String>?,
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"
}
}

View File

@@ -11,8 +11,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.Icons
import androidx.compose.material.icons.outlined.DeleteForever 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.Info
import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material.icons.outlined.WarningAmber
@@ -217,7 +223,7 @@ fun CoreSettingsScreen(navController: NavController) {
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
) )
// Destroy Data Card - No dialog, immediate deletion // Working Directory Card
Card( Card(
modifier = modifier =
Modifier Modifier
@@ -225,9 +231,37 @@ fun CoreSettingsScreen(navController: NavController) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
colors = colors =
CardDefaults.cardColors( 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( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -245,7 +279,7 @@ fun CoreSettingsScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.clickable { .clickable {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val filesDir = context.getExternalFilesDir(null) ?: context.filesDir val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
@@ -272,3 +306,24 @@ fun CoreSettingsScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp)) 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()
}
}

View File

@@ -348,4 +348,6 @@
<string name="clear_logs">Clear Logs</string> <string name="clear_logs">Clear Logs</string>
<string name="selected_count">%d selected</string> <string name="selected_count">%d selected</string>
<string name="not_selected">Not selected</string> <string name="not_selected">Not selected</string>
<string name="browse">Browse</string>
<string name="no_file_manager">No file manager found</string>
</resources> </resources>