Skip to content

Commit 81e01d7

Browse files
feat: shorten android home screen for long deployment-ID lists (#258)
1 parent 1057797 commit 81e01d7

2 files changed

Lines changed: 119 additions & 106 deletions

File tree

android/app/src/main/java/com/therealaleph/mhrv/ui/AppPickerDialog.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,19 @@ fun AppPickerDialog(
6161
}
6262

6363
val filtered: List<AppEntry> = remember(apps, query) {
64-
if (query.isBlank()) apps
64+
val base = if (query.isBlank()) apps
6565
else apps.filter {
6666
it.label.contains(query, ignoreCase = true) ||
6767
it.packageName.contains(query, ignoreCase = true)
6868
}
69+
// Pre-selected packages float to the top so the user can find what
70+
// they already chose without scrolling the whole list. The sort
71+
// key uses `initial` (the set passed when the dialog opened), not
72+
// the live `selected` state — re-checking inside the dialog must
73+
// not reorder rows under the user's finger. The new ordering takes
74+
// effect the next time the dialog opens. Stable sort preserves
75+
// the alphabetical-by-label order within each group.
76+
base.sortedByDescending { it.packageName in initial }
6977
}
7078

7179
AlertDialog(

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 110 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@ sealed class CaInstallOutcome {
7575
/**
7676
* Top-level screen. Intentionally one scrollable page rather than tabs —
7777
* first-run users need to see everything (deployment IDs, cert button,
78-
* Start) on one surface. Anything that isn't first-run critical lives in
79-
* collapsible sections (SNI pool, Advanced, Logs) so the default view
80-
* stays short.
78+
* Connect) on one surface. The Connect/Disconnect button sits right under
79+
* the Mode dropdown so a long deployment-ID list can't push it off-screen
80+
* for daily-use taps. Anything that isn't first-run critical (Apps Script
81+
* setup once filled, SNI pool, Advanced, Logs) lives in collapsible
82+
* sections so the default view stays short.
8183
*/
8284
@OptIn(ExperimentalMaterial3Api::class)
8385
@Composable
@@ -254,28 +256,111 @@ fun HomeScreen(
254256
onChange = { persist(cfg.copy(mode = it)) },
255257
)
256258

259+
// Connect/Disconnect lives right under Mode so users with a long
260+
// deployment-ID list don't have to scroll past it on every
261+
// session. Disabled state still acts as the "you're not set up
262+
// yet" signal — they'll expand the Apps Script section below to
263+
// resolve it.
264+
val isVpnRunning by VpnState.isRunning.collectAsState()
265+
Button(
266+
onClick = {
267+
if (isVpnRunning) {
268+
awaitingRunning = false
269+
onStop()
270+
} else {
271+
awaitingRunning = true
272+
// Connect flow: auto-resolve google_ip so we don't
273+
// hand the proxy a stale anycast target; repair
274+
// front_domain if it got corrupted into an IP
275+
// (SNI has to be a hostname); then fire onStart.
276+
// All three steps go through the Compose persist()
277+
// so a subsequent field edit can't overwrite the
278+
// fresh values with pre-resolve ones.
279+
scope.launch {
280+
// Only auto-fill google_ip if it's empty.
281+
// Issue #71: some Iranian ISPs return
282+
// poisoned A records for www.google.com that
283+
// resolve but then refuse TLS (or route to a
284+
// Google IP that's not on the GFE and can't
285+
// handle our SNI-rewrite). If the user has
286+
// manually set a working IP
287+
// (e.g. 216.239.38.120), we must NOT
288+
// overwrite it with a poisoned fresh lookup
289+
// just because the two values differ. They
290+
// can still force a re-resolve via the
291+
// explicit "Auto-detect" button above.
292+
var updated = cfg
293+
if (updated.googleIp.isBlank()) {
294+
val fresh = withContext(Dispatchers.IO) {
295+
NetworkDetect.resolveGoogleIp()
296+
}
297+
if (!fresh.isNullOrBlank()) {
298+
updated = updated.copy(googleIp = fresh)
299+
}
300+
}
301+
if (updated.frontDomain.isBlank() ||
302+
updated.frontDomain.parseAsIpOrNull() != null
303+
) {
304+
updated = updated.copy(frontDomain = "www.google.com")
305+
}
306+
if (updated !== cfg) persist(updated)
307+
onStart()
308+
}
309+
}
310+
},
311+
enabled = (isVpnRunning ||
312+
cfg.mode == Mode.GOOGLE_ONLY ||
313+
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning,
314+
colors = ButtonDefaults.buttonColors(
315+
containerColor = if (isVpnRunning) ErrRed else OkGreen,
316+
contentColor = androidx.compose.ui.graphics.Color.White,
317+
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
318+
),
319+
modifier = Modifier
320+
.fillMaxWidth()
321+
.heightIn(min = 52.dp),
322+
) {
323+
Text(
324+
when {
325+
transitioning -> ""
326+
isVpnRunning -> stringResource(R.string.btn_disconnect)
327+
else -> stringResource(R.string.btn_connect)
328+
},
329+
style = MaterialTheme.typography.titleMedium,
330+
)
331+
}
332+
257333
Spacer(Modifier.height(4.dp))
258-
SectionHeader(stringResource(R.string.sec_apps_script_relay))
259334

260335
val appsScriptEnabled = cfg.mode == Mode.APPS_SCRIPT || cfg.mode == Mode.FULL
261-
DeploymentIdsField(
262-
urls = cfg.appsScriptUrls,
263-
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
264-
enabled = appsScriptEnabled,
265-
)
336+
// Wrapped in a collapsible so a long ID list (10+ deployments
337+
// is normal in full-tunnel rotations) doesn't dominate the
338+
// screen once it's set up. Starts expanded for first-run users
339+
// (no IDs/key yet) so the form is immediately discoverable.
340+
CollapsibleSection(
341+
title = stringResource(R.string.sec_apps_script_relay),
342+
initiallyExpanded = appsScriptEnabled &&
343+
(cfg.appsScriptUrls.isEmpty() || cfg.authKey.isBlank()),
344+
) {
345+
DeploymentIdsField(
346+
urls = cfg.appsScriptUrls,
347+
onChange = { persist(cfg.copy(appsScriptUrls = it)) },
348+
enabled = appsScriptEnabled,
349+
)
266350

267-
OutlinedTextField(
268-
value = cfg.authKey,
269-
onValueChange = { persist(cfg.copy(authKey = it)) },
270-
label = { Text(stringResource(R.string.field_auth_key)) },
271-
singleLine = true,
272-
enabled = appsScriptEnabled,
273-
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
274-
modifier = Modifier.fillMaxWidth(),
275-
supportingText = {
276-
Text(stringResource(R.string.help_auth_key))
277-
},
278-
)
351+
OutlinedTextField(
352+
value = cfg.authKey,
353+
onValueChange = { persist(cfg.copy(authKey = it)) },
354+
label = { Text(stringResource(R.string.field_auth_key)) },
355+
singleLine = true,
356+
enabled = appsScriptEnabled,
357+
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
358+
modifier = Modifier.fillMaxWidth(),
359+
supportingText = {
360+
Text(stringResource(R.string.help_auth_key))
361+
},
362+
)
363+
}
279364

280365
Spacer(Modifier.height(4.dp))
281366
SectionHeader(stringResource(R.string.sec_network))
@@ -379,90 +464,10 @@ fun HomeScreen(
379464
}
380465

381466
Spacer(Modifier.height(8.dp))
382-
383-
// Unified Connect/Disconnect button. Color + label track the
384-
// service's real "is it running right now" state (via
385-
// `VpnState.isRunning`), so the UI never shows "Connect" while
386-
// the tunnel is still up or "Disconnect" after the service
387-
// finished tearing down. Two tap paths, one button:
388-
// - running=false → green "Connect" → runs the auto-resolve
389-
// + persist + onStart() sequence we used to hang off the
390-
// old Start button.
391-
// - running=true → red "Disconnect" → fires onStop().
392-
val isVpnRunning by VpnState.isRunning.collectAsState()
393-
Button(
394-
onClick = {
395-
if (isVpnRunning) {
396-
awaitingRunning = false
397-
onStop()
398-
} else {
399-
awaitingRunning = true
400-
// Connect flow: auto-resolve google_ip so we don't
401-
// hand the proxy a stale anycast target; repair
402-
// front_domain if it got corrupted into an IP
403-
// (SNI has to be a hostname); then fire onStart.
404-
// All three steps go through the Compose persist()
405-
// so a subsequent field edit can't overwrite the
406-
// fresh values with pre-resolve ones.
407-
scope.launch {
408-
// Only auto-fill google_ip if it's empty.
409-
// Issue #71: some Iranian ISPs return
410-
// poisoned A records for www.google.com that
411-
// resolve but then refuse TLS (or route to a
412-
// Google IP that's not on the GFE and can't
413-
// handle our SNI-rewrite). If the user has
414-
// manually set a working IP
415-
// (e.g. 216.239.38.120), we must NOT
416-
// overwrite it with a poisoned fresh lookup
417-
// just because the two values differ. They
418-
// can still force a re-resolve via the
419-
// explicit "Auto-detect" button above.
420-
var updated = cfg
421-
if (updated.googleIp.isBlank()) {
422-
val fresh = withContext(Dispatchers.IO) {
423-
NetworkDetect.resolveGoogleIp()
424-
}
425-
if (!fresh.isNullOrBlank()) {
426-
updated = updated.copy(googleIp = fresh)
427-
}
428-
}
429-
if (updated.frontDomain.isBlank() ||
430-
updated.frontDomain.parseAsIpOrNull() != null
431-
) {
432-
updated = updated.copy(frontDomain = "www.google.com")
433-
}
434-
if (updated !== cfg) persist(updated)
435-
onStart()
436-
}
437-
}
438-
},
439-
enabled = (isVpnRunning ||
440-
cfg.mode == Mode.GOOGLE_ONLY ||
441-
(cfg.hasDeploymentId && cfg.authKey.isNotBlank())) && !transitioning,
442-
colors = ButtonDefaults.buttonColors(
443-
containerColor = if (isVpnRunning) ErrRed else OkGreen,
444-
contentColor = androidx.compose.ui.graphics.Color.White,
445-
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
446-
),
447-
modifier = Modifier
448-
.fillMaxWidth()
449-
.heightIn(min = 52.dp),
450-
) {
451-
Text(
452-
when {
453-
transitioning -> ""
454-
isVpnRunning -> stringResource(R.string.btn_disconnect)
455-
else -> stringResource(R.string.btn_connect)
456-
},
457-
style = MaterialTheme.typography.titleMedium,
458-
)
459-
}
460-
461-
Spacer(Modifier.height(4.dp))
462-
// Secondary accent button — FilledTonalButton reads as a lower-
463-
// priority action next to Start/Stop, matching the desktop UI's
464-
// visual hierarchy where Install CA is offered as a helper
465-
// button rather than the headline action.
467+
// Secondary action — FilledTonalButton signals "helper" against
468+
// the primary Connect/Disconnect button at the top. Kept down
469+
// here because cert install is a one-time setup step; daily
470+
// users never tap it again.
466471
FilledTonalButton(
467472
onClick = { showInstallDialog = true },
468473
modifier = Modifier.fillMaxWidth(),

0 commit comments

Comments
 (0)