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