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