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:
parent
dc85cdd174
commit
3edf6c6a8c
3 changed files with 134 additions and 0 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue