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" />
|
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>
|
||||||
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.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user