From 3edf6c6a8c28a4feac90af51d1189a82991aa0c9 Mon Sep 17 00:00:00 2001 From: Kohei Date: Fri, 27 Feb 2026 12:23:54 +0900 Subject: [PATCH] feat: add SAF directory picker for workspace fields (Step 5) Add "directory" schema type for workspace/data_dir fields in the gateway and render a SAF OpenDocumentTree picker in the Android Config UI. Co-Authored-By: Claude Opus 4.6 --- .../config/ConfigSectionDetailScreen.kt | 86 +++++++++++++++++++ pkg/gateway/schema.go | 13 +++ pkg/gateway/server_test.go | 35 ++++++++ 3 files changed, 134 insertions(+) diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt index 6a509e2e4..bf984dcf3 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt @@ -1,5 +1,10 @@ package io.clawdroid.backend.config +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,8 +42,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.KeyboardType @@ -51,6 +58,7 @@ import io.clawdroid.core.ui.theme.GlassWhite import io.clawdroid.core.ui.theme.NeonCyan import io.clawdroid.core.ui.theme.TextPrimary import io.clawdroid.core.ui.theme.TextSecondary +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -147,6 +155,7 @@ fun ConfigSectionDetailScreen( ConfigField( field = field, onValueChanged = { viewModel.onFieldValueChanged(field.key, it) }, + snackbarHostState = snackbarHostState, ) } } @@ -168,12 +177,14 @@ fun ConfigSectionDetailScreen( private fun ConfigField( field: FieldState, onValueChanged: (String) -> Unit, + snackbarHostState: SnackbarHostState? = null, ) { when (field.type) { "bool" -> BoolField(field, onValueChanged) "int" -> NumberField(field, onValueChanged, KeyboardType.Number) "float" -> NumberField(field, onValueChanged, KeyboardType.Decimal) "[]string" -> StringArrayField(field, onValueChanged) + "directory" -> DirectoryField(field, onValueChanged, snackbarHostState) "map", "[]any" -> ReadOnlyField(field) else -> StringField(field, onValueChanged) } @@ -281,6 +292,81 @@ private fun ReadOnlyField(field: FieldState) { ) } +@Composable +private fun DirectoryField( + field: FieldState, + onValueChanged: (String) -> Unit, + snackbarHostState: SnackbarHostState?, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + val path = safUriToPath(uri) + if (path != null) { + onValueChanged(path) + } else { + scope.launch { + snackbarHostState?.showSnackbar("Internal storage only") + } + } + } + + Column { + OutlinedTextField( + value = field.value, + onValueChange = onValueChanged, + label = { Text(field.label, color = TextSecondary) }, + singleLine = true, + trailingIcon = { + IconButton(onClick = { launcher.launch(null) }) { + Icon( + painter = painterResource(LucideR.drawable.lucide_ic_folder_open), + contentDescription = "Browse", + tint = NeonCyan, + ) + } + }, + colors = configFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Internal storage only for SAF picker", + style = MaterialTheme.typography.labelSmall, + color = TextSecondary.copy(alpha = 0.6f), + modifier = Modifier.padding(start = 16.dp, top = 2.dp), + ) + } +} + +/** + * Converts a SAF tree URI to a filesystem path. + * Only internal storage (`primary:...`) is supported. + */ +private fun safUriToPath(uri: Uri): String? { + val docId = try { + DocumentsContract.getTreeDocumentId(uri) + } catch (_: Exception) { + return null + } + + if (!docId.startsWith("primary:")) return null + + val relativePath = docId.removePrefix("primary:") + return if (relativePath.isEmpty()) { + "/storage/emulated/0" + } else { + "/storage/emulated/0/$relativePath" + } +} + @Composable private fun configFieldColors() = OutlinedTextFieldDefaults.colors( focusedBorderColor = NeonCyan.copy(alpha = 0.5f), diff --git a/pkg/gateway/schema.go b/pkg/gateway/schema.go index e9ea9aba1..f57a0ee65 100644 --- a/pkg/gateway/schema.go +++ b/pkg/gateway/schema.go @@ -44,6 +44,14 @@ var secretKeys = map[string]bool{ "channel_access_token": true, } +// directoryKeys lists full dot-separated JSON keys that represent directory paths. +// Fields matching these keys are reported as type "directory" so that +// Android can render a SAF directory-picker instead of a plain text field. +var directoryKeys = map[string]bool{ + "defaults.workspace": true, + "defaults.data_dir": true, +} + // BuildSchema generates a SchemaResponse by reflecting over a default Config. func BuildSchema(defaultCfg *config.Config) SchemaResponse { var sections []SchemaSection @@ -130,6 +138,11 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField { defVal = fieldVal.Interface() } + // Override type for directory-path fields + if schemaType == "string" && directoryKeys[fullKey] { + schemaType = "directory" + } + fields = append(fields, SchemaField{ Key: fullKey, Label: snakeToTitle(jk), diff --git a/pkg/gateway/server_test.go b/pkg/gateway/server_test.go index 81c350ef8..152e0baaf 100644 --- a/pkg/gateway/server_test.go +++ b/pkg/gateway/server_test.go @@ -2470,6 +2470,41 @@ func TestBuildSchema_AgentsSectionFields(t *testing.T) { } } +// --- #28b: directory type override --- + +func TestBuildSchema_DirectoryType(t *testing.T) { + schema := BuildSchema(config.DefaultConfig()) + + // Find the agents section which contains the defaults sub-fields + var agentsFields []SchemaField + for _, sec := range schema.Sections { + if sec.Key == "agents" { + agentsFields = sec.Fields + break + } + } + if agentsFields == nil { + t.Fatal("agents section not found in schema") + } + + fieldByKey := make(map[string]SchemaField) + for _, f := range agentsFields { + fieldByKey[f.Key] = f + } + + dirFields := []string{"defaults.workspace", "defaults.data_dir"} + for _, k := range dirFields { + f, ok := fieldByKey[k] + if !ok { + t.Errorf("field %q not found in agents section", k) + continue + } + if f.Type != "directory" { + t.Errorf("field %q: want type %q, got %q", k, "directory", f.Type) + } + } +} + // --- #29: rate_limits section fields in schema --- func TestBuildSchema_RateLimitsSectionFields(t *testing.T) {