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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -37,8 +42,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType 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.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary import io.clawdroid.core.ui.theme.TextSecondary
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -147,6 +155,7 @@ fun ConfigSectionDetailScreen(
ConfigField( ConfigField(
field = field, field = field,
onValueChanged = { viewModel.onFieldValueChanged(field.key, it) }, onValueChanged = { viewModel.onFieldValueChanged(field.key, it) },
snackbarHostState = snackbarHostState,
) )
} }
} }
@ -168,12 +177,14 @@ fun ConfigSectionDetailScreen(
private fun ConfigField( private fun ConfigField(
field: FieldState, field: FieldState,
onValueChanged: (String) -> Unit, onValueChanged: (String) -> Unit,
snackbarHostState: SnackbarHostState? = null,
) { ) {
when (field.type) { when (field.type) {
"bool" -> BoolField(field, onValueChanged) "bool" -> BoolField(field, onValueChanged)
"int" -> NumberField(field, onValueChanged, KeyboardType.Number) "int" -> NumberField(field, onValueChanged, KeyboardType.Number)
"float" -> NumberField(field, onValueChanged, KeyboardType.Decimal) "float" -> NumberField(field, onValueChanged, KeyboardType.Decimal)
"[]string" -> StringArrayField(field, onValueChanged) "[]string" -> StringArrayField(field, onValueChanged)
"directory" -> DirectoryField(field, onValueChanged, snackbarHostState)
"map", "[]any" -> ReadOnlyField(field) "map", "[]any" -> ReadOnlyField(field)
else -> StringField(field, onValueChanged) 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 @Composable
private fun configFieldColors() = OutlinedTextFieldDefaults.colors( private fun configFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NeonCyan.copy(alpha = 0.5f), focusedBorderColor = NeonCyan.copy(alpha = 0.5f),

View file

@ -44,6 +44,14 @@ var secretKeys = map[string]bool{
"channel_access_token": true, "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. // BuildSchema generates a SchemaResponse by reflecting over a default Config.
func BuildSchema(defaultCfg *config.Config) SchemaResponse { func BuildSchema(defaultCfg *config.Config) SchemaResponse {
var sections []SchemaSection var sections []SchemaSection
@ -130,6 +138,11 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
defVal = fieldVal.Interface() defVal = fieldVal.Interface()
} }
// Override type for directory-path fields
if schemaType == "string" && directoryKeys[fullKey] {
schemaType = "directory"
}
fields = append(fields, SchemaField{ fields = append(fields, SchemaField{
Key: fullKey, Key: fullKey,
Label: snakeToTitle(jk), 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 --- // --- #29: rate_limits section fields in schema ---
func TestBuildSchema_RateLimitsSectionFields(t *testing.T) { func TestBuildSchema_RateLimitsSectionFields(t *testing.T) {