Skip to content

Commit dc87944

Browse files
authored
Add ability to add custom app target package to list (#111)
Signed-off-by: Emmanuel Delgado <mannydelgado12@gmail.com>
1 parent c9b931e commit dc87944

File tree

5 files changed

+270
-14
lines changed

5 files changed

+270
-14
lines changed

trailblaze-host/src/main/java/xyz/block/trailblaze/ui/TrailblazeSettingsRepo.kt

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,48 @@ class TrailblazeSettingsRepo(
113113
return serverStateFlow.value.appConfig.selectedTrailblazeDriverTypes.values.toSet()
114114
}
115115

116+
/** Adds a custom app package name to the persisted list. */
117+
fun addCustomAppPackageName(packageName: String) {
118+
updateAppConfig { config ->
119+
val trimmed = packageName.trim()
120+
if (trimmed.isNotEmpty() && trimmed !in config.customAppPackageNames) {
121+
config.copy(customAppPackageNames = config.customAppPackageNames + trimmed)
122+
} else {
123+
config
124+
}
125+
}
126+
}
127+
128+
/** Removes a custom app package name from the persisted list. */
129+
fun removeCustomAppPackageName(packageName: String) {
130+
updateAppConfig { config ->
131+
config.copy(
132+
customAppPackageNames = config.customAppPackageNames - packageName,
133+
// Clear selection if the removed package was selected
134+
selectedTargetAppId = if (config.selectedTargetAppId == packageName) {
135+
null
136+
} else {
137+
config.selectedTargetAppId
138+
},
139+
)
140+
}
141+
}
142+
116143
fun getCurrentSelectedTargetApp(): TrailblazeHostAppTarget? {
117-
return allTargetApps()
144+
val selectedId = serverStateFlow.value.appConfig.selectedTargetAppId ?: return null
145+
146+
// Check registered app targets first
147+
val registeredTarget = allTargetApps()
118148
.filter { it != defaultHostAppTarget }
119-
.firstOrNull { appTarget ->
120-
appTarget.id == serverStateFlow.value.appConfig.selectedTargetAppId
121-
}
149+
.firstOrNull { appTarget -> appTarget.id == selectedId }
150+
if (registeredTarget != null) return registeredTarget
151+
152+
// Fall back to custom package names added by the user
153+
if (selectedId in serverStateFlow.value.appConfig.customAppPackageNames) {
154+
return TrailblazeHostAppTarget.CustomPackageHostAppTarget(selectedId)
155+
}
156+
157+
return null
122158
}
123159

124160
/** Manages HTTP/HTTPS port resolution (runtime CLI overrides + persisted fallback). */

trailblaze-host/src/main/java/xyz/block/trailblaze/ui/composables/DeviceSelectionDialog.kt

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,13 @@ fun DeviceConfigurationContent(
9393
mutableStateOf(
9494
availableDevices.filter { device ->
9595
val isWebPlatform = device.platform == TrailblazeDevicePlatform.WEB
96+
val hasLoadedApps = installedAppIdsByDevice.containsKey(device.trailblazeDeviceId)
9697
val appIdIfInstalled = selectedTargetApp?.getAppIdIfInstalled(
9798
platform = device.platform,
9899
installedAppIds = installedAppIdsByDevice[device.trailblazeDeviceId] ?: emptySet()
99100
)
100101
device.instanceId in lastSelectedDeviceInstanceIds &&
101-
(isWebPlatform || selectedTargetApp == null || appIdIfInstalled != null)
102+
(isWebPlatform || selectedTargetApp == null || appIdIfInstalled != null || !hasLoadedApps)
102103
}.toSet()
103104
)
104105
}
@@ -110,6 +111,14 @@ fun DeviceConfigurationContent(
110111
}
111112
}
112113

114+
// Re-refresh when the selected target app changes (e.g., user adds a custom package)
115+
// to ensure installed app data is up-to-date for the new target.
116+
LaunchedEffect(selectedTargetApp) {
117+
if (selectedTargetApp != null) {
118+
deviceManager.loadDevices()
119+
}
120+
}
121+
113122
Column(
114123
modifier = modifier.fillMaxWidth(),
115124
verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -174,13 +183,17 @@ fun DeviceConfigurationContent(
174183
) {
175184
availableDevices.forEach { device ->
176185
val isWebPlatform = device.platform == TrailblazeDevicePlatform.WEB
186+
// Check whether we have loaded installed app data for this device yet.
187+
// installedAppIdsByDevice only has entries after loadDevices() completes for a device.
188+
val hasLoadedInstalledApps = installedAppIdsByDevice.containsKey(device.trailblazeDeviceId)
177189
val appIdIfInstalled = selectedTargetApp?.getAppIdIfInstalled(
178190
platform = device.platform,
179191
installedAppIds = installedAppIdsByDevice[device.trailblazeDeviceId] ?: emptySet()
180192
)
181193
val isAppInstalled = appIdIfInstalled != null
182-
// Web browsers are always enabled (no app to install); other devices need app check
183-
val isDeviceEnabled = isWebPlatform || selectedTargetApp == null || isAppInstalled
194+
// Web browsers are always enabled; other devices need app check.
195+
// While installed app data is loading, allow device selection (don't block on stale data).
196+
val isDeviceEnabled = isWebPlatform || selectedTargetApp == null || isAppInstalled || !hasLoadedInstalledApps
184197
val activeSessionId = activeDeviceSessions[device.trailblazeDeviceId]
185198
val hasActiveSession = activeSessionId != null
186199
// Get version info for the installed app
@@ -194,6 +207,7 @@ fun DeviceConfigurationContent(
194207
installedAppId = appIdIfInstalled,
195208
appTarget = selectedTargetApp,
196209
appVersionInfo = versionInfo,
210+
hasLoadedInstalledApps = hasLoadedInstalledApps,
197211
activeSessionId = activeSessionId?.value,
198212
onSessionClick = onSessionClick,
199213
onToggle = {
@@ -436,6 +450,7 @@ fun SingleDeviceListItem(
436450
appTarget: TrailblazeHostAppTarget?,
437451
installedAppId: String?,
438452
appVersionInfo: AppVersionInfo? = null,
453+
hasLoadedInstalledApps: Boolean = true,
439454
activeSessionId: String? = null,
440455
onSessionClick: ((String) -> Unit)? = null,
441456
onToggle: () -> Unit,
@@ -444,8 +459,9 @@ fun SingleDeviceListItem(
444459
// Web browsers don't have apps to install - they're always ready to use
445460
val isWebPlatform = device.platform == TrailblazeDevicePlatform.WEB
446461
val isAppInstalled = installedAppId != null
447-
// Web devices are always enabled; other devices need app installation check
448-
val isEnabled = isWebPlatform || appTarget == null || isAppInstalled
462+
// Web devices are always enabled; other devices need app check.
463+
// While installed app data hasn't loaded yet, allow selection (don't show false negatives).
464+
val isEnabled = isWebPlatform || appTarget == null || isAppInstalled || !hasLoadedInstalledApps
449465
// A device has an active session if there's a session ID
450466
val hasActiveSession = activeSessionId != null
451467

@@ -597,6 +613,18 @@ fun SingleDeviceListItem(
597613
color = MaterialTheme.colorScheme.primary,
598614
fontWeight = FontWeight.Medium
599615
)
616+
} else if (!hasLoadedInstalledApps) {
617+
// Data hasn't loaded yet — show a neutral "checking" state instead of a false negative
618+
CircularProgressIndicator(
619+
modifier = Modifier.size(16.dp),
620+
strokeWidth = 2.dp,
621+
)
622+
Text(
623+
text = "Checking if ${appTarget.displayName} is installed...",
624+
style = MaterialTheme.typography.bodySmall,
625+
color = MaterialTheme.colorScheme.onSurfaceVariant,
626+
fontWeight = FontWeight.Medium
627+
)
600628
} else {
601629
Icon(
602630
imageVector = Icons.Default.Close,

trailblaze-host/src/main/java/xyz/block/trailblaze/ui/tabs/devices/TargetAppConfigRow.kt

Lines changed: 168 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
11
package xyz.block.trailblaze.ui.tabs.devices
22

3+
import androidx.compose.foundation.layout.Arrangement
34
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.Row
46
import androidx.compose.foundation.layout.Spacer
57
import androidx.compose.foundation.layout.fillMaxWidth
68
import androidx.compose.foundation.layout.height
9+
import androidx.compose.foundation.layout.size
10+
import androidx.compose.material.icons.Icons
11+
import androidx.compose.material.icons.filled.Add
12+
import androidx.compose.material.icons.filled.Delete
13+
import androidx.compose.material3.AlertDialog
14+
import androidx.compose.material3.Button
715
import androidx.compose.material3.DropdownMenuItem
816
import androidx.compose.material3.ExperimentalMaterial3Api
917
import androidx.compose.material3.ExposedDropdownMenuBox
1018
import androidx.compose.material3.ExposedDropdownMenuDefaults
19+
import androidx.compose.material3.HorizontalDivider
20+
import androidx.compose.material3.Icon
21+
import androidx.compose.material3.IconButton
22+
import androidx.compose.material3.MaterialTheme
23+
import androidx.compose.material3.OutlinedTextField
1124
import androidx.compose.material3.Text
25+
import androidx.compose.material3.TextButton
1226
import androidx.compose.material3.TextField
1327
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.collectAsState
1429
import androidx.compose.runtime.getValue
1530
import androidx.compose.runtime.mutableStateOf
1631
import androidx.compose.runtime.remember
1732
import androidx.compose.runtime.setValue
33+
import androidx.compose.ui.Alignment
1834
import androidx.compose.ui.Modifier
1935
import androidx.compose.ui.unit.dp
2036
import xyz.block.trailblaze.model.TrailblazeHostAppTarget
@@ -25,6 +41,10 @@ import xyz.block.trailblaze.ui.composables.CodeBlock
2541
/**
2642
* Reusable row component for target app selection.
2743
* Used by DevicesTab for target app configuration.
44+
*
45+
* Shows registered app targets followed by user-added custom package names.
46+
* Includes an "Add Package" option at the bottom of the dropdown to let users
47+
* register new package names which are persisted in app settings.
2848
*/
2949
@OptIn(ExperimentalMaterial3Api::class)
3050
@Composable
@@ -36,6 +56,19 @@ fun TargetAppConfigRow(
3656
modifier: Modifier = Modifier,
3757
) {
3858
var expanded by remember { mutableStateOf(false) }
59+
var showAddPackageDialog by remember { mutableStateOf(false) }
60+
61+
val currentState by settingsRepo.serverStateFlow.collectAsState()
62+
val customPackageNames = currentState.appConfig.customAppPackageNames
63+
64+
// Determine display value: could be a registered target app or a custom package name
65+
val selectedCustomPackage = if (selectedTargetApp == null) {
66+
currentState.appConfig.selectedTargetAppId?.takeIf { it in customPackageNames }
67+
} else null
68+
69+
val displayValue = selectedTargetApp?.displayName
70+
?: selectedCustomPackage
71+
?: "Select target app"
3972

4073
Column(modifier = modifier) {
4174
// Target App Dropdown
@@ -48,7 +81,7 @@ fun TargetAppConfigRow(
4881
) {
4982
TextField(
5083
readOnly = true,
51-
value = selectedTargetApp?.displayName ?: "Select target app",
84+
value = displayValue,
5285
onValueChange = { },
5386
label = { Text("Target App") },
5487
leadingIcon = {
@@ -67,18 +100,79 @@ fun TargetAppConfigRow(
67100
expanded = false
68101
}
69102
) {
70-
deviceManager.availableAppTargets.forEach { selectedTargetApp ->
103+
// Registered app targets
104+
deviceManager.availableAppTargets.forEach { appTarget ->
71105
DropdownMenuItem(
72106
leadingIcon = {
73-
deviceManager.appIconProvider.getIcon(selectedTargetApp)
107+
deviceManager.appIconProvider.getIcon(appTarget)
74108
},
75-
text = { Text(selectedTargetApp.displayName) },
109+
text = { Text(appTarget.displayName) },
76110
onClick = {
77-
settingsRepo.targetAppSelected(selectedTargetApp)
111+
settingsRepo.targetAppSelected(appTarget)
78112
expanded = false
79113
}
80114
)
81115
}
116+
117+
// User-added custom package names
118+
if (customPackageNames.isNotEmpty()) {
119+
HorizontalDivider()
120+
121+
customPackageNames.forEach { packageName ->
122+
DropdownMenuItem(
123+
text = {
124+
Row(
125+
modifier = Modifier.fillMaxWidth(),
126+
horizontalArrangement = Arrangement.SpaceBetween,
127+
verticalAlignment = Alignment.CenterVertically,
128+
) {
129+
Text(packageName, modifier = Modifier.weight(1f))
130+
IconButton(
131+
onClick = {
132+
settingsRepo.removeCustomAppPackageName(packageName)
133+
},
134+
modifier = Modifier.size(24.dp)
135+
) {
136+
Icon(
137+
imageVector = Icons.Default.Delete,
138+
contentDescription = "Remove $packageName",
139+
tint = MaterialTheme.colorScheme.error,
140+
modifier = Modifier.size(16.dp),
141+
)
142+
}
143+
}
144+
},
145+
onClick = {
146+
settingsRepo.updateAppConfig { config ->
147+
config.copy(selectedTargetAppId = packageName)
148+
}
149+
expanded = false
150+
}
151+
)
152+
}
153+
}
154+
155+
// "Add Package" option at the bottom
156+
HorizontalDivider()
157+
DropdownMenuItem(
158+
leadingIcon = {
159+
Icon(
160+
imageVector = Icons.Default.Add,
161+
contentDescription = null,
162+
tint = MaterialTheme.colorScheme.primary,
163+
)
164+
},
165+
text = {
166+
Text(
167+
"Add Package",
168+
color = MaterialTheme.colorScheme.primary,
169+
)
170+
},
171+
onClick = {
172+
expanded = false
173+
showAddPackageDialog = true
174+
}
175+
)
82176
}
83177
}
84178

@@ -92,4 +186,73 @@ fun TargetAppConfigRow(
92186
)
93187
}
94188
}
189+
190+
// Add Package Dialog
191+
if (showAddPackageDialog) {
192+
AddPackageDialog(
193+
onDismiss = { showAddPackageDialog = false },
194+
onAdd = { packageName ->
195+
settingsRepo.addCustomAppPackageName(packageName)
196+
// Auto-select the newly added package
197+
settingsRepo.updateAppConfig { config ->
198+
config.copy(selectedTargetAppId = packageName.trim())
199+
}
200+
showAddPackageDialog = false
201+
},
202+
existingPackages = customPackageNames,
203+
)
204+
}
205+
}
206+
207+
/**
208+
* Dialog for adding a new custom application package name.
209+
*/
210+
@Composable
211+
private fun AddPackageDialog(
212+
onDismiss: () -> Unit,
213+
onAdd: (String) -> Unit,
214+
existingPackages: List<String>,
215+
) {
216+
var packageName by remember { mutableStateOf("") }
217+
val trimmed = packageName.trim()
218+
val isDuplicate = trimmed in existingPackages
219+
val isValid = trimmed.isNotEmpty() && !isDuplicate
220+
221+
AlertDialog(
222+
onDismissRequest = onDismiss,
223+
title = { Text("Add Application Package") },
224+
text = {
225+
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
226+
Text(
227+
text = "Enter the package name of the application you want to test.",
228+
style = MaterialTheme.typography.bodyMedium,
229+
)
230+
OutlinedTextField(
231+
value = packageName,
232+
onValueChange = { packageName = it },
233+
label = { Text("Package Name") },
234+
placeholder = { Text("com.example.myapp") },
235+
modifier = Modifier.fillMaxWidth(),
236+
singleLine = true,
237+
isError = isDuplicate,
238+
supportingText = if (isDuplicate) {
239+
{ Text("This package has already been added") }
240+
} else null,
241+
)
242+
}
243+
},
244+
confirmButton = {
245+
Button(
246+
onClick = { onAdd(trimmed) },
247+
enabled = isValid,
248+
) {
249+
Text("Add")
250+
}
251+
},
252+
dismissButton = {
253+
TextButton(onClick = onDismiss) {
254+
Text("Cancel")
255+
}
256+
},
257+
)
95258
}

0 commit comments

Comments
 (0)