Skip to content

Commit e70d837

Browse files
committed
Launcher change API and UI
1 parent 7785543 commit e70d837

File tree

8 files changed

+323
-1
lines changed

8 files changed

+323
-1
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from "react";
2+
3+
interface LauncherOption {
4+
label: string;
5+
component: string;
6+
}
7+
8+
interface LauncherSelectProps {
9+
launchers: LauncherOption[];
10+
current: string;
11+
onChange: (component: string) => void;
12+
}
13+
14+
export const LauncherSelect: React.FC<LauncherSelectProps> = ({
15+
launchers,
16+
current,
17+
onChange,
18+
}) => {
19+
if (launchers.length === 0) {
20+
return <span className="status-display">No launchers found</span>;
21+
}
22+
23+
return (
24+
<select
25+
className="setting-select"
26+
value={current}
27+
onChange={(e) => onChange(e.target.value)}
28+
>
29+
{!current && <option value="">Select a launcher...</option>}
30+
{launchers.map((launcher) => (
31+
<option key={launcher.component} value={launcher.component}>
32+
{launcher.label}
33+
</option>
34+
))}
35+
</select>
36+
);
37+
};

bridge-settings/react-app/src/components/SystemSettings.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import React from "react";
22
import { useSettings } from "../hooks/useSettings";
33
import { ToggleSwitch } from "./ToggleSwitch";
44
import { Slider } from "./Slider";
5+
import { LauncherSelect } from "./LauncherSelect";
6+
7+
interface LauncherOption {
8+
label: string;
9+
component: string;
10+
}
511

612
export const SystemSettings: React.FC = () => {
713
const { getSystemSettings, updateSystemSetting } = useSettings();
@@ -15,10 +21,28 @@ export const SystemSettings: React.FC = () => {
1521
updateSystemSetting(key, value);
1622
};
1723

24+
const handleLauncherChange = (component: string) => {
25+
updateSystemSetting("launcher.current", component);
26+
};
27+
28+
const availableLaunchers = (systemSettings["launcher.available"] ?? []) as LauncherOption[];
29+
const currentLauncher = (systemSettings["launcher.current"] ?? "") as string;
30+
1831
return (
1932
<div className="settings-section">
2033
<h2>System Settings</h2>
2134

35+
<div className="setting-item">
36+
<span className="setting-label">Default Launcher</span>
37+
<div className="setting-control">
38+
<LauncherSelect
39+
launchers={availableLaunchers}
40+
current={currentLauncher}
41+
onChange={handleLauncherChange}
42+
/>
43+
</div>
44+
</div>
45+
2246
<div className="setting-item">
2347
<span className="setting-label">Display Enabled</span>
2448
<div className="setting-control">

bridge-settings/react-app/src/styles/index.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,35 @@ input[type="range"]::-moz-range-thumb {
621621
flex: 1;
622622
}
623623

624+
/* Select / Dropdown Styles */
625+
.setting-select {
626+
padding: 8px 12px;
627+
border: 1px solid #555555;
628+
border-radius: 4px;
629+
font-size: 14px;
630+
background: #404040;
631+
color: #e0e0e0;
632+
cursor: pointer;
633+
min-width: 200px;
634+
max-width: 350px;
635+
}
636+
637+
.setting-select:focus {
638+
outline: none;
639+
border-color: #3498db;
640+
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.3);
641+
}
642+
643+
.setting-select:hover {
644+
border-color: #666666;
645+
}
646+
647+
.setting-select option {
648+
background: #404040;
649+
color: #e0e0e0;
650+
padding: 4px 8px;
651+
}
652+
624653
/* eSIM Help Styles */
625654
.esim-help {
626655
margin-top: 24px;
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package com.penumbraos.bridge_settings
2+
3+
import android.util.Log
4+
import com.penumbraos.sdk.api.ShellClient
5+
6+
private const val TAG = "LauncherController"
7+
8+
data class LauncherInfo(
9+
val label: String,
10+
val component: String
11+
)
12+
13+
class LauncherController(private val shellClient: ShellClient) {
14+
15+
companion object {
16+
private val KNOWN_LAUNCHER_NAMES = mapOf(
17+
"humane.experience.systemnavigation/humaneinternal.system.ipc.HumaneExperienceActivity" to "Humane SystemNavigation",
18+
"com.penumbraos.mabl.pin/com.penumbraos.mabl.MainActivity" to "MABL",
19+
"com.android.settings/.FallbackHome" to "Settings Fallback",
20+
)
21+
}
22+
23+
/**
24+
* Queries available launcher apps by resolving activities with CATEGORY_HOME.
25+
* Uses `pm query-activities` to find all apps registered as home/launcher.
26+
*/
27+
suspend fun getAvailableLaunchers(): List<LauncherInfo> {
28+
return try {
29+
val result = shellClient.executeCommand(
30+
"pm query-activities --brief -a android.intent.action.MAIN -c android.intent.category.HOME"
31+
)
32+
val output = result.output.trim()
33+
Log.d(TAG, "query-activities output:\n$output")
34+
35+
if (!result.isSuccess || output.isEmpty()) {
36+
Log.w(
37+
TAG,
38+
"Failed to query launcher activities, exit=${result.exitCode}, error='${result.error}'"
39+
)
40+
return emptyList()
41+
}
42+
43+
parseLaunchers(output)
44+
} catch (e: Exception) {
45+
Log.e(TAG, "Failed to get available launchers", e)
46+
emptyList()
47+
}
48+
}
49+
50+
/**
51+
* Parses the output of `pm query-activities --brief` which outputs lines like:
52+
* com.android.launcher3/.Launcher
53+
* com.example.app/.HomeActivity
54+
*
55+
* Or in some Android versions with a "priority=N" header line followed by component names.
56+
*/
57+
private fun parseLaunchers(output: String): List<LauncherInfo> {
58+
val launchers = mutableListOf<LauncherInfo>()
59+
60+
for (line in output.lines()) {
61+
val trimmed = line.trim()
62+
// Skip empty lines, header lines like "priority=0", and other non-component lines
63+
if (trimmed.isEmpty() || !trimmed.contains("/")) continue
64+
65+
// Component format: package/class (e.g., com.android.launcher3/.Launcher)
66+
val component = trimmed
67+
val packageName = component.substringBefore("/")
68+
69+
val label = KNOWN_LAUNCHER_NAMES[component] ?: packageName
70+
71+
launchers.add(LauncherInfo(label = label, component = component))
72+
Log.d(TAG, "Found launcher: $label ($component)")
73+
}
74+
75+
Log.i(TAG, "Found ${launchers.size} available launchers")
76+
return launchers
77+
}
78+
79+
/**
80+
* Gets the current default launcher using `cmd shortcut get-default-launcher`.
81+
* Returns the component name string, or null if it can't be determined.
82+
*/
83+
suspend fun getCurrentLauncher(): String? {
84+
return try {
85+
val result = shellClient.executeCommand("cmd shortcut get-default-launcher")
86+
val output = result.output.trim()
87+
Log.d(TAG, "get-default-launcher output: '$output', exit=${result.exitCode}")
88+
89+
if (!result.isSuccess) {
90+
Log.w(
91+
TAG,
92+
"Failed to get default launcher, exit=${result.exitCode}, error='${result.error}'"
93+
)
94+
return null
95+
}
96+
97+
// Output format varies. It may be just a component name, or it may include
98+
// extra text. Try to extract a component name (package/class pattern).
99+
val componentRegex = Regex("""[\w.]+/[\w.]+""")
100+
val match = componentRegex.find(output)
101+
102+
if (match != null) {
103+
val component = match.value
104+
Log.i(TAG, "Current default launcher: $component")
105+
component
106+
} else {
107+
Log.w(TAG, "Could not parse default launcher from output: '$output'")
108+
null
109+
}
110+
} catch (e: Exception) {
111+
Log.e(TAG, "Failed to get current launcher", e)
112+
null
113+
}
114+
}
115+
116+
/**
117+
* Sets the default launcher using `cmd package set-home-activity`
118+
* Verifies the change took effect by re-querying afterward, then force-stops
119+
* the old launcher so Android immediately switches to the new one
120+
*
121+
* @param componentName The component name in package/class format (e.g., "com.android.launcher3/.Launcher")
122+
* @return true if the launcher was changed successfully
123+
*/
124+
suspend fun setDefaultLauncher(componentName: String): Boolean {
125+
return try {
126+
Log.i(TAG, "Setting default launcher to: $componentName")
127+
128+
// Get the current launcher before changing so we can force-stop it
129+
val previousLauncher = getCurrentLauncher()
130+
val previousPackage = previousLauncher?.substringBefore("/")
131+
val newPackage = componentName.substringBefore("/")
132+
133+
val result = shellClient.executeCommandWithTimeout(
134+
"cmd package set-home-activity $componentName",
135+
timeoutMs = 10000
136+
)
137+
138+
Log.d(
139+
TAG,
140+
"set-home-activity result: exit=${result.exitCode}, output='${result.output}', error='${result.error}'"
141+
)
142+
143+
if (!result.isSuccess) {
144+
Log.w(
145+
TAG,
146+
"set-home-activity command failed, exit=${result.exitCode}, error='${result.error}', output='${result.output}'"
147+
)
148+
return false
149+
}
150+
151+
// Verify the change took effect
152+
val currentLauncher = getCurrentLauncher()
153+
if (currentLauncher != null && currentLauncher == componentName) {
154+
Log.i(TAG, "Default launcher successfully changed to: $componentName")
155+
} else {
156+
Log.w(
157+
TAG,
158+
"Launcher change may not have taken effect. Expected=$componentName, got=$currentLauncher"
159+
)
160+
}
161+
162+
// Force-stop the old launcher so Android immediately resolves to the new one.
163+
// Only do this if the launcher actually changed (different package).
164+
if (previousPackage != null && previousPackage != newPackage) {
165+
Log.i(TAG, "Force-stopping previous launcher: $previousPackage")
166+
val stopResult = shellClient.executeCommandWithTimeout(
167+
"am force-stop $previousPackage",
168+
timeoutMs = 5000
169+
)
170+
Log.d(
171+
TAG,
172+
"force-stop result: exit=${stopResult.exitCode}, output='${stopResult.output}', error='${stopResult.error}'"
173+
)
174+
}
175+
176+
true
177+
} catch (e: Exception) {
178+
Log.e(TAG, "Failed to set default launcher to $componentName", e)
179+
false
180+
}
181+
}
182+
}

bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsRegistry.kt

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ class SettingsRegistry(
119119

120120

121121
private val humaneDisplayController = HumaneDisplayController(shellClient)
122+
private val launcherController = LauncherController(shellClient)
122123
private val temperatureController = TemperatureController(shellClient)
123124
private val registryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
124125

@@ -203,14 +204,31 @@ class SettingsRegistry(
203204
systemSettings["display.humane_enabled"] = false
204205
}
205206

207+
// Launcher settings
208+
try {
209+
val availableLaunchers = launcherController.getAvailableLaunchers()
210+
systemSettings["launcher.available"] = availableLaunchers.map { launcher ->
211+
mapOf("label" to launcher.label, "component" to launcher.component)
212+
}
213+
214+
val currentLauncher = launcherController.getCurrentLauncher()
215+
systemSettings["launcher.current"] = currentLauncher ?: ""
216+
Log.i(TAG, "Loaded launcher settings: current=$currentLauncher, available=${availableLaunchers.size}")
217+
} catch (e: Exception) {
218+
Log.e(TAG, "Failed to load launcher settings", e)
219+
systemSettings["launcher.available"] = emptyList<Map<String, String>>()
220+
systemSettings["launcher.current"] = ""
221+
}
222+
206223
} catch (e: Exception) {
207224
Log.e(TAG, "Failed to load current Android settings", e)
208225
}
209226
}
210227

211228
private fun isAndroidSystemSetting(key: String): Boolean {
212229
return when (key) {
213-
"audio.volume", "audio.muted", "display.humane_enabled", "device.temperature" -> true
230+
"audio.volume", "audio.muted", "display.humane_enabled", "device.temperature",
231+
"launcher.current", "launcher.available" -> true
214232
else -> false
215233
}
216234
}
@@ -271,6 +289,28 @@ class SettingsRegistry(
271289
success
272290
}
273291

292+
"launcher.current" -> {
293+
val componentName = when (value) {
294+
is String -> value
295+
else -> return false
296+
}
297+
Log.i(TAG, "Attempting to set default launcher to $componentName")
298+
val success = launcherController.setDefaultLauncher(componentName)
299+
if (success) {
300+
systemSettings["launcher.current"] = componentName
301+
Log.i(TAG, "Default launcher set to $componentName")
302+
} else {
303+
Log.w(TAG, "Failed to set default launcher to $componentName")
304+
}
305+
success
306+
}
307+
308+
"launcher.available" -> {
309+
// Read-only setting, cannot be changed by clients
310+
Log.w(TAG, "launcher.available is read-only")
311+
false
312+
}
313+
274314
else -> {
275315
Log.w(TAG, "Unknown Android system setting: $key")
276316
false
@@ -440,6 +480,8 @@ class SettingsRegistry(
440480
"audio.muted" -> value is Boolean
441481
"display.humane_enabled" -> value is Boolean
442482
"device.temperature" -> value is Number // Read-only, but validate type
483+
"launcher.current" -> value is String && value.contains("/")
484+
"launcher.available" -> false // Read-only
443485
else -> true // Allow unknown settings for extensibility
444486
}
445487
}

bridge-settings/src/main/java/com/penumbraos/bridge_settings/SettingsService.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SettingsService {
7777
settingsRegistry.setWebServer(webServer)
7878
settingsProvider.setWebServer(webServer)
7979

80+
// TODO: This seems to cause deadlocks sometimes
8081
webServer.start()
8182

8283
waitForBridgeSystem(TAG, bridge)

bridge-system/src/main/java/com/penumbraos/bridge_system/provider/WebSocketProvider.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class WebSocketProvider(private val client: OkHttpClient) : IWebSocketProvider.S
8181
}
8282
} else {
8383
Log.e("WebSocketProviderService", "WebSocket not found for requestId: $requestId")
84+
throw IllegalStateException("WebSocket not found for requestId: $requestId")
8485
}
8586
}
8687

0 commit comments

Comments
 (0)