diff --git a/bridge-settings/react-app/src/App.tsx b/bridge-settings/react-app/src/App.tsx index 70f16b4..ed754b1 100644 --- a/bridge-settings/react-app/src/App.tsx +++ b/bridge-settings/react-app/src/App.tsx @@ -4,9 +4,10 @@ import SystemStatus from "./components/SystemStatus"; import SystemSettings from "./components/SystemSettings"; import AppSettings from "./components/AppSettings"; import MABLStatus from "./components/MABLStatus"; +import ESimSettings from "./components/ESimSettings"; function App() { - const { loading, error, connected } = useSettings(); + const { loading, error, connected, executeAction, actionResults } = useSettings(); if (loading) { return ( @@ -35,6 +36,7 @@ function App() { + )} diff --git a/bridge-settings/react-app/src/components/ActionButton.tsx b/bridge-settings/react-app/src/components/ActionButton.tsx new file mode 100644 index 0000000..53eb4b6 --- /dev/null +++ b/bridge-settings/react-app/src/components/ActionButton.tsx @@ -0,0 +1,211 @@ +import React, { useState } from "react"; +import { + ActionDefinition, + ActionParameter, + ActionResult, + LogEntry, +} from "../types/settings"; + +interface ActionButtonProps { + appId: string; + action: ActionDefinition; + onExecute: ( + appId: string, + action: string, + params: Record + ) => void; + actionResult?: ActionResult; +} + +const ActionButton: React.FC = ({ + appId, + action, + onExecute, + actionResult, +}) => { + const [isExecuting, setIsExecuting] = useState(false); + const [showParameters, setShowParameters] = useState(false); + const [paramValues, setParamValues] = useState>({}); + const [lastResult, setLastResult] = useState(null); + + const hasParameters = action.parameters && action.parameters.length > 0; + + const handleExecute = () => { + if (hasParameters && !showParameters) { + setShowParameters(true); + return; + } + + setIsExecuting(true); + onExecute(appId, action.key, paramValues); + }; + + const handleParameterChange = (paramName: string, value: unknown) => { + setParamValues((prev) => ({ + ...prev, + [paramName]: value, + })); + }; + + const getInputConfig = (param: ActionParameter) => { + const currentValue = paramValues[param.name] ?? param.defaultValue ?? ""; + + const baseConfigs = { + boolean: { + type: "checkbox" as const, + checked: Boolean(currentValue), + onChange: (e: React.ChangeEvent) => + handleParameterChange(param.name, e.target.checked), + }, + integer: { + type: "number" as const, + value: String(currentValue), + onChange: (e: React.ChangeEvent) => + handleParameterChange(param.name, parseInt(e.target.value) || 0), + }, + float: { + type: "number" as const, + step: "0.01", + value: String(currentValue), + onChange: (e: React.ChangeEvent) => + handleParameterChange(param.name, parseFloat(e.target.value) || 0), + }, + string: { + type: "text" as const, + value: String(currentValue), + placeholder: param.description, + onChange: (e: React.ChangeEvent) => + handleParameterChange(param.name, e.target.value), + }, + }; + + return ( + baseConfigs[param.type as keyof typeof baseConfigs] || baseConfigs.string + ); + }; + + const renderParameterInput = (param: ActionParameter) => { + const inputConfig = getInputConfig(param); + + return ( + + ); + }; + + const renderLogs = (logs: LogEntry[]) => { + if (!logs || !Array.isArray(logs) || logs.length === 0) { + return null; + } + + return ( +
+

Execution Log:

+
+ {logs.map((log, index) => { + const levelClass = `log-${log.level.toLowerCase()}`; + return ( +
+ + {new Date(log.timestamp).toLocaleTimeString()} + + {log.level} + {log.message} +
+ ); + })} +
+
+ ); + }; + + React.useEffect(() => { + // When we receive an external action result, update our local state and stop executing + if (actionResult) { + setLastResult(actionResult); + setIsExecuting(false); + } + }, [actionResult]); + + return ( +
+
+ + {action.description && ( + {action.description} + )} +
+ + {showParameters && hasParameters && ( +
+

Parameters:

+ {action.parameters!.map((param) => renderParameterInput(param))} +
+ + +
+
+ )} + + {lastResult && ( +
+
+ + {lastResult.success ? "✓" : "✗"} + + + {lastResult.message || + (lastResult.success + ? "Action completed successfully" + : "Action failed")} + +
+ + {lastResult.data && Object.keys(lastResult.data).length > 0 && ( +
+

Result Data:

+
{JSON.stringify(lastResult.data, null, 2)}
+
+ )} + + {renderLogs(lastResult.logs || [])} +
+ )} +
+ ); +}; + +export default ActionButton; diff --git a/bridge-settings/react-app/src/components/ESimSettings.tsx b/bridge-settings/react-app/src/components/ESimSettings.tsx new file mode 100644 index 0000000..a0e74c2 --- /dev/null +++ b/bridge-settings/react-app/src/components/ESimSettings.tsx @@ -0,0 +1,220 @@ +import React, { useState, useEffect } from "react"; +import ActionButton from "./ActionButton"; +import { ActionDefinition, ActionResult } from "../types/settings"; + +interface ESimSettingsProps { + onExecuteAction: ( + appId: string, + action: string, + params: Record + ) => void; + actionResults: Record; +} + +const ESimSettings: React.FC = ({ + onExecuteAction, + actionResults, +}) => { + const [actions, setActions] = useState>({}); + const [isConnected, setIsConnected] = useState(false); + + // Mock actions for development - in production these would come from WebSocket messages + useEffect(() => { + const mockActions: Record = { + getProfiles: { + key: "getProfiles", + displayText: "List eSIM Profiles", + description: "Retrieve all eSIM profiles on the device", + }, + getActiveProfile: { + key: "getActiveProfile", + displayText: "Get Active Profile", + description: "Get the currently active eSIM profile", + }, + getEid: { + key: "getEid", + displayText: "Get Device EID", + description: "Retrieve the device's embedded identity document (EID)", + }, + enableProfile: { + key: "enableProfile", + displayText: "Enable Profile", + parameters: [ + { + name: "iccid", + type: "string", + required: true, + description: "Profile ICCID to enable", + }, + ], + description: "Enable an eSIM profile by ICCID", + }, + disableProfile: { + key: "disableProfile", + displayText: "Disable Profile", + parameters: [ + { + name: "iccid", + type: "string", + required: true, + description: "Profile ICCID to disable", + }, + ], + description: "Disable an eSIM profile by ICCID", + }, + deleteProfile: { + key: "deleteProfile", + displayText: "Delete Profile", + parameters: [ + { + name: "iccid", + type: "string", + required: true, + description: "Profile ICCID to delete", + }, + ], + description: "Permanently delete an eSIM profile", + }, + setNickname: { + key: "setNickname", + displayText: "Set Profile Nickname", + parameters: [ + { + name: "iccid", + type: "string", + required: true, + description: "Profile ICCID", + }, + { + name: "nickname", + type: "string", + required: true, + description: "New nickname for the profile", + }, + ], + description: "Set a custom nickname for an eSIM profile", + }, + downloadProfile: { + key: "downloadProfile", + displayText: "Download Profile", + parameters: [ + { + name: "activationCode", + type: "string", + required: true, + description: "eSIM activation code or QR code data", + }, + ], + description: "Download a new eSIM profile from activation code", + }, + downloadAndEnableProfile: { + key: "downloadAndEnableProfile", + displayText: "Download & Enable Profile", + parameters: [ + { + name: "activationCode", + type: "string", + required: true, + description: "eSIM activation code or QR code data", + }, + ], + description: "Download and immediately enable a new eSIM profile", + }, + }; + + setActions(mockActions); + setIsConnected(true); + }, []); + + if (!isConnected) { + return ( +
+

eSIM Management

+
Connecting to eSIM service...
+
+ ); + } + + if (Object.keys(actions).length === 0) { + return ( +
+

eSIM Management

+
No eSIM actions available
+
+ ); + } + + const informationActions = ["getProfiles", "getActiveProfile", "getEid"]; + const managementActions = [ + "enableProfile", + "disableProfile", + "deleteProfile", + "setNickname", + ]; + const downloadActions = ["downloadProfile", "downloadAndEnableProfile"]; + + const renderActionGroup = (title: string, actionKeys: string[]) => { + const groupActions = actionKeys.filter((key) => actions[key]); + if (groupActions.length === 0) return null; + + return ( +
+

{title}

+
+ {groupActions.map((actionKey) => ( + + ))} +
+
+ ); + }; + + return ( +
+

eSIM Management

+
+ Manage embedded SIM (eSIM) profiles on your device. You can view + existing profiles, download new ones, and manage their activation + status. +
+ + {renderActionGroup("Information", informationActions)} + {renderActionGroup("Profile Management", managementActions)} + {renderActionGroup("Download Profiles", downloadActions)} + +
+

Usage Notes:

+
    +
  • + Get Profiles: Shows all eSIM profiles with their + current status +
  • +
  • + Get Active Profile: Shows which profile is + currently active +
  • +
  • + Get EID: Shows the device's unique embedded + identity +
  • +
  • + Enable/Disable: Requires the profile's ICCID (shown + in profile list) +
  • +
  • + Download: Requires an activation code from your + carrier +
  • +
+
+
+ ); +}; + +export default ESimSettings; diff --git a/bridge-settings/react-app/src/hooks/useSettings.ts b/bridge-settings/react-app/src/hooks/useSettings.ts index f0fa0b1..c7c3190 100644 --- a/bridge-settings/react-app/src/hooks/useSettings.ts +++ b/bridge-settings/react-app/src/hooks/useSettings.ts @@ -1,82 +1,134 @@ -import { useState, useEffect } from 'react'; -import { useWebSocketMessages, useWebSocket } from './useWebSocket'; -import { SystemStatus } from '../types/settings'; +import { useState, useEffect } from "react"; +import { useWebSocketMessages, useWebSocket } from "./useWebSocket"; +import { SystemStatus, ActionResult } from "../types/settings"; export function useSettings() { - const [allSettings, setAllSettings] = useState>>({}); + const [allSettings, setAllSettings] = useState< + Record> + >({}); const [systemStatus, setSystemStatus] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - + const [actionResults, setActionResults] = useState< + Record + >({}); + const { lastMessage } = useWebSocketMessages(); - const { updateSetting, connectionState } = useWebSocket(); + const { updateSetting, executeAction, connectionState } = useWebSocket(); useEffect(() => { if (!lastMessage) return; switch (lastMessage.type) { - case 'allSettings': + case "allSettings": setAllSettings(lastMessage.settings); setLoading(false); break; - - case 'settingChanged': - setAllSettings(prev => ({ + + case "settingChanged": + setAllSettings((prev) => ({ ...prev, [lastMessage.category]: { ...prev[lastMessage.category], - [lastMessage.key]: lastMessage.value - } + [lastMessage.key]: lastMessage.value, + }, })); break; - - case 'statusUpdate': - if (lastMessage.statusType === 'battery') { - setSystemStatus(prev => ({ + + case "statusUpdate": + if (lastMessage.statusType === "battery") { + setSystemStatus((prev) => ({ ...prev, battery: { level: Number(lastMessage.data.level) || 0, charging: Boolean(lastMessage.data.charging), - powerSaveMode: Boolean(lastMessage.data.powerSaveMode) - } + powerSaveMode: Boolean(lastMessage.data.powerSaveMode), + }, })); - } else if (lastMessage.statusType === 'display') { - setSystemStatus(prev => ({ + } else if (lastMessage.statusType === "display") { + setSystemStatus((prev) => ({ ...prev, display: { brightness: Number(lastMessage.data.brightness) || 50, - autoBrightness: Boolean(lastMessage.data.autoBrightness) - } + autoBrightness: Boolean(lastMessage.data.autoBrightness), + }, })); - } else if (lastMessage.statusType === 'audio') { - setSystemStatus(prev => ({ + } else if (lastMessage.statusType === "audio") { + setSystemStatus((prev) => ({ ...prev, audio: { volume: Number(lastMessage.data.volume) || 50, - muted: Boolean(lastMessage.data.muted) - } + muted: Boolean(lastMessage.data.muted), + }, })); - } else if (lastMessage.statusType === 'network') { - setSystemStatus(prev => ({ + } else if (lastMessage.statusType === "network") { + setSystemStatus((prev) => ({ ...prev, network: { - wifiEnabled: Boolean(lastMessage.data.wifiEnabled) - } + wifiEnabled: Boolean(lastMessage.data.wifiEnabled), + }, })); } break; - - case 'error': + + case "actionResult": + console.log( + `Action result for ${lastMessage.appId}.${lastMessage.action}:`, + lastMessage + ); + const actionKey = `${lastMessage.appId}.${lastMessage.action}`; + setActionResults((prev) => ({ + ...prev, + [actionKey]: { + success: lastMessage.success, + message: lastMessage.message, + data: lastMessage.data, + logs: lastMessage.logs, + }, + })); + break; + + case "appEvent": + console.log(`App event from ${lastMessage.appId}:`, lastMessage); + if (lastMessage.eventType === "actionResult" && lastMessage.payload) { + const payload = lastMessage.payload as any; + const actionKey = `${lastMessage.appId}.${payload.action}`; + setActionResults((prev) => ({ + ...prev, + [actionKey]: { + success: payload.success, + message: payload.message, + data: payload.data, + logs: Array.isArray(payload.logs) ? payload.logs : [], + }, + })); + } + break; + + case "actionsRegistered": + console.log( + `Actions registered for ${lastMessage.appId}:`, + lastMessage.actions + ); + // Action definitions could be stored globally for dynamic UI generation + break; + + case "error": setError(lastMessage.message); break; } }, [lastMessage]); const updateSystemSetting = (key: string, value: unknown) => { - updateSetting('system', key, value); + updateSetting("system", key, value); }; - const updateAppSetting = (appId: string, category: string, key: string, value: unknown) => { + const updateAppSetting = ( + appId: string, + category: string, + key: string, + value: unknown + ) => { updateSetting(`${appId}.${category}`, key, value); }; @@ -100,9 +152,11 @@ export function useSettings() { loading, error, connected: connectionState.connected, + actionResults, updateSystemSetting, updateAppSetting, getSystemSettings, - getAppSettings + getAppSettings, + executeAction, }; -} \ No newline at end of file +} diff --git a/bridge-settings/react-app/src/hooks/useWebSocket.ts b/bridge-settings/react-app/src/hooks/useWebSocket.ts index 80ddd9a..78034c0 100644 --- a/bridge-settings/react-app/src/hooks/useWebSocket.ts +++ b/bridge-settings/react-app/src/hooks/useWebSocket.ts @@ -23,6 +23,7 @@ export function useWebSocket() { send: websocketService.send.bind(websocketService), updateSetting: websocketService.updateSetting.bind(websocketService), registerForUpdates: websocketService.registerForUpdates.bind(websocketService), + executeAction: websocketService.executeAction.bind(websocketService), connect: websocketService.connect.bind(websocketService), disconnect: websocketService.disconnect.bind(websocketService) }; diff --git a/bridge-settings/react-app/src/services/websocketService.ts b/bridge-settings/react-app/src/services/websocketService.ts index 954126d..0bc50eb 100644 --- a/bridge-settings/react-app/src/services/websocketService.ts +++ b/bridge-settings/react-app/src/services/websocketService.ts @@ -134,6 +134,15 @@ export class WebSocketService { }); } + executeAction(appId: string, action: string, params: Record): void { + this.send({ + type: "executeAction", + appId, + action, + params, + }); + } + onConnectionStateChange( listener: (state: ConnectionState) => void ): () => void { diff --git a/bridge-settings/react-app/src/styles/index.css b/bridge-settings/react-app/src/styles/index.css index a072839..68e084c 100644 --- a/bridge-settings/react-app/src/styles/index.css +++ b/bridge-settings/react-app/src/styles/index.css @@ -314,4 +314,335 @@ input[type="range"]::-moz-range-thumb { color: #3498db; font-family: monospace; font-weight: bold; +} + +/* eSIM Settings Styles */ +.esim-description { + background: #f8f9fa; + padding: 12px; + border-radius: 6px; + margin-bottom: 20px; + font-size: 14px; + color: #5d6d7e; +} + +.action-group { + margin-bottom: 24px; +} + +.action-group h3 { + color: #2c3e50; + margin-bottom: 16px; + font-size: 1.1rem; + border-bottom: 2px solid #3498db; + padding-bottom: 8px; +} + +.actions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +/* Action Button Styles */ +.action-button-container { + background: white; + border-radius: 8px; + border: 1px solid #e1e8ed; + overflow: hidden; + transition: box-shadow 0.2s ease; +} + +.action-button-container:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.action-header { + padding: 16px; + border-bottom: 1px solid #f0f3f5; +} + +.action-button { + background: #3498db; + color: white; + border: none; + padding: 12px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + width: 100%; + transition: background-color 0.2s ease; +} + +.action-button:hover:not(:disabled) { + background: #2980b9; +} + +.action-button:disabled { + background: #bdc3c7; + cursor: not-allowed; +} + +.action-button.executing { + background: #f39c12; +} + +.action-description { + display: block; + font-size: 12px; + color: #7f8c8d; + margin-top: 8px; + font-style: italic; +} + +/* Parameter Form Styles */ +.parameter-form { + padding: 16px; + background: #f8f9fa; + border-top: 1px solid #e1e8ed; +} + +.parameter-form h4 { + margin-bottom: 12px; + color: #2c3e50; + font-size: 14px; +} + +.parameter-input { + display: flex; + flex-direction: column; + margin-bottom: 12px; +} + +.parameter-label { + font-size: 14px; + color: #2c3e50; + margin-bottom: 4px; + font-weight: 500; +} + +.parameter-label .required { + color: #e74c3c; + margin-left: 4px; +} + +.parameter-input input { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.parameter-input input:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); +} + +.parameter-description { + font-size: 12px; + color: #7f8c8d; + margin-top: 4px; + font-style: italic; +} + +.parameter-actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.execute-with-params, .cancel-params { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s ease; +} + +.execute-with-params { + background: #27ae60; + color: white; +} + +.execute-with-params:hover:not(:disabled) { + background: #229954; +} + +.execute-with-params:disabled { + background: #bdc3c7; + cursor: not-allowed; +} + +.cancel-params { + background: #ecf0f1; + color: #2c3e50; +} + +.cancel-params:hover { + background: #d5dbdb; +} + +/* Action Result Styles */ +.action-result { + padding: 16px; + border-top: 1px solid #e1e8ed; + margin-top: 16px; +} + +.action-result.success { + background: rgba(46, 204, 113, 0.1); + border-left: 4px solid #2ecc71; +} + +.action-result.error { + background: rgba(231, 76, 60, 0.1); + border-left: 4px solid #e74c3c; +} + +.result-header { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.result-status { + font-size: 18px; + margin-right: 8px; + font-weight: bold; +} + +.result-status.success { + color: #2ecc71; +} + +.result-status.error { + color: #e74c3c; +} + +.result-message { + flex: 1; + font-size: 14px; + color: #2c3e50; +} + +.result-data { + margin-bottom: 16px; +} + +.result-data h4 { + margin-bottom: 8px; + color: #2c3e50; + font-size: 14px; +} + +.result-data pre { + background: #f8f9fa; + border: 1px solid #e1e8ed; + border-radius: 4px; + padding: 12px; + font-size: 12px; + overflow-x: auto; + max-height: 200px; + overflow-y: auto; +} + +/* Action Logs Styles */ +.action-logs { + margin-top: 16px; +} + +.action-logs h4 { + margin-bottom: 8px; + color: #2c3e50; + font-size: 14px; +} + +.log-entries { + background: #2c3e50; + border-radius: 4px; + padding: 8px; + max-height: 200px; + overflow-y: auto; +} + +.log-entry { + display: flex; + align-items: center; + padding: 4px 0; + font-family: monospace; + font-size: 12px; + color: #ecf0f1; +} + +.log-timestamp { + margin-right: 8px; + color: #95a5a6; + min-width: 80px; +} + +.log-level { + margin-right: 8px; + padding: 2px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: bold; + min-width: 50px; + text-align: center; +} + +.log-level.log-info { + background: #3498db; + color: white; +} + +.log-level.log-warning { + background: #f39c12; + color: white; +} + +.log-level.log-error { + background: #e74c3c; + color: white; +} + +.log-level.log-debug { + background: #95a5a6; + color: white; +} + +.log-message { + flex: 1; +} + +/* eSIM Help Styles */ +.esim-help { + margin-top: 24px; + padding: 16px; + background: #f0f9ff; + border-left: 4px solid #3498db; + border-radius: 0 6px 6px 0; +} + +.esim-help h4 { + color: #2c3e50; + margin-bottom: 12px; + font-size: 1rem; +} + +.esim-help ul { + margin-left: 20px; +} + +.esim-help li { + margin-bottom: 8px; + font-size: 14px; + color: #34495e; + line-height: 1.5; +} + +.esim-help strong { + color: #2c3e50; } \ No newline at end of file diff --git a/bridge-settings/react-app/src/types/settings.ts b/bridge-settings/react-app/src/types/settings.ts index 4401187..2b51e31 100644 --- a/bridge-settings/react-app/src/types/settings.ts +++ b/bridge-settings/react-app/src/types/settings.ts @@ -3,7 +3,8 @@ export type SettingsMessage = | { type: 'updateSetting'; category: string; key: string; value: unknown } | { type: 'registerForUpdates'; categories: string[] } - | { type: 'getAllSettings' }; + | { type: 'getAllSettings' } + | { type: 'executeAction'; appId: string; action: string; params: Record }; export type StatusMessage = | { type: 'settingChanged'; category: string; key: string; value: unknown } @@ -11,12 +12,14 @@ export type StatusMessage = | { 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'; + type: 'boolean' | 'integer' | 'string' | 'float' | 'action'; defaultValue: unknown; validation?: { min?: number; @@ -26,6 +29,34 @@ export interface Setting { }; } +export interface ActionDefinition { + key: string; + displayText: string; + parameters?: ActionParameter[]; + description?: string; +} + +export interface ActionParameter { + name: string; + type: 'boolean' | 'integer' | 'string' | 'float'; + required: boolean; + defaultValue?: unknown; + description?: string; +} + +export interface LogEntry { + timestamp: number; + level: 'INFO' | 'WARNING' | 'ERROR' | 'DEBUG'; + message: string; +} + +export interface ActionResult { + success: boolean; + message?: string; + data?: Record; + logs?: LogEntry[]; +} + export interface SettingsCategory { name: string; settings: Record; 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 new file mode 100644 index 0000000..810212a --- /dev/null +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/ESimSettingsProvider.kt @@ -0,0 +1,643 @@ +package com.penumbraos.bridge_settings + +import android.util.Log +import com.penumbraos.bridge.types.EsimProfile +import com.penumbraos.sdk.api.EsimClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +private const val TAG = "ESimSettingsProvider" + +class ESimSettingsProvider( + private val esimClient: EsimClient, + private val settingsRegistry: SettingsRegistry +) : SettingsActionProvider { + + private val providerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override suspend fun executeAction(action: String, params: Map): ActionResult { + Log.i(TAG, "Executing eSIM action: $action with params: $params") + + return try { + when (action) { + "getProfiles" -> getProfilesAction() + "getActiveProfile" -> getActiveProfileAction() + "getEid" -> getEidAction() + "enableProfile" -> enableProfileAction(params) + "disableProfile" -> disableProfileAction(params) + "deleteProfile" -> deleteProfileAction(params) + "setNickname" -> setNicknameAction(params) + "downloadProfile" -> downloadProfileAction(params) + "downloadAndEnableProfile" -> downloadAndEnableProfileAction(params) + else -> ActionResult( + success = false, + message = "Unknown eSIM action: $action", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Unknown action: $action" + ) + ) + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error executing eSIM action $action", e) + ActionResult( + success = false, + message = "eSIM action failed: ${e.message}", + logs = listOf( + LogEntry(level = LogLevel.ERROR, message = "Exception in $action: ${e.message}") + ) + ) + } + } + + override fun getActionDefinitions(): Map { + return mapOf( + "getProfiles" to ActionDefinition( + key = "getProfiles", + displayText = "List eSIM Profiles", + description = "Retrieve all eSIM profiles on the device" + ), + "getActiveProfile" to ActionDefinition( + key = "getActiveProfile", + displayText = "Get Active Profile", + description = "Get the currently active eSIM profile" + ), + "getEid" to ActionDefinition( + key = "getEid", + displayText = "Get Device EID", + description = "Retrieve the device's embedded identity document (EID)" + ), + "enableProfile" to ActionDefinition( + key = "enableProfile", + displayText = "Enable Profile", + parameters = listOf( + ActionParameter( + "iccid", + SettingType.STRING, + required = true, + description = "Profile ICCID to enable" + ) + ), + description = "Enable an eSIM profile by ICCID" + ), + "disableProfile" to ActionDefinition( + key = "disableProfile", + displayText = "Disable Profile", + parameters = listOf( + ActionParameter( + "iccid", + SettingType.STRING, + required = true, + description = "Profile ICCID to disable" + ) + ), + description = "Disable an eSIM profile by ICCID" + ), + "deleteProfile" to ActionDefinition( + key = "deleteProfile", + displayText = "Delete Profile", + parameters = listOf( + ActionParameter( + "iccid", + SettingType.STRING, + required = true, + description = "Profile ICCID to delete" + ) + ), + description = "Permanently delete an eSIM profile" + ), + "setNickname" to ActionDefinition( + key = "setNickname", + displayText = "Set Profile Nickname", + parameters = listOf( + ActionParameter( + "iccid", + SettingType.STRING, + required = true, + description = "Profile ICCID" + ), + ActionParameter( + "nickname", + SettingType.STRING, + required = true, + description = "New nickname for the profile" + ) + ), + description = "Set a custom nickname for an eSIM profile" + ), + "downloadProfile" to ActionDefinition( + key = "downloadProfile", + displayText = "Download Profile", + parameters = listOf( + ActionParameter( + "activationCode", + SettingType.STRING, + required = true, + description = "eSIM activation code or QR code data" + ) + ), + description = "Download a new eSIM profile from activation code" + ), + "downloadAndEnableProfile" to ActionDefinition( + key = "downloadAndEnableProfile", + displayText = "Download & Enable Profile", + parameters = listOf( + ActionParameter( + "activationCode", + SettingType.STRING, + required = true, + description = "eSIM activation code or QR code data" + ) + ), + description = "Download and immediately enable a new eSIM profile" + ) + ) + } + + private suspend fun getProfilesAction(): ActionResult { + return try { + sendStatusUpdate("Retrieving eSIM profiles...") + + val profiles = esimClient.getProfiles() + val profilesData = profiles.map { profile -> + mapOf( + "iccid" to profile.iccid, + "profileState" to profile.profileState, + "profileName" to (profile.profileName ?: ""), + "profileNickname" to (profile.profileNickname ?: ""), + "serviceProviderName" to (profile.serviceProviderName ?: ""), + "index" to profile.index, + "isEnabled" to profile.isEnabled, + "isDisabled" to profile.isDisabled, + "displayName" to getProfileDisplayName(profile) + ) + } + + val logs = mutableListOf() + logs.add( + LogEntry( + level = LogLevel.INFO, + message = "Found ${profiles.size} eSIM profiles" + ) + ) + + profiles.forEachIndexed { index, profile -> + val displayName = getProfileDisplayName(profile) + val status = when { + profile.isEnabled -> "ENABLED" + profile.isDisabled -> "DISABLED" + else -> profile.profileState + } + logs.add( + LogEntry( + level = LogLevel.INFO, + message = "[$index] $displayName (${profile.iccid}) - $status" + ) + ) + } + + ActionResult( + success = true, + message = "Retrieved ${profiles.size} eSIM profiles", + data = mapOf("profiles" to profilesData), + logs = logs + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to get eSIM profiles: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "getProfiles failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun getActiveProfileAction(): ActionResult { + return try { + sendStatusUpdate("Getting active eSIM profile...") + + val activeProfile = esimClient.getActiveProfile() + + if (activeProfile != null) { + val profileData = mapOf( + "iccid" to activeProfile.iccid, + "profileState" to activeProfile.profileState, + "profileName" to (activeProfile.profileName ?: ""), + "profileNickname" to (activeProfile.profileNickname ?: ""), + "serviceProviderName" to (activeProfile.serviceProviderName ?: ""), + "index" to activeProfile.index, + "isEnabled" to activeProfile.isEnabled, + "isDisabled" to activeProfile.isDisabled, + "displayName" to getProfileDisplayName(activeProfile) + ) + + ActionResult( + success = true, + message = "Active profile: ${getProfileDisplayName(activeProfile)}", + data = mapOf("activeProfile" to profileData), + logs = listOf( + LogEntry( + level = LogLevel.INFO, + message = "Active profile: ${getProfileDisplayName(activeProfile)} (${activeProfile.iccid})" + ) + ) + ) + } else { + ActionResult( + success = true, + message = "No active eSIM profile", + data = mapOf("activeProfile" to null), + logs = listOf( + LogEntry( + level = LogLevel.INFO, + message = "No active eSIM profile found" + ) + ) + ) + } + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to get active eSIM profile: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "getActiveProfile failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun getEidAction(): ActionResult { + return try { + sendStatusUpdate("Getting device EID...") + + val eid = esimClient.getEid() + + ActionResult( + success = true, + message = "Device EID retrieved", + data = mapOf("eid" to eid), + logs = listOf(LogEntry(level = LogLevel.INFO, message = "Device EID: $eid")) + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to get device EID: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "getEid failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun enableProfileAction(params: Map): ActionResult { + val iccid = params["iccid"] as? String + if (iccid.isNullOrBlank()) { + return ActionResult( + success = false, + message = "ICCID parameter is required", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Missing required parameter: iccid" + ) + ) + ) + } + + return try { + sendStatusUpdate("Enabling eSIM profile $iccid...") + + val result = esimClient.enableProfile(iccid) + + ActionResult( + success = result.success, + message = if (result.success) "Profile enabled successfully" else "Failed to enable profile: ${result.result}", + data = mapOf( + "operation" to result.operation, + "success" to result.success, + "result" to result.result + ), + logs = listOf( + LogEntry( + level = if (result.success) LogLevel.INFO else LogLevel.ERROR, + message = "Enable profile $iccid: ${if (result.success) "SUCCESS" else "FAILED - ${result.result}"}" + ) + ) + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to enable profile: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "enableProfile failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun disableProfileAction(params: Map): ActionResult { + val iccid = params["iccid"] as? String + if (iccid.isNullOrBlank()) { + return ActionResult( + success = false, + message = "ICCID parameter is required", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Missing required parameter: iccid" + ) + ) + ) + } + + return try { + sendStatusUpdate("Disabling eSIM profile $iccid...") + + val result = esimClient.disableProfile(iccid) + + ActionResult( + success = result.success, + message = if (result.success) "Profile disabled successfully" else "Failed to disable profile: ${result.result}", + data = mapOf( + "operation" to result.operation, + "success" to result.success, + "result" to result.result + ), + logs = listOf( + LogEntry( + level = if (result.success) LogLevel.INFO else LogLevel.ERROR, + message = "Disable profile $iccid: ${if (result.success) "SUCCESS" else "FAILED - ${result.result}"}" + ) + ) + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to disable profile: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "disableProfile failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun deleteProfileAction(params: Map): ActionResult { + val iccid = params["iccid"] as? String + if (iccid.isNullOrBlank()) { + return ActionResult( + success = false, + message = "ICCID parameter is required", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Missing required parameter: iccid" + ) + ) + ) + } + + return try { + sendStatusUpdate("Deleting eSIM profile $iccid...") + + val result = esimClient.deleteProfile(iccid) + + ActionResult( + success = result.success, + message = if (result.success) "Profile deleted successfully" else "Failed to delete profile: ${result.result}", + data = mapOf( + "operation" to result.operation, + "success" to result.success, + "result" to result.result + ), + logs = listOf( + LogEntry( + level = if (result.success) LogLevel.INFO else LogLevel.ERROR, + message = "Delete profile $iccid: ${if (result.success) "SUCCESS" else "FAILED - ${result.result}"}" + ) + ) + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to delete profile: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "deleteProfile failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun setNicknameAction(params: Map): ActionResult { + val iccid = params["iccid"] as? String + val nickname = params["nickname"] as? String + + if (iccid.isNullOrBlank()) { + return ActionResult( + success = false, + message = "ICCID parameter is required", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Missing required parameter: iccid" + ) + ) + ) + } + + if (nickname.isNullOrBlank()) { + return ActionResult( + success = false, + message = "Nickname parameter is required", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Missing required parameter: nickname" + ) + ) + ) + } + + return try { + sendStatusUpdate("Setting nickname for eSIM profile $iccid...") + + val result = esimClient.setNickname(iccid, nickname) + + ActionResult( + success = result.success, + message = if (result.success) "Nickname set successfully" else "Failed to set nickname: ${result.result}", + data = mapOf( + "operation" to result.operation, + "success" to result.success, + "result" to result.result, + "iccid" to iccid, + "nickname" to nickname + ), + logs = listOf( + LogEntry( + level = if (result.success) LogLevel.INFO else LogLevel.ERROR, + message = "Set nickname '$nickname' for profile $iccid: ${if (result.success) "SUCCESS" else "FAILED - ${result.result}"}" + ) + ) + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to set nickname: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "setNickname failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun downloadProfileAction(params: Map): ActionResult { + val activationCode = params["activationCode"] as? String + if (activationCode.isNullOrBlank()) { + return ActionResult( + success = false, + message = "Activation code parameter is required", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Missing required parameter: activationCode" + ) + ) + ) + } + + return try { + sendStatusUpdate("Downloading eSIM profile...") + sendStatusUpdate("Activation code: $activationCode") + + val result = esimClient.downloadProfile(activationCode) + + ActionResult( + success = result.success, + message = if (result.success) "Profile downloaded successfully" else "Failed to download profile: ${result.result}", + data = mapOf( + "operation" to result.operation, + "success" to result.success, + "result" to result.result, + "activationCode" to activationCode + ), + logs = listOf( + LogEntry(level = LogLevel.INFO, message = "Starting profile download..."), + LogEntry( + level = if (result.success) LogLevel.INFO else LogLevel.ERROR, + message = "Download profile: ${if (result.success) "SUCCESS" else "FAILED - ${result.result}"}" + ) + ) + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to download profile: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "downloadProfile failed: ${e.message}" + ) + ) + ) + } + } + + private suspend fun downloadAndEnableProfileAction(params: Map): ActionResult { + val activationCode = params["activationCode"] as? String + if (activationCode.isNullOrBlank()) { + return ActionResult( + success = false, + message = "Activation code parameter is required", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Missing required parameter: activationCode" + ) + ) + ) + } + + return try { + sendStatusUpdate("Downloading and enabling eSIM profile...") + sendStatusUpdate("Activation code: $activationCode") + + val result = esimClient.downloadAndEnableProfile(activationCode) + + ActionResult( + success = result.success, + message = if (result.success) "Profile downloaded and enabled successfully" else "Failed to download and enable profile: ${result.result}", + data = mapOf( + "operation" to result.operation, + "success" to result.success, + "result" to result.result, + "activationCode" to activationCode + ), + logs = listOf( + LogEntry( + level = LogLevel.INFO, + message = "Starting profile download and enable..." + ), + LogEntry( + level = if (result.success) LogLevel.INFO else LogLevel.ERROR, + message = "Download and enable profile: ${if (result.success) "SUCCESS" else "FAILED - ${result.result}"}" + ) + ) + ) + } catch (e: Exception) { + ActionResult( + success = false, + message = "Failed to download and enable profile: ${e.message}", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "downloadAndEnableProfile failed: ${e.message}" + ) + ) + ) + } + } + + private fun getProfileDisplayName(profile: EsimProfile): String { + return when { + !profile.profileNickname.isNullOrBlank() -> profile.profileNickname + !profile.profileName.isNullOrBlank() -> profile.profileName + !profile.serviceProviderName.isNullOrBlank() -> profile.serviceProviderName + else -> "Unknown Profile" + } + } + + private suspend fun sendStatusUpdate(message: String) { + Log.d(TAG, message) + settingsRegistry.sendAppStatusUpdate( + "esim", "provider", mapOf( + "status" to message, + "timestamp" to System.currentTimeMillis() + ) + ) + } +} \ No newline at end of file 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 82f0c7b..24188c0 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 @@ -144,6 +144,18 @@ 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) + Log.i(TAG, "Executed action: $appId.$action") + } catch (e: Exception) { + Log.e(TAG, "Error executing action: $appId.$action", e) + } + } + } + private fun convertSchemaToDefinitions(schema: Map<*, *>): Map { val definitions = mutableMapOf() @@ -181,6 +193,7 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin SettingType.INTEGER -> 0 SettingType.STRING -> "" SettingType.FLOAT -> 0.0f + SettingType.ACTION -> "Action" } } 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 249d6b0..8480c2f 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 @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @@ -27,6 +26,43 @@ import java.util.concurrent.ConcurrentHashMap private const val TAG = "SettingsRegistry" +data class ActionResult( + val success: Boolean, + val message: String? = null, + val data: Map? = null, + val logs: List? = null +) + +data class LogEntry( + val timestamp: Long = System.currentTimeMillis(), + val level: LogLevel, + val message: String +) + +enum class LogLevel { + INFO, WARNING, ERROR, DEBUG +} + +interface SettingsActionProvider { + suspend fun executeAction(action: String, params: Map): ActionResult + 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, @@ -35,7 +71,7 @@ data class SettingDefinition( ) enum class SettingType { - BOOLEAN, INTEGER, STRING, FLOAT + BOOLEAN, INTEGER, STRING, FLOAT, ACTION } data class SettingValidation( @@ -61,6 +97,7 @@ data class PersistedSettings( class SettingsRegistry(private val context: Context, val shellClient: ShellClient) { private val appSettings = ConcurrentHashMap>() private val systemSettings = ConcurrentHashMap() + private val actionProviders = ConcurrentHashMap() // Store saved app settings values until apps register their schemas private val savedAppSettingsValues = @@ -370,6 +407,7 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien SettingType.INTEGER -> value is Int || value is Long SettingType.STRING -> value is String SettingType.FLOAT -> value is Float || value is Double + SettingType.ACTION -> value is String // Actions are treated as strings } if (!isValidType) return false @@ -450,6 +488,101 @@ class SettingsRegistry(private val context: Context, val shellClient: ShellClien ?: Log.w(TAG, "Cannot send app event - web server not initialized") } + fun registerActionProvider(appId: String, provider: SettingsActionProvider) { + actionProviders[appId] = provider + Log.i(TAG, "Registered action provider for app: $appId") + + // Broadcast available actions to web UI + registryScope.launch { + val actions = provider.getActionDefinitions() + sendAppEvent(appId, "actionsRegistered", mapOf("actions" to actions)) + } + } + + fun unregisterActionProvider(appId: String) { + actionProviders.remove(appId) + Log.i(TAG, "Unregistered action provider for app: $appId") + } + + suspend fun executeAction( + appId: String, + action: String, + params: Map + ): ActionResult { + Log.i(TAG, "Executing action: $appId.$action with params: $params") + + val provider = actionProviders[appId] + if (provider == null) { + val errorResult = ActionResult( + success = false, + message = "No action provider registered for app: $appId", + logs = listOf( + LogEntry( + level = LogLevel.ERROR, + message = "Action provider not found for $appId" + ) + ) + ) + + // Broadcast error result + sendAppEvent( + appId, "actionResult", mapOf( + "action" to action, + "success" to false, + "message" to (errorResult.message ?: ""), + "logs" to (errorResult.logs ?: emptyList()) + ) + ) + + return errorResult + } + + return try { + val result = provider.executeAction(action, params) + Log.i(TAG, "Action $appId.$action completed. Success: ${result.success}") + + // Broadcast action result via WebSocket + sendAppEvent( + appId, "actionResult", mapOf( + "action" to action, + "success" to result.success, + "message" to (result.message ?: ""), + "data" to (result.data ?: emptyMap()), + "logs" to (result.logs ?: emptyList()) + ) + ) + + result + } catch (e: Exception) { + Log.e(TAG, "Action execution failed: $appId.$action", e) + val errorResult = ActionResult( + success = false, + message = "Action execution failed: ${e.message}", + logs = listOf(LogEntry(level = LogLevel.ERROR, message = "Exception: ${e.message}")) + ) + + // Broadcast error result + sendAppEvent( + appId, "actionResult", mapOf( + "action" to action, + "success" to false, + "message" to (errorResult.message ?: ""), + "logs" to (errorResult.logs ?: emptyList()) + ) + ) + + errorResult + } + } + + fun getActionProvider(appId: String): SettingsActionProvider? { + return actionProviders[appId] + } + + fun getAllActionProviders(): Map { + return actionProviders.toMap() + } + private fun setupTemperatureMonitoring() { registryScope.launch { temperatureController.temperatureFlow.collect { temperature -> diff --git a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsService.kt b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsService.kt index b2aeb2b..18e0510 100644 --- a/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsService.kt +++ b/bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsService.kt @@ -3,9 +3,12 @@ package com.penumbraos.bridge_settings import android.util.Log import com.penumbraos.appprocessmocks.Common import com.penumbraos.appprocessmocks.MockContext +import com.penumbraos.bridge.IEsimProvider import com.penumbraos.bridge.IShellProvider import com.penumbraos.bridge.external.connectToBridge import com.penumbraos.bridge.external.waitForBridgeShell +import com.penumbraos.bridge.external.waitForBridgeSystem +import com.penumbraos.sdk.api.EsimClient import com.penumbraos.sdk.api.ShellClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,6 +22,7 @@ class SettingsService { private lateinit var settingsRegistry: SettingsRegistry private lateinit var webServer: SettingsWebServer private lateinit var settingsProvider: SettingsProvider + private lateinit var esimProvider: ESimSettingsProvider fun start() { Log.i(TAG, "Starting Settings Service") @@ -55,6 +59,22 @@ class SettingsService { settingsRegistry.setWebServer(webServer) webServer.start() + + waitForBridgeSystem(TAG, bridge) + + try { + val esimProviderInterface = IEsimProvider.Stub.asInterface(bridge.esimProvider) + if (esimProviderInterface != null) { + val esimClient = EsimClient(esimProviderInterface) + esimProvider = ESimSettingsProvider(esimClient, settingsRegistry) + settingsRegistry.registerActionProvider("esim", esimProvider) + Log.i(TAG, "Registered eSIM action provider") + } else { + Log.e(TAG, "eSIM provider not available or failed to initialize") + } + } catch (e: Exception) { + Log.w(TAG, "eSIM provider not available or failed to initialize", e) + } } Log.i(TAG, "Settings Service started successfully") 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 52e5931..b861999 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 @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalSerializationApi::class) + package com.penumbraos.bridge_settings import android.util.Log @@ -30,9 +32,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @@ -60,6 +62,15 @@ sealed class SettingsMessage { @Serializable @SerialName("getAllSettings") object GetAllSettings : SettingsMessage() + + @Serializable + @SerialName("executeAction") + data class ExecuteAction( + val appId: String, + val action: String, + val params: Map + ) : + SettingsMessage() } @Serializable @@ -130,7 +141,9 @@ class SettingsWebServer( val startTime = System.currentTimeMillis() val timeout = 5000 // 5 seconds - while (server?.engine?.resolvedConnectors()?.isEmpty() != false && (System.currentTimeMillis() - startTime) < timeout) { + while (server?.engine?.resolvedConnectors() + ?.isEmpty() != false && (System.currentTimeMillis() - startTime) < timeout + ) { Thread.sleep(100) } @@ -306,7 +319,10 @@ class SettingsWebServer( try { // Send current settings to new client val allSettings = settingsRegistry.getAllSettings() - sendToSession(session, StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings))) + sendToSession( + session, + StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings)) + ) for (frame in session.incoming) { when (frame) { @@ -340,7 +356,10 @@ class SettingsWebServer( when (message) { is SettingsMessage.GetAllSettings -> { val allSettings = settingsRegistry.getAllSettings() - sendToSession(session, StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings))) + sendToSession( + session, + StatusMessage.AllSettings(convertToJsonCompatibleMap(allSettings)) + ) } is SettingsMessage.UpdateSetting -> { @@ -366,6 +385,30 @@ class SettingsWebServer( // We'll broadcast all changes for now Log.i(TAG, "Client registered for updates: ${message.categories}") } + + 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 -> + primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.doubleOrNull + ?: primitive.content + } + } + + // Execute the action through SettingsRegistry + settingsRegistry.executeAction(message.appId, message.action, params) + + } catch (e: Exception) { + Log.e(TAG, "Error executing action ${message.appId}.${message.action}", e) + sendToSession( + session, + StatusMessage.Error("Action execution failed: ${e.message}") + ) + } + } } } @@ -374,7 +417,11 @@ class SettingsWebServer( broadcast(message) } - suspend fun broadcastAppStatusUpdate(appId: String, component: String, payload: Map) { + suspend fun broadcastAppStatusUpdate( + appId: String, + component: String, + payload: Map + ) { val message = StatusMessage.AppStatusUpdate( appId = appId, component = component, @@ -418,12 +465,29 @@ class SettingsWebServer( } } - private fun convertValuesToJsonElements(data: Map): Map { + 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()) } } @@ -436,12 +500,14 @@ class SettingsWebServer( 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()) - }) + else -> mapOf( + "value" to when (value) { + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + else -> JsonPrimitive(value.toString()) + } + ) } } } diff --git a/bridge-shared/aidl/com/penumbraos/bridge/ISettingsProvider.aidl b/bridge-shared/aidl/com/penumbraos/bridge/ISettingsProvider.aidl index 0c53b71..308ec8c 100644 --- a/bridge-shared/aidl/com/penumbraos/bridge/ISettingsProvider.aidl +++ b/bridge-shared/aidl/com/penumbraos/bridge/ISettingsProvider.aidl @@ -10,6 +10,7 @@ interface ISettingsProvider { Map getAllSettings(String appId); Map getSystemSettings(); void updateSystemSetting(String key, String value); + void executeAction(String appId, String action, in Map params); void sendAppStatusUpdate(String appId, String component, in Map payload); void sendAppEvent(String appId, String eventType, in Map payload); } \ No newline at end of file diff --git a/sdk/src/main/java/com/penumbraos/sdk/api/SettingsClient.kt b/sdk/src/main/java/com/penumbraos/sdk/api/SettingsClient.kt index fddec23..1f6cbbe 100644 --- a/sdk/src/main/java/com/penumbraos/sdk/api/SettingsClient.kt +++ b/sdk/src/main/java/com/penumbraos/sdk/api/SettingsClient.kt @@ -120,6 +120,16 @@ class SettingsClient(private val settingsProvider: ISettingsProvider) { Log.e(TAG, "Failed to send event: $appId.$eventType", e) } } + + suspend fun executeAction(appId: String, action: String, params: Map): Boolean { + return try { + settingsProvider.executeAction(appId, action, params) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to execute action: $appId.$action", e) + false + } + } } class SettingsCategoryBuilder { @@ -181,6 +191,23 @@ class SettingsCategory { ) } + fun actionSetting( + key: String, + displayText: String, + parameters: List? = null, + description: String? = null + ) { + val validation = mutableMapOf() + validation["displayText"] = displayText + parameters?.let { validation["parameters"] = it } + description?.let { validation["description"] = it } + + settings[key] = SettingDefinition( + key, SettingType.ACTION, displayText, + validation.ifEmpty { null } + ) + } + internal fun toSchemaMap(): Map> { return settings.mapValues { (_, definition) -> val schema = mutableMapOf( @@ -201,7 +228,7 @@ data class SettingDefinition( ) enum class SettingType { - BOOLEAN, INTEGER, STRING, FLOAT + BOOLEAN, INTEGER, STRING, FLOAT, ACTION } class SettingsException(message: String) : Exception(message) \ No newline at end of file