Working eSIM control via web UI

This commit is contained in:
Adam Gastineau 2025-08-09 12:26:36 -07:00
parent 8c8eb3b672
commit 53b5dae49f
15 changed files with 1816 additions and 54 deletions

View File

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

View File

@ -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<string, unknown>
) => void;
actionResult?: ActionResult;
}
const ActionButton: React.FC<ActionButtonProps> = ({
appId,
action,
onExecute,
actionResult,
}) => {
const [isExecuting, setIsExecuting] = useState(false);
const [showParameters, setShowParameters] = useState(false);
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
const [lastResult, setLastResult] = useState<ActionResult | null>(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<HTMLInputElement>) =>
handleParameterChange(param.name, e.target.checked),
},
integer: {
type: "number" as const,
value: String(currentValue),
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
handleParameterChange(param.name, parseInt(e.target.value) || 0),
},
float: {
type: "number" as const,
step: "0.01",
value: String(currentValue),
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
handleParameterChange(param.name, parseFloat(e.target.value) || 0),
},
string: {
type: "text" as const,
value: String(currentValue),
placeholder: param.description,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
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 (
<label key={param.name} className="parameter-input">
<span className="parameter-label">
{param.name}
{param.required && <span className="required">*</span>}
</span>
<input {...inputConfig} />
{param.description && (
<span className="parameter-description">{param.description}</span>
)}
</label>
);
};
const renderLogs = (logs: LogEntry[]) => {
if (!logs || !Array.isArray(logs) || logs.length === 0) {
return null;
}
return (
<div className="action-logs">
<h4>Execution Log:</h4>
<div className="log-entries">
{logs.map((log, index) => {
const levelClass = `log-${log.level.toLowerCase()}`;
return (
<div key={index} className={`log-entry ${levelClass}`}>
<span className="log-timestamp">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className={`log-level ${levelClass}`}>{log.level}</span>
<span className="log-message">{log.message}</span>
</div>
);
})}
</div>
</div>
);
};
React.useEffect(() => {
// When we receive an external action result, update our local state and stop executing
if (actionResult) {
setLastResult(actionResult);
setIsExecuting(false);
}
}, [actionResult]);
return (
<div className="action-button-container">
<div className="action-header">
<button
className={`action-button ${isExecuting ? "executing" : ""}`}
onClick={handleExecute}
disabled={isExecuting}
>
{isExecuting ? "Executing..." : action.displayText}
</button>
{action.description && (
<span className="action-description">{action.description}</span>
)}
</div>
{showParameters && hasParameters && (
<div className="parameter-form">
<h4>Parameters:</h4>
{action.parameters!.map((param) => renderParameterInput(param))}
<div className="parameter-actions">
<button
className="execute-with-params"
onClick={handleExecute}
disabled={isExecuting}
>
{isExecuting ? "Executing..." : "Execute"}
</button>
<button
className="cancel-params"
onClick={() => setShowParameters(false)}
>
Cancel
</button>
</div>
</div>
)}
{lastResult && (
<div
className={`action-result ${
lastResult.success ? "success" : "error"
}`}
>
<div className="result-header">
<span
className={`result-status ${
lastResult.success ? "success" : "error"
}`}
>
{lastResult.success ? "✓" : "✗"}
</span>
<span className="result-message">
{lastResult.message ||
(lastResult.success
? "Action completed successfully"
: "Action failed")}
</span>
</div>
{lastResult.data && Object.keys(lastResult.data).length > 0 && (
<div className="result-data">
<h4>Result Data:</h4>
<pre>{JSON.stringify(lastResult.data, null, 2)}</pre>
</div>
)}
{renderLogs(lastResult.logs || [])}
</div>
)}
</div>
);
};
export default ActionButton;

View File

@ -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<string, unknown>
) => void;
actionResults: Record<string, ActionResult>;
}
const ESimSettings: React.FC<ESimSettingsProps> = ({
onExecuteAction,
actionResults,
}) => {
const [actions, setActions] = useState<Record<string, ActionDefinition>>({});
const [isConnected, setIsConnected] = useState(false);
// Mock actions for development - in production these would come from WebSocket messages
useEffect(() => {
const mockActions: Record<string, ActionDefinition> = {
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 (
<div className="settings-section">
<h2>eSIM Management</h2>
<div className="status-message">Connecting to eSIM service...</div>
</div>
);
}
if (Object.keys(actions).length === 0) {
return (
<div className="settings-section">
<h2>eSIM Management</h2>
<div className="status-message">No eSIM actions available</div>
</div>
);
}
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 (
<div className="action-group">
<h3>{title}</h3>
<div className="actions-grid">
{groupActions.map((actionKey) => (
<ActionButton
key={actionKey}
appId="esim"
action={actions[actionKey]}
onExecute={onExecuteAction}
actionResult={actionResults[`esim.${actionKey}`]}
/>
))}
</div>
</div>
);
};
return (
<div className="settings-section">
<h2>eSIM Management</h2>
<div className="esim-description">
Manage embedded SIM (eSIM) profiles on your device. You can view
existing profiles, download new ones, and manage their activation
status.
</div>
{renderActionGroup("Information", informationActions)}
{renderActionGroup("Profile Management", managementActions)}
{renderActionGroup("Download Profiles", downloadActions)}
<div className="esim-help">
<h4>Usage Notes:</h4>
<ul>
<li>
<strong>Get Profiles:</strong> Shows all eSIM profiles with their
current status
</li>
<li>
<strong>Get Active Profile:</strong> Shows which profile is
currently active
</li>
<li>
<strong>Get EID:</strong> Shows the device's unique embedded
identity
</li>
<li>
<strong>Enable/Disable:</strong> Requires the profile's ICCID (shown
in profile list)
</li>
<li>
<strong>Download:</strong> Requires an activation code from your
carrier
</li>
</ul>
</div>
</div>
);
};
export default ESimSettings;

View File

@ -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<Record<string, Record<string, unknown>>>({});
const [allSettings, setAllSettings] = useState<
Record<string, Record<string, unknown>>
>({});
const [systemStatus, setSystemStatus] = useState<SystemStatus>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionResults, setActionResults] = useState<
Record<string, ActionResult>
>({});
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,
};
}
}

View File

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

View File

@ -134,6 +134,15 @@ export class WebSocketService {
});
}
executeAction(appId: string, action: string, params: Record<string, unknown>): void {
this.send({
type: "executeAction",
appId,
action,
params,
});
}
onConnectionStateChange(
listener: (state: ConnectionState) => void
): () => void {

View File

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

View File

@ -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<string, unknown> };
export type StatusMessage =
| { type: 'settingChanged'; category: string; key: string; value: unknown }
@ -11,12 +12,14 @@ export type StatusMessage =
| { 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';
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<string, unknown>;
logs?: LogEntry[];
}
export interface SettingsCategory {
name: string;
settings: Record<string, Setting>;

View File

@ -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<String, Any>): 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<String, ActionDefinition> {
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<String, Any>(
"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<LogEntry>()
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<String, Any>): 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<String, Any>): 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<String, Any>): 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<String, Any>): 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<String, Any>): 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<String, Any>): 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<String, Any>(
"status" to message,
"timestamp" to System.currentTimeMillis()
)
)
}
}

View File

@ -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<String, SettingDefinition> {
val definitions = mutableMapOf<String, SettingDefinition>()
@ -181,6 +193,7 @@ class SettingsProvider(private val settingsRegistry: SettingsRegistry) : ISettin
SettingType.INTEGER -> 0
SettingType.STRING -> ""
SettingType.FLOAT -> 0.0f
SettingType.ACTION -> "Action"
}
}

View File

@ -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<String, Any?>? = null,
val logs: List<LogEntry>? = 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<String, Any>): ActionResult
fun getActionDefinitions(): Map<String, ActionDefinition>
}
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,
@ -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<String, MutableMap<String, AppSettingsCategory>>()
private val systemSettings = ConcurrentHashMap<String, Any>()
private val actionProviders = ConcurrentHashMap<String, SettingsActionProvider>()
// 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<String, Any>("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<String, Any>
): 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<String, Any>(
"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<String, Any>(
"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<String, Any>(
"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<String, SettingsActionProvider> {
return actionProviders.toMap()
}
private fun setupTemperatureMonitoring() {
registryScope.launch {
temperatureController.temperatureFlow.collect { temperature ->

View File

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

View File

@ -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<String, JsonElement>
) :
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<String, Any>
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<String, Any>) {
suspend fun broadcastAppStatusUpdate(
appId: String,
component: String,
payload: Map<String, Any>
) {
val message = StatusMessage.AppStatusUpdate(
appId = appId,
component = component,
@ -418,12 +465,29 @@ class SettingsWebServer(
}
}
private fun convertValuesToJsonElements(data: Map<String, Any>): Map<String, JsonElement> {
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())
}
}
@ -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())
}
)
}
}
}

View File

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

View File

@ -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<String, Any>): 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<String>? = null,
description: String? = null
) {
val validation = mutableMapOf<String, Any>()
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<String, Map<String, Any>> {
return settings.mapValues { (_, definition) ->
val schema = mutableMapOf<String, Any>(
@ -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)