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:
@@ -225,6 +225,17 @@
|
||||
android:resource="@xml/cache_paths" />
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
187
app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt
Normal file
187
app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,4 +348,6 @@
|
||||
<string name="clear_logs">Clear Logs</string>
|
||||
<string name="selected_count">%d 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>
|
||||
Reference in New Issue
Block a user