diff --git a/bridge-settings/react-app/src/App.tsx b/bridge-settings/react-app/src/App.tsx index d02ceac..fabac64 100644 --- a/bridge-settings/react-app/src/App.tsx +++ b/bridge-settings/react-app/src/App.tsx @@ -2,12 +2,19 @@ import { useSettings } from "./hooks/useSettings"; import ConnectionStatus from "./components/ConnectionStatus"; import SystemStatus from "./components/SystemStatus"; import SystemSettings from "./components/SystemSettings"; -import AppSettings from "./components/AppSettings"; +import { AppSettings } from "./components/AppSettings"; import MABLStatus from "./components/MABLStatus"; import ESimSettings from "./components/ESimSettings"; function App() { - const { loading, error, connected, executeAction, actionResults, executionStatus } = useSettings(); + const { + loading, + error, + connected, + executeAction, + actionResults, + executionStatus, + } = useSettings(); if (loading) { return ( @@ -36,7 +43,11 @@ function App() { - + )} diff --git a/bridge-settings/react-app/src/components/ActionButton.tsx b/bridge-settings/react-app/src/components/ActionButton.tsx index 2d40dc9..0b8596d 100644 --- a/bridge-settings/react-app/src/components/ActionButton.tsx +++ b/bridge-settings/react-app/src/components/ActionButton.tsx @@ -6,6 +6,7 @@ import { LogEntry, ExecutionStatus, } from "../types/settings"; +import TextInput from "./TextInput"; interface ActionButtonProps { appId: string; @@ -74,11 +75,10 @@ const ActionButton: React.FC = ({ handleParameterChange(param.name, parseFloat(e.target.value) || 0), }, string: { - type: "text" as const, + type: "string" as const, value: String(currentValue), placeholder: param.description, - onChange: (e: React.ChangeEvent) => - handleParameterChange(param.name, e.target.value), + onChange: (value: string) => handleParameterChange(param.name, value), }, }; @@ -96,7 +96,15 @@ const ActionButton: React.FC = ({ {param.name} {param.required && *} - + {inputConfig.type === "string" ? ( + + ) : ( + + )} {param.description && ( {param.description} )} diff --git a/bridge-settings/react-app/src/components/AppSettings.tsx b/bridge-settings/react-app/src/components/AppSettings.tsx index 6df3424..5f1a8e7 100644 --- a/bridge-settings/react-app/src/components/AppSettings.tsx +++ b/bridge-settings/react-app/src/components/AppSettings.tsx @@ -1,104 +1,105 @@ -import React from 'react'; -import { useSettings } from '../hooks/useSettings'; -import ToggleSwitch from './ToggleSwitch'; -import Slider from './Slider'; +import React from "react"; +import { useSettings } from "../hooks/useSettings"; +import ToggleSwitch from "./ToggleSwitch"; +import Slider from "./Slider"; +import TextInput from "./TextInput"; -const AppSettings: React.FC = () => { - const { allSettings, updateAppSetting } = useSettings(); +export const AppSettings: React.FC = () => { + const { allSettings } = useSettings(); // Extract app settings (anything that's not 'system') const appSettingsEntries = Object.entries(allSettings).filter( - ([key]) => key !== 'system' + ([key]) => key !== "system" ); if (appSettingsEntries.length === 0) { return (

App Settings

-

- No app settings registered yet. Apps will appear here when they register settings through the SDK. +

+ No app settings registered yet. Apps will appear here when they + register settings through the SDK.

); } - const handleAppSettingChange = (fullKey: string, settingKey: string, value: unknown) => { - // Parse the full key to get appId and category - const parts = fullKey.split('.'); - if (parts.length >= 2) { - const appId = parts[0]; - const category = parts.slice(1).join('.'); - updateAppSetting(appId, category, settingKey, value); - } - }; - - const renderSettingControl = (fullKey: string, settingKey: string, value: unknown) => { - // Try to determine the setting type from the value - if (typeof value === 'boolean') { - // Boolean setting - return ( - handleAppSettingChange(fullKey, settingKey, enabled)} - /> - ); - } else if (typeof value === 'number') { - // Numeric setting - treat as slider with reasonable defaults - const max = Math.max(100, value * 2); // Reasonable max - return ( -
- handleAppSettingChange(fullKey, settingKey, newValue)} - /> - {value} -
- ); - } else { - // String setting or unknown - just display for now - return ( - - {String(value)} - - ); - } - }; - return (

App Settings

- - {appSettingsEntries.map(([fullKey, settings]) => { - const appName = fullKey.split('.')[0]; - const categoryName = fullKey.split('.').slice(1).join('.'); - - return ( -
-

- {appName} - {categoryName} -

- - {Object.entries(settings).map(([settingKey, value]) => ( -
- {settingKey} + {appSettingsEntries.map(([appId, settings]) => ( +
+

+ {appId} +

+ {Object.entries(settings).map(([category, categoryContents]) => { + return Object.entries( + categoryContents as Record + ).map(([property, value]) => ( +
+ {property}
- {renderSettingControl(fullKey, settingKey, value)} +
- ))} -
- ); - })} + )); + })} +
+ ))}
); }; -export default AppSettings; \ No newline at end of file +const SettingControlView: React.FC<{ + appId: string; + category: string; + property: string; + value: unknown; +}> = ({ appId, category, property, value }) => { + const { updateAppSetting } = useSettings(); + + if (typeof value === "boolean") { + return ( + + updateAppSetting(appId, category, property, enabled) + } + /> + ); + } else if (typeof value === "number") { + return ( +
+ + updateAppSetting(appId, category, property, newValue) + } + /> + {value} +
+ ); + } else { + return ( + + updateAppSetting(appId, category, property, newValue) + } + /> + ); + } +}; diff --git a/bridge-settings/react-app/src/components/Slider.tsx b/bridge-settings/react-app/src/components/Slider.tsx index 176cf44..bd300ee 100644 --- a/bridge-settings/react-app/src/components/Slider.tsx +++ b/bridge-settings/react-app/src/components/Slider.tsx @@ -1,21 +1,21 @@ -import React from 'react'; +import React from "react"; interface SliderProps { value: number; - min: number; - max: number; + min?: number; + max?: number; step?: number; onChange: (value: number) => void; disabled?: boolean; } -const Slider: React.FC = ({ - value, - min, - max, - step = 1, - onChange, - disabled = false +const Slider: React.FC = ({ + value, + min, + max, + step = 1, + onChange, + disabled = false, }) => { const handleChange = (event: React.ChangeEvent) => { if (!disabled) { @@ -38,4 +38,4 @@ const Slider: React.FC = ({ ); }; -export default Slider; \ No newline at end of file +export default Slider; diff --git a/bridge-settings/react-app/src/components/TextInput.tsx b/bridge-settings/react-app/src/components/TextInput.tsx new file mode 100644 index 0000000..f5ba606 --- /dev/null +++ b/bridge-settings/react-app/src/components/TextInput.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +interface TextInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + style?: React.CSSProperties; +} + +const TextInput: React.FC = ({ + value, + onChange, + placeholder = "", + disabled = false, + style = {}, +}) => { + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + const defaultStyle: React.CSSProperties = { + padding: "4px 8px", + border: "1px solid #bdc3c7", + borderRadius: "3px", + fontSize: "0.9rem", + minWidth: "120px", + maxWidth: "200px", + outline: "none", + ...style, + }; + + return ( + (e.target.style.borderColor = "#3498db")} + onBlur={(e) => (e.target.style.borderColor = "#bdc3c7")} + /> + ); +}; + +export default TextInput; diff --git a/bridge-settings/react-app/src/hooks/useSettings.ts b/bridge-settings/react-app/src/hooks/useSettings.ts index 3bb7969..deb9e50 100644 --- a/bridge-settings/react-app/src/hooks/useSettings.ts +++ b/bridge-settings/react-app/src/hooks/useSettings.ts @@ -127,18 +127,15 @@ export function useSettings() { } }, [lastMessage]); - const updateSystemSetting = (key: string, value: unknown) => { - updateSetting("system", key, value); - }; + const updateSystemSetting = (key: string, value: unknown) => + updateSetting("system", "main", key, value); const updateAppSetting = ( appId: string, category: string, key: string, value: unknown - ) => { - updateSetting(`${appId}.${category}`, key, value); - }; + ) => updateSetting(appId, category, key, value); const getSystemSettings = () => { return allSettings.system || {}; diff --git a/bridge-settings/react-app/src/services/websocketService.ts b/bridge-settings/react-app/src/services/websocketService.ts index 0bc50eb..2c6a09b 100644 --- a/bridge-settings/react-app/src/services/websocketService.ts +++ b/bridge-settings/react-app/src/services/websocketService.ts @@ -118,9 +118,15 @@ export class WebSocketService { } } - updateSetting(category: string, key: string, value: unknown): void { + updateSetting( + appId: string, + category: string, + key: string, + value: unknown + ): void { this.send({ type: "updateSetting", + appId, category, key, value, @@ -134,7 +140,11 @@ export class WebSocketService { }); } - executeAction(appId: string, action: string, params: Record): void { + executeAction( + appId: string, + action: string, + params: Record + ): void { this.send({ type: "executeAction", appId, diff --git a/bridge-settings/react-app/src/types/settings.ts b/bridge-settings/react-app/src/types/settings.ts index 027690a..925956e 100644 --- a/bridge-settings/react-app/src/types/settings.ts +++ b/bridge-settings/react-app/src/types/settings.ts @@ -1,25 +1,56 @@ -// Message types for WebSocket communication with Kotlin backend +export type SettingsMessage = + | { + type: "updateSetting"; + appId: string; + category: string; + key: string; + value: unknown; + } + | { type: "registerForUpdates"; categories: string[] } + | { type: "getAllSettings" } + | { + type: "executeAction"; + appId: string; + action: string; + params: Record; + }; -export type SettingsMessage = - | { type: 'updateSetting'; category: string; key: string; value: unknown } - | { type: 'registerForUpdates'; categories: string[] } - | { type: 'getAllSettings' } - | { type: 'executeAction'; appId: string; action: string; params: Record }; - -export type StatusMessage = - | { type: 'settingChanged'; category: string; key: string; value: unknown } - | { type: 'statusUpdate'; statusType: string; data: Record } - | { type: 'allSettings'; settings: Record> } - | { type: 'appStatusUpdate'; appId: string; component: string; data: Record } - | { type: 'appEvent'; appId: string; eventType: string; payload: Record } - | { type: 'actionResult'; appId: string; action: string; success: boolean; message?: string; data?: Record; logs?: LogEntry[] } - | { type: 'actionsRegistered'; appId: string; actions: Record } - | { type: 'error'; message: string }; +export type StatusMessage = + | { type: "settingChanged"; category: string; key: string; value: unknown } + | { type: "statusUpdate"; statusType: string; data: Record } + | { type: "allSettings"; settings: Record> } + | { + type: "appStatusUpdate"; + appId: string; + component: string; + data: Record; + } + | { + type: "appEvent"; + appId: string; + eventType: string; + payload: Record; + } + | { + type: "actionResult"; + appId: string; + action: string; + success: boolean; + message?: string; + data?: Record; + logs?: LogEntry[]; + } + | { + type: "actionsRegistered"; + appId: string; + actions: Record; + } + | { type: "error"; message: string }; export interface Setting { key: string; value: unknown; - type: 'boolean' | 'integer' | 'string' | 'float' | 'action'; + type: "boolean" | "integer" | "string" | "float" | "action"; defaultValue: unknown; validation?: { min?: number; @@ -38,7 +69,7 @@ export interface ActionDefinition { export interface ActionParameter { name: string; - type: 'boolean' | 'integer' | 'string' | 'float'; + type: "boolean" | "integer" | "string" | "float"; required: boolean; defaultValue?: unknown; description?: string; @@ -46,7 +77,7 @@ export interface ActionParameter { export interface LogEntry { timestamp: number; - level: 'INFO' | 'WARNING' | 'ERROR' | 'DEBUG'; + level: "INFO" | "WARNING" | "ERROR" | "DEBUG"; message: string; } @@ -98,4 +129,4 @@ export interface ConnectionState { connected: boolean; connecting: boolean; error: string | null; -} \ No newline at end of file +} diff --git a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/EsimSettingsProvider.kt b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/EsimSettingsProvider.kt index 615df10..ff4bad8 100644 --- a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/EsimSettingsProvider.kt +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/EsimSettingsProvider.kt @@ -48,24 +48,24 @@ class EsimSettingsProvider( } } - override fun getActionDefinitions(): Map { + override fun getActionDefinitions(): Map { return mapOf( - "getProfiles" to ActionDefinition( + "getProfiles" to LocalActionDefinition( key = "getProfiles", displayText = "List eSIM Profiles", description = "Retrieve all eSIM profiles on the device" ), - "getActiveProfile" to ActionDefinition( + "getActiveProfile" to LocalActionDefinition( key = "getActiveProfile", displayText = "Get Active Profile", description = "Get the currently active eSIM profile" ), - "getEid" to ActionDefinition( + "getEid" to LocalActionDefinition( key = "getEid", displayText = "Get Device EID", description = "Retrieve the device's embedded identity document (EID)" ), - "enableProfile" to ActionDefinition( + "enableProfile" to LocalActionDefinition( key = "enableProfile", displayText = "Enable Profile", parameters = listOf( @@ -78,7 +78,7 @@ class EsimSettingsProvider( ), description = "Enable an eSIM profile by ICCID" ), - "disableProfile" to ActionDefinition( + "disableProfile" to LocalActionDefinition( key = "disableProfile", displayText = "Disable Profile", parameters = listOf( @@ -91,7 +91,7 @@ class EsimSettingsProvider( ), description = "Disable an eSIM profile by ICCID" ), - "deleteProfile" to ActionDefinition( + "deleteProfile" to LocalActionDefinition( key = "deleteProfile", displayText = "Delete Profile", parameters = listOf( @@ -104,7 +104,7 @@ class EsimSettingsProvider( ), description = "Permanently delete an eSIM profile" ), - "setNickname" to ActionDefinition( + "setNickname" to LocalActionDefinition( key = "setNickname", displayText = "Set Profile Nickname", parameters = listOf( @@ -123,7 +123,7 @@ class EsimSettingsProvider( ), description = "Set a custom nickname for an eSIM profile" ), - "downloadProfile" to ActionDefinition( + "downloadProfile" to LocalActionDefinition( key = "downloadProfile", displayText = "Download Profile", parameters = listOf( @@ -136,7 +136,7 @@ class EsimSettingsProvider( ), description = "Download a new eSIM profile from activation code" ), - "downloadAndEnableProfile" to ActionDefinition( + "downloadAndEnableProfile" to LocalActionDefinition( key = "downloadAndEnableProfile", displayText = "Download & Enable Profile", parameters = listOf( diff --git a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsProvider.kt b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsProvider.kt index 7cfa70d..bd2892d 100644 --- a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsProvider.kt +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsProvider.kt @@ -123,8 +123,11 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin override fun sendAppStatusUpdate(appId: String, component: String, payload: Map<*, *>) { providerScope.launch { try { - val convertedPayload = convertMapPayload(payload) - settingsRegistry.sendAppStatusUpdate(appId, component, convertedPayload) + settingsRegistry.sendAppStatusUpdate( + appId, + component, + payload as Map + ) Log.d(TAG, "Sent app status update: $appId.$component") } catch (e: Exception) { Log.e(TAG, "Error sending app status update: $appId.$component", e) @@ -135,8 +138,7 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin override fun sendAppEvent(appId: String, eventType: String, payload: Map<*, *>) { providerScope.launch { try { - val convertedPayload = convertMapPayload(payload) - settingsRegistry.sendAppEvent(appId, eventType, convertedPayload) + settingsRegistry.sendAppEvent(appId, eventType, payload as Map) Log.d(TAG, "Sent app event: $appId.$eventType") } catch (e: Exception) { Log.e(TAG, "Error sending app event: $appId.$eventType", e) @@ -147,8 +149,7 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin override fun executeAction(appId: String, action: String, params: Map<*, *>) { providerScope.launch { try { - val convertedParams = convertMapPayload(params) - settingsRegistry.executeAction(appId, action, convertedParams) + settingsRegistry.executeAction(appId, action, params as Map) Log.i(TAG, "Executed action: $appId.$action") } catch (e: Exception) { Log.e(TAG, "Error executing action: $appId.$action", e) @@ -156,19 +157,24 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin } } - override fun executeActionWithCallback(appId: String, action: String, params: Map<*, *>, callback: ISettingsCallback) { + override fun executeActionWithCallback( + appId: String, + action: String, + params: Map<*, *>, + callback: ISettingsCallback + ) { providerScope.launch { try { - val convertedParams = convertMapPayload(params) - val result = settingsRegistry.executeAction(appId, action, convertedParams) + val result = + settingsRegistry.executeAction(appId, action, params as Map) Log.i(TAG, "Executed action with callback: $appId.$action") - + safeCallback(TAG) { callback.onActionResult( - appId, - action, - result.success, - result.message ?: "", + appId, + action, + result.success, + result.message ?: "", result.data ?: emptyMap() ) } @@ -232,10 +238,6 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin } } - private fun convertMapPayload(payload: Map<*, *>): Map { - return payload.mapKeys { it.key.toString() }.mapValues { it.value ?: "" } - } - private fun notifySettingsChanged(allSettings: Map) { // This would be implemented to notify specific callbacks about relevant changes // For now, we'll skip detailed change detection @@ -328,7 +330,7 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin } } } - + com.penumbraos.bridge.types.AppActionInfo().apply { this.appId = appId this.actions = actionDefinitions @@ -339,7 +341,7 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin emptyList() } } - + private fun getSystemSettingDescription(key: String): String { return when (key) { "audio.volume" -> "Audio volume level (0-100)" diff --git a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsRegistry.kt b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsRegistry.kt index 5f3df27..f00c230 100644 --- a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsRegistry.kt +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsRegistry.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @@ -47,67 +46,14 @@ enum class LogLevel { interface SettingsActionProvider { suspend fun executeAction(action: String, params: Map): ActionResult - fun getActionDefinitions(): Map + fun getActionDefinitions(): Map } -data class ActionDefinition( - val key: String, - val displayText: String, - val parameters: List = emptyList(), - val description: String? = null -) - -data class ActionParameter( - val name: String, - val type: SettingType, - val required: Boolean = true, - val defaultValue: Any? = null, - val description: String? = null -) - -data class SettingDefinition( - val key: String, - val type: SettingType, - val defaultValue: Any, - val validation: SettingValidation? = null -) - -enum class SettingType { - BOOLEAN, INTEGER, STRING, FLOAT, ACTION -} - -data class SettingValidation( - val min: Number? = null, - val max: Number? = null, - val allowedValues: List? = null, - val regex: String? = null -) - -data class AppSettingsCategory( - val appId: String, - val category: String, - val definitions: Map, - val values: MutableMap = mutableMapOf() -) - -@Serializable -data class PersistedSettings( - val systemSettings: Map = emptyMap(), - val appSettings: Map>> = emptyMap() -) - -data class ExecutingAction( - val providerId: String, - val actionName: String, - val params: Map, - val startTime: Long -) - class SettingsRegistry(private val context: Context, val shellClient: ShellClient) { private val appSettings = ConcurrentHashMap>() private val systemSettings = ConcurrentHashMap() private val actionProviders = ConcurrentHashMap() - + // Execution state tracking @Volatile private var currentExecutingAction: ExecutingAction? = null @@ -117,14 +63,14 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien companion object { private const val EXECUTION_TIMEOUT_MS = 30000L // 30 seconds } - + private fun startExecutionTimeout(appId: String, action: String) { clearState() - + executionTimeoutRunnable = Runnable { Log.w(TAG, "Action execution timeout: $appId.$action") currentExecutingAction = null - + // Broadcast timeout error registryScope.launch { sendAppEvent( @@ -132,20 +78,22 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien "action" to action, "success" to false, "message" to "Action timed out after ${EXECUTION_TIMEOUT_MS / 1000} seconds", - "logs" to listOf(mapOf( - "timestamp" to System.currentTimeMillis(), - "level" to "ERROR", - "message" to "Action execution timeout" - )) + "logs" to listOf( + mapOf( + "timestamp" to System.currentTimeMillis(), + "level" to "ERROR", + "message" to "Action execution timeout" + ) + ) ) ) } } - + executionTimeoutHandler.postDelayed(executionTimeoutRunnable!!, EXECUTION_TIMEOUT_MS) Log.d(TAG, "Started execution timeout for: $appId.$action") } - + private fun clearState() { currentExecutingAction = null executionTimeoutRunnable?.let { @@ -165,8 +113,8 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien // Reference to web server for broadcasting (set by SettingsService) private var webServer: SettingsWebServer? = null - private val _settingsFlow = MutableStateFlow>(emptyMap()) - val settingsFlow: StateFlow> = _settingsFlow.asStateFlow() + private val _settingsFlow = MutableStateFlow>>(emptyMap()) + val settingsFlow: StateFlow>> = _settingsFlow.asStateFlow() @SuppressLint("SdCardPath") private val settingsFile = File("/sdcard/penumbra/etc/settings.json") @@ -437,8 +385,8 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien return systemSettings.toMap() } - fun getAllSettings(): Map { - val result = mutableMapOf() + fun getAllSettings(): Map> { + val result = mutableMapOf>() // Add system settings result["system"] = systemSettings.toMap() @@ -451,7 +399,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien } result[appId] = appData } - + // Add current execution status getCurrentExecutionStatus()?.let { executionStatus -> result["executionStatus"] = executionStatus @@ -555,7 +503,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien // Broadcast available actions to web UI registryScope.launch { val actions = provider.getActionDefinitions() - sendAppEvent(appId, "actionsRegistered", mapOf("actions" to actions)) + sendAppEvent(appId, "actionsRegistered", mapOf("actions" to actions)) } } @@ -570,7 +518,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien params: Map ): ActionResult { Log.i(TAG, "Executing action: $appId.$action with params: $params") - + currentExecutingAction = ExecutingAction(appId, action, params, System.currentTimeMillis()) startExecutionTimeout(appId, action) @@ -596,7 +544,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien "logs" to (errorResult.logs ?: emptyList()) ) ) - + clearState() return errorResult @@ -616,7 +564,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien "logs" to (result.logs ?: emptyList()) ) ) - + clearState() result @@ -637,7 +585,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien "logs" to (errorResult.logs ?: emptyList()) ) ) - + clearState() errorResult @@ -647,7 +595,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien fun getActionProvider(appId: String): SettingsActionProvider? { return actionProviders[appId] } - + fun getCurrentExecutionStatus(): Map? { return currentExecutingAction?.let { action -> mapOf( diff --git a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsWebServer.kt b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsWebServer.kt index b861999..0aee002 100644 --- a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsWebServer.kt +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsWebServer.kt @@ -3,6 +3,7 @@ package com.penumbraos.bridge_settings import android.util.Log +import com.penumbraos.bridge_settings.json.toJsonElement import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json @@ -32,12 +33,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.serialization.Contextual import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.intOrNull @@ -52,7 +53,12 @@ private const val TAG = "SettingsWebServer" sealed class SettingsMessage { @Serializable @SerialName("updateSetting") - data class UpdateSetting(val category: String, val key: String, val value: JsonElement) : + data class UpdateSetting( + val appId: String, + val category: String, + val key: String, + val value: JsonElement + ) : SettingsMessage() @Serializable @@ -86,14 +92,16 @@ sealed class StatusMessage { @Serializable @SerialName("allSettings") - data class AllSettings(val settings: Map>) : StatusMessage() + data class AllSettings(val settings: JsonElement) : + StatusMessage() @Serializable @SerialName("appStatusUpdate") data class AppStatusUpdate( val appId: String, val component: String, - val data: Map + @Contextual + val data: JsonElement ) : StatusMessage() @Serializable @@ -101,7 +109,8 @@ sealed class StatusMessage { data class AppEvent( val appId: String, val eventType: String, - val payload: Map + @Contextual + val payload: JsonElement ) : StatusMessage() @Serializable @@ -321,7 +330,7 @@ class SettingsWebServer( val allSettings = settingsRegistry.getAllSettings() sendToSession( session, - StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings)) + StatusMessage.AllSettings(allSettings.toJsonElement()) ) for (frame in session.incoming) { @@ -358,7 +367,7 @@ class SettingsWebServer( val allSettings = settingsRegistry.getAllSettings() sendToSession( session, - StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings)) + StatusMessage.AllSettings(allSettings.toJsonElement()) ) } @@ -367,12 +376,15 @@ class SettingsWebServer( primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.doubleOrNull ?: primitive.content } - val success = if (message.category == "system") { + val success = if (message.appId == "system") { settingsRegistry.updateSystemSetting(message.key, convertedValue) } else { - // For app settings, we'd need to parse the category to get appId - // This is simplified - in a real implementation, the message format would include appId - false + settingsRegistry.updateAppSetting( + message.appId, + message.category, + message.key, + convertedValue + ) } if (!success) { @@ -389,7 +401,7 @@ class SettingsWebServer( is SettingsMessage.ExecuteAction -> { try { Log.i(TAG, "Executing action: ${message.appId}.${message.action}") - + // Convert JsonElement parameters to Map val params = message.params.mapValues { (_, value) -> value.jsonPrimitive.let { primitive -> @@ -412,8 +424,8 @@ class SettingsWebServer( } } - private suspend fun broadcastSettingsUpdate(allSettings: Map) { - val message = StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings)) + private suspend fun broadcastSettingsUpdate(allSettings: Map>) { + val message = StatusMessage.AllSettings(allSettings.toJsonElement()) broadcast(message) } @@ -425,7 +437,7 @@ class SettingsWebServer( val message = StatusMessage.AppStatusUpdate( appId = appId, component = component, - data = convertValuesToJsonElements(payload) + data = payload.toJsonElement() ) broadcast(message) Log.d(TAG, "Broadcasted app status update: $appId.$component") @@ -435,7 +447,7 @@ class SettingsWebServer( val message = StatusMessage.AppEvent( appId = appId, eventType = eventType, - payload = convertValuesToJsonElements(payload) + payload = payload.toJsonElement() ) broadcast(message) Log.d(TAG, "Broadcasted app event: $appId.$eventType") @@ -465,53 +477,6 @@ class SettingsWebServer( } } - private fun convertValuesToJsonElements(data: Map): Map { - return data.mapValues { (_, value) -> - when (value) { - null -> JsonPrimitive(null) - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is String -> JsonPrimitive(value) - is List<*> -> { - try { - JsonPrimitive(Json.encodeToString(value)) - } catch (_: Exception) { - JsonPrimitive(value.toString()) - } - } - - is Map<*, *> -> { - try { - JsonPrimitive(Json.encodeToString(value)) - } catch (_: Exception) { - JsonPrimitive(value.toString()) - } - } - - else -> JsonPrimitive(value.toString()) - } - } - } - - private fun convertToJsonCompatibleMap(input: Map): Map> { - return input.mapValues { (_, value) -> - when (value) { - is Map<*, *> -> convertValuesToJsonElements( - value.mapKeys { it.key.toString() }.mapValues { it.value ?: "" } - ) - - else -> mapOf( - "value" to when (value) { - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - is String -> JsonPrimitive(value) - else -> JsonPrimitive(value.toString()) - } - ) - } - } - } - private fun getResourceFromApk(resourcePath: String): InputStream? { return try { this::class.java.classLoader?.getResourceAsStream(resourcePath) diff --git a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/json/ToJson.kt b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/json/ToJson.kt new file mode 100644 index 0000000..b527b9c --- /dev/null +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/json/ToJson.kt @@ -0,0 +1,116 @@ +@file:OptIn(ExperimentalUnsignedTypes::class, ExperimentalSerializationApi::class) + +package com.penumbraos.bridge_settings.json + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.serializerOrNull + +internal fun Any?.toJsonElement(): JsonElement { + val serializer = this?.let { Json.serializersModule.serializerOrNull(this::class.java) } + + return when { + this == null -> JsonNull + serializer != null -> Json.encodeToJsonElement(serializer, this) + this is Map<*, *> -> toJsonElement() + this is Array<*> -> toJsonElement() + this is BooleanArray -> toJsonElement() + this is ByteArray -> toJsonElement() + this is CharArray -> toJsonElement() + this is ShortArray -> toJsonElement() + this is IntArray -> toJsonElement() + this is LongArray -> toJsonElement() + this is FloatArray -> toJsonElement() + this is DoubleArray -> toJsonElement() + this is UByteArray -> toJsonElement() + this is UShortArray -> toJsonElement() + this is UIntArray -> toJsonElement() + this is ULongArray -> toJsonElement() + this is Collection<*> -> toJsonElement() + this is Boolean -> JsonPrimitive(this) + this is Number -> JsonPrimitive(this) + this is String -> JsonPrimitive(this) + this is Enum<*> -> JsonPrimitive(this.name) + this is Pair<*, *> -> JsonObject( + mapOf( + "first" to first.toJsonElement(), + "second" to second.toJsonElement(), + ) + ) + + this is Triple<*, *, *> -> JsonObject( + mapOf( + "first" to first.toJsonElement(), + "second" to second.toJsonElement(), + "third" to third.toJsonElement(), + ) + ) + + else -> error("Can't serialize '$this' as it is of an unknown type") + } +} + +internal fun Map<*, *>.toJsonElement(): JsonElement { + return buildJsonObject { + forEach { (key, value) -> + if (key !is String) + error("Only string keys are supported for maps") + + put(key, value.toJsonElement()) + } + } +} + +internal fun Collection<*>.toJsonElement(): JsonElement = buildJsonArray { + forEach { element -> + add(element.toJsonElement()) + } +} + +internal fun Array<*>.toJsonElement(): JsonElement = buildJsonArray { + forEach { element -> + add(element.toJsonElement()) + } +} + +internal fun BooleanArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun ByteArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun CharArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it.toString())) } } + +internal fun ShortArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun IntArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun LongArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun FloatArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun DoubleArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun UByteArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun UShortArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun UIntArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } + +internal fun ULongArray.toJsonElement(): JsonElement = + buildJsonArray { forEach { add(JsonPrimitive(it)) } } \ No newline at end of file diff --git a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/types.kt b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/types.kt new file mode 100644 index 0000000..244cad3 --- /dev/null +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/types.kt @@ -0,0 +1,121 @@ +package com.penumbraos.bridge_settings + +import com.penumbraos.bridge_settings.json.toJsonElement +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.double +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.float +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull + +object AnyAsJsonElementSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Any") + + override fun serialize(encoder: Encoder, value: Any) { + val jsonElement = value.toJsonElement() + encoder.encodeSerializableValue(JsonElement.serializer(), jsonElement) + } + + override fun deserialize(decoder: Decoder): Any { + val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer()) + return when { + jsonElement.jsonPrimitive.isString -> jsonElement.jsonPrimitive.content + else -> jsonElement.jsonPrimitive.content + } + } +} + +object NumberAsJsonElementSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Number") + + override fun serialize(encoder: Encoder, value: Number) { + val jsonElement = value.toJsonElement() + encoder.encodeSerializableValue(JsonElement.serializer(), jsonElement) + } + + override fun deserialize(decoder: Decoder): Number { + val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer()) + val primitive = jsonElement.jsonPrimitive + return when { + primitive.intOrNull != null -> primitive.int + primitive.longOrNull != null -> primitive.long + primitive.floatOrNull != null -> primitive.float + primitive.doubleOrNull != null -> primitive.double + else -> primitive.content.toDoubleOrNull() ?: 0.0 + } + } +} + +@Serializable +data class LocalActionDefinition( + val key: String, + val displayText: String, + val parameters: List = emptyList(), + val description: String? = null +) + +@Serializable +data class ActionParameter( + val name: String, + val type: SettingType, + val required: Boolean = true, + @Serializable(with = AnyAsJsonElementSerializer::class) + val defaultValue: Any? = null, + val description: String? = null +) + +@Serializable +data class SettingDefinition( + val key: String, + val type: SettingType, + @Serializable(with = AnyAsJsonElementSerializer::class) + val defaultValue: Any, + val validation: SettingValidation? = null +) + +@Serializable +enum class SettingType { + BOOLEAN, INTEGER, STRING, FLOAT, ACTION +} + +@Serializable +data class SettingValidation( + @Serializable(with = NumberAsJsonElementSerializer::class) + val min: Number? = null, + @Serializable(with = NumberAsJsonElementSerializer::class) + val max: Number? = null, + val allowedValues: List<@Serializable(with = AnyAsJsonElementSerializer::class) Any>? = null, + val regex: String? = null +) + +@Serializable +data class AppSettingsCategory( + val appId: String, + val category: String, + val definitions: Map, + val values: MutableMap = mutableMapOf() +) + +@Serializable +data class PersistedSettings( + val systemSettings: Map = emptyMap(), + val appSettings: Map>> = emptyMap() +) + +@Serializable +data class ExecutingAction( + val providerId: String, + val actionName: String, + val params: Map, + val startTime: Long +)