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
|
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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue