Support for updating app settings

This commit is contained in:
Adam Gastineau 2025-08-13 12:17:48 -07:00
parent 3273b05719
commit 7af56c4f5d
14 changed files with 552 additions and 295 deletions

View File

@ -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() {
<SystemSettings />
<AppSettings />
<MABLStatus />
<ESimSettings onExecuteAction={executeAction} actionResults={actionResults} executionStatus={executionStatus} />
<ESimSettings
onExecuteAction={executeAction}
actionResults={actionResults}
executionStatus={executionStatus}
/>
</div>
</>
)}

View File

@ -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<ActionButtonProps> = ({
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<HTMLInputElement>) =>
handleParameterChange(param.name, e.target.value),
onChange: (value: string) => handleParameterChange(param.name, value),
},
};
@ -96,7 +96,15 @@ const ActionButton: React.FC<ActionButtonProps> = ({
{param.name}
{param.required && <span className="required">*</span>}
</span>
<input {...inputConfig} />
{inputConfig.type === "string" ? (
<TextInput
value={inputConfig.value}
onChange={inputConfig.onChange}
placeholder={inputConfig.placeholder}
/>
) : (
<input {...inputConfig} />
)}
{param.description && (
<span className="parameter-description">{param.description}</span>
)}

View File

@ -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 (
<div className="settings-section">
<h2>App Settings</h2>
<p style={{ color: '#7f8c8d', fontStyle: 'italic' }}>
No app settings registered yet. Apps will appear here when they register settings through the SDK.
<p style={{ color: "#7f8c8d", fontStyle: "italic" }}>
No app settings registered yet. Apps will appear here when they
register settings through the SDK.
</p>
</div>
);
}
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 (
<ToggleSwitch
enabled={value}
onChange={(enabled) => 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 (
<div className="setting-control">
<Slider
value={value}
min={0}
max={max}
onChange={(newValue) => handleAppSettingChange(fullKey, settingKey, newValue)}
/>
<span className="status-display">{value}</span>
</div>
);
} else {
// String setting or unknown - just display for now
return (
<span className="status-display" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{String(value)}
</span>
);
}
};
return (
<div className="settings-section">
<h2>App Settings</h2>
{appSettingsEntries.map(([fullKey, settings]) => {
const appName = fullKey.split('.')[0];
const categoryName = fullKey.split('.').slice(1).join('.');
return (
<div key={fullKey} style={{ marginBottom: '20px' }}>
<h3 style={{
color: '#34495e',
fontSize: '1rem',
marginBottom: '12px',
borderBottom: '1px solid #ecf0f1',
paddingBottom: '4px'
}}>
{appName} - {categoryName}
</h3>
{Object.entries(settings).map(([settingKey, value]) => (
<div key={settingKey} className="setting-item">
<span className="setting-label">{settingKey}</span>
{appSettingsEntries.map(([appId, settings]) => (
<div key={appId} style={{ marginBottom: "20px" }}>
<h3
style={{
color: "#34495e",
fontSize: "1rem",
marginBottom: "12px",
borderBottom: "1px solid #ecf0f1",
paddingBottom: "4px",
}}
>
{appId}
</h3>
{Object.entries(settings).map(([category, categoryContents]) => {
return Object.entries(
categoryContents as Record<string, unknown>
).map(([property, value]) => (
<div key={`${category}.${property}`} className="setting-item">
<span className="setting-label">{property}</span>
<div className="setting-control">
{renderSettingControl(fullKey, settingKey, value)}
<SettingControlView
appId={appId}
category={category}
property={property}
value={value}
/>
</div>
</div>
))}
</div>
);
})}
));
})}
</div>
))}
</div>
);
};
export default AppSettings;
const SettingControlView: React.FC<{
appId: string;
category: string;
property: string;
value: unknown;
}> = ({ appId, category, property, value }) => {
const { updateAppSetting } = useSettings();
if (typeof value === "boolean") {
return (
<ToggleSwitch
enabled={value}
onChange={(enabled) =>
updateAppSetting(appId, category, property, enabled)
}
/>
);
} else if (typeof value === "number") {
return (
<div className="setting-control">
<Slider
value={value}
onChange={(newValue) =>
updateAppSetting(appId, category, property, newValue)
}
/>
<span className="status-display">{value}</span>
</div>
);
} else {
return (
<TextInput
value={String(value)}
onChange={(newValue) =>
updateAppSetting(appId, category, property, newValue)
}
/>
);
}
};

View File

@ -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<SliderProps> = ({
value,
min,
max,
step = 1,
onChange,
disabled = false
const Slider: React.FC<SliderProps> = ({
value,
min,
max,
step = 1,
onChange,
disabled = false,
}) => {
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!disabled) {
@ -38,4 +38,4 @@ const Slider: React.FC<SliderProps> = ({
);
};
export default Slider;
export default Slider;

View File

@ -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<TextInputProps> = ({
value,
onChange,
placeholder = "",
disabled = false,
style = {},
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<input
type="text"
value={value}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
style={defaultStyle}
onFocus={(e) => (e.target.style.borderColor = "#3498db")}
onBlur={(e) => (e.target.style.borderColor = "#bdc3c7")}
/>
);
};
export default TextInput;

View File

@ -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 || {};

View File

@ -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<string, unknown>): void {
executeAction(
appId: string,
action: string,
params: Record<string, unknown>
): void {
this.send({
type: "executeAction",
appId,

View File

@ -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<string, unknown>;
};
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<string, unknown> };
export type StatusMessage =
| { type: 'settingChanged'; category: string; key: string; value: unknown }
| { type: 'statusUpdate'; statusType: string; data: Record<string, unknown> }
| { type: 'allSettings'; settings: Record<string, Record<string, unknown>> }
| { type: 'appStatusUpdate'; appId: string; component: string; data: Record<string, unknown> }
| { type: 'appEvent'; appId: string; eventType: string; payload: Record<string, unknown> }
| { type: 'actionResult'; appId: string; action: string; success: boolean; message?: string; data?: Record<string, unknown>; logs?: LogEntry[] }
| { type: 'actionsRegistered'; appId: string; actions: Record<string, ActionDefinition> }
| { type: 'error'; message: string };
export type StatusMessage =
| { type: "settingChanged"; category: string; key: string; value: unknown }
| { type: "statusUpdate"; statusType: string; data: Record<string, unknown> }
| { type: "allSettings"; settings: Record<string, Record<string, unknown>> }
| {
type: "appStatusUpdate";
appId: string;
component: string;
data: Record<string, unknown>;
}
| {
type: "appEvent";
appId: string;
eventType: string;
payload: Record<string, unknown>;
}
| {
type: "actionResult";
appId: string;
action: string;
success: boolean;
message?: string;
data?: Record<string, unknown>;
logs?: LogEntry[];
}
| {
type: "actionsRegistered";
appId: string;
actions: Record<string, ActionDefinition>;
}
| { 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;
}
}

View File

@ -48,24 +48,24 @@ class EsimSettingsProvider(
}
}
override fun getActionDefinitions(): Map<String, ActionDefinition> {
override fun getActionDefinitions(): Map<String, LocalActionDefinition> {
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(

View File

@ -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<String, Any>
)
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<String, Any>)
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<String, Any>)
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<String, Any>)
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<String, Any>()
)
}
@ -232,10 +238,6 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin
}
}
private fun convertMapPayload(payload: Map<*, *>): Map<String, Any> {
return payload.mapKeys { it.key.toString() }.mapValues { it.value ?: "" }
}
private fun notifySettingsChanged(allSettings: Map<String, Any>) {
// 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)"

View File

@ -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<String, Any>): ActionResult
fun getActionDefinitions(): Map<String, ActionDefinition>
fun getActionDefinitions(): Map<String, LocalActionDefinition>
}
data class ActionDefinition(
val key: String,
val displayText: String,
val parameters: List<ActionParameter> = 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<Any>? = null,
val regex: String? = null
)
data class AppSettingsCategory(
val appId: String,
val category: String,
val definitions: Map<String, SettingDefinition>,
val values: MutableMap<String, Any> = mutableMapOf()
)
@Serializable
data class PersistedSettings(
val systemSettings: Map<String, String> = emptyMap(),
val appSettings: Map<String, Map<String, Map<String, JsonElement>>> = emptyMap()
)
data class ExecutingAction(
val providerId: String,
val actionName: String,
val params: Map<String, Any>,
val startTime: Long
)
class SettingsRegistry(private val context: Context, val shellClient: ShellClient) {
private val appSettings = ConcurrentHashMap<String, MutableMap<String, AppSettingsCategory>>()
private val systemSettings = ConcurrentHashMap<String, Any>()
private val actionProviders = ConcurrentHashMap<String, SettingsActionProvider>()
// 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<String, Any>(
"timestamp" to System.currentTimeMillis(),
"level" to "ERROR",
"message" to "Action execution timeout"
))
"logs" to listOf(
mapOf<String, Any>(
"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<Map<String, Any>>(emptyMap())
val settingsFlow: StateFlow<Map<String, Any>> = _settingsFlow.asStateFlow()
private val _settingsFlow = MutableStateFlow<Map<String, Map<String, Any>>>(emptyMap())
val settingsFlow: StateFlow<Map<String, Map<String, Any>>> = _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<String, Any> {
val result = mutableMapOf<String, Any>()
fun getAllSettings(): Map<String, Map<String, Any>> {
val result = mutableMapOf<String, Map<String, Any>>()
// 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<String, Any>("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<String, Any>
): 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<String, Any>? {
return currentExecutingAction?.let { action ->
mapOf(

View File

@ -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<String, Map<String, JsonElement>>) : StatusMessage()
data class AllSettings(val settings: JsonElement) :
StatusMessage()
@Serializable
@SerialName("appStatusUpdate")
data class AppStatusUpdate(
val appId: String,
val component: String,
val data: Map<String, JsonElement>
@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<String, JsonElement>
@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<String, Any>
val params = message.params.mapValues { (_, value) ->
value.jsonPrimitive.let { primitive ->
@ -412,8 +424,8 @@ class SettingsWebServer(
}
}
private suspend fun broadcastSettingsUpdate(allSettings: Map<String, Any>) {
val message = StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings))
private suspend fun broadcastSettingsUpdate(allSettings: Map<String, Map<String, Any>>) {
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<String, Any?>): Map<String, JsonElement> {
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<String, Any>): Map<String, Map<String, JsonElement>> {
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)

View File

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

View File

@ -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<Any> {
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<Number> {
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<ActionParameter> = 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<String, SettingDefinition>,
val values: MutableMap<String, @Serializable(with = AnyAsJsonElementSerializer::class) Any> = mutableMapOf()
)
@Serializable
data class PersistedSettings(
val systemSettings: Map<String, String> = emptyMap(),
val appSettings: Map<String, Map<String, Map<String, JsonElement>>> = emptyMap()
)
@Serializable
data class ExecutingAction(
val providerId: String,
val actionName: String,
val params: Map<String, @Serializable(with = AnyAsJsonElementSerializer::class) Any>,
val startTime: Long
)