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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-27 12:23:54 +09:00
parent dc85cdd174
commit 3edf6c6a8c
3 changed files with 134 additions and 0 deletions

View file

@ -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),

View file

@ -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),

View file

@ -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) {