mirror of
https://github.com/PenumbraOS/sdk.git
synced 2026-02-03 17:26:48 -06:00
Working eSIM control via web UI
This commit is contained in:
parent
8c8eb3b672
commit
53b5dae49f
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
211
bridge-settings/react-app/src/components/ActionButton.tsx
Normal file
211
bridge-settings/react-app/src/components/ActionButton.tsx
Normal 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;
|
||||
220
bridge-settings/react-app/src/components/ESimSettings.tsx
Normal file
220
bridge-settings/react-app/src/components/ESimSettings.tsx
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user