From 6add7e133b44cd986c9e5a83aedddf5494523bc8 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 18:34:26 +0500 Subject: [PATCH 1/3] Reset auth path to Backend on process restart so a stale Direct escalation doesn't strand users on github-blocked networks --- .../presentation/AuthenticationViewModel.kt | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt index 5b3751321..2dd36bda5 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt @@ -345,7 +345,7 @@ class AuthenticationViewModel( ) } - saveToSavedState(start.deviceCode, startUi, authPath) + saveToSavedState(start.deviceCode, startUi) startCountdown(start.expiresInSec) startPolling(start.deviceCode) @@ -424,7 +424,12 @@ class AuthenticationViewModel( if (outcome.path != authPath) { logger.debug("Auth path escalated from $authPath to ${outcome.path}") authPath = outcome.path - savedStateHandle[KEY_AUTH_PATH] = authPath.name + // Intentionally not persisted: escalation is a reaction to the + // current network's reachability and shouldn't carry across a + // process restart. A user whose Backend hiccupped once and got + // bumped to Direct shouldn't be stuck on Direct forever — esp. + // on networks where github.com is the unreachable side. Next + // process start re-evaluates from Backend. } when (val result = outcome.result) { @@ -497,7 +502,6 @@ class AuthenticationViewModel( private fun saveToSavedState( deviceCode: String, startUi: GithubDeviceStartUi, - path: AuthPath, ) { savedStateHandle[KEY_DEVICE_CODE] = deviceCode savedStateHandle[KEY_USER_CODE] = startUi.userCode @@ -506,7 +510,6 @@ class AuthenticationViewModel( savedStateHandle[KEY_INTERVAL_SEC] = startUi.intervalSec savedStateHandle[KEY_EXPIRES_IN_SEC] = startUi.expiresInSec savedStateHandle[KEY_START_TIME_MILLIS] = System.currentTimeMillis() - savedStateHandle[KEY_AUTH_PATH] = path.name } private fun clearSavedState() { @@ -520,13 +523,6 @@ class AuthenticationViewModel( val expiresInSec = savedStateHandle.get(KEY_EXPIRES_IN_SEC) ?: return val intervalSec = savedStateHandle.get(KEY_INTERVAL_SEC) ?: 5 val startTimeMillis = savedStateHandle.get(KEY_START_TIME_MILLIS) ?: return - val restoredPath = - savedStateHandle.get(KEY_AUTH_PATH)?.let { - runCatching { AuthPath.valueOf(it) }.getOrNull() - } ?: run { - clearSavedState() - return - } val elapsedSec = ((System.currentTimeMillis() - startTimeMillis) / 1000).toInt() val remainingSec = expiresInSec - elapsedSec @@ -536,7 +532,15 @@ class AuthenticationViewModel( return } - authPath = restoredPath + // Always restart on Backend. A prior Direct escalation reflected the + // network at the time it happened; on a fresh process the network may + // be different (e.g., user switched away from a hostile WiFi, or back + // to one where github.com is filtered). The escalation logic in + // pollDeviceTokenOnce will re-promote to Direct if Backend is still + // failing on the new network. + authPath = AuthPath.Backend + // Drop any legacy auth_path bundle entry so it never resurrects. + savedStateHandle.remove(KEY_AUTH_PATH) logger.debug("Restored auth session on path=$authPath") val startUi = From 18655c2b8d1ea12bd11dc4e3b5bda3174fd697e3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 18:34:32 +0500 Subject: [PATCH 2/3] Suppress and auto-dismiss the rate-limit dialog while on the auth screen --- .../kotlin/zed/rainxch/githubstore/Main.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 358e407de..53044aad8 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -86,7 +86,19 @@ fun App(deepLinkUri: String? = null) { ) { ApplyAndroidSystemBars(state.isDarkTheme) - if (state.showRateLimitDialog && state.rateLimitInfo != null) { + // Suppress the rate-limit dialog while the user is on the auth + // screen. They already accepted the prompt and are mid-sign-in; + // re-emitting the same dialog over the auth UI is noise that + // also blocks them from finishing the device-flow steps. Also + // flush any pending flag set by background API calls during + // auth, so it doesn't ghost back when the user returns home. + val onAuthScreen = currentScreen is GithubStoreGraph.AuthenticationScreen + LaunchedEffect(onAuthScreen, state.showRateLimitDialog) { + if (onAuthScreen && state.showRateLimitDialog) { + viewModel.onAction(MainAction.DismissRateLimitDialog) + } + } + if (state.showRateLimitDialog && state.rateLimitInfo != null && !onAuthScreen) { RateLimitDialog( rateLimitInfo = state.rateLimitInfo!!, isAuthenticated = state.isLoggedIn, From d61bb2d2fe2f0e04e3ac45d21e0de8f70efe1ae1 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 28 Apr 2026 21:10:13 +0500 Subject: [PATCH 3/3] Restore authPath persistence and gate mid-session transitions to one-way Backend->Direct only --- .../presentation/AuthenticationViewModel.kt | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt index 2dd36bda5..04201050a 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt @@ -345,7 +345,7 @@ class AuthenticationViewModel( ) } - saveToSavedState(start.deviceCode, startUi) + saveToSavedState(start.deviceCode, startUi, authPath) startCountdown(start.expiresInSec) startPolling(start.deviceCode) @@ -422,14 +422,28 @@ class AuthenticationViewModel( } if (outcome.path != authPath) { - logger.debug("Auth path escalated from $authPath to ${outcome.path}") - authPath = outcome.path - // Intentionally not persisted: escalation is a reaction to the - // current network's reachability and shouldn't carry across a - // process restart. A user whose Backend hiccupped once and got - // bumped to Direct shouldn't be stuck on Direct forever — esp. - // on networks where github.com is the unreachable side. Next - // process start re-evaluates from Backend. + // Mid-session transitions are strictly one-way: only the + // Backend → Direct escalation that AuthenticationRepositoryImpl + // performs on infrastructure errors (timeouts, connect/socket + // failures, 5xx) is legitimate. Direct → Backend is not a path + // the repository ever returns; treat any such outcome as a + // defensive no-op rather than silently flipping back to a + // probably-broken Backend mid-flow. The infrastructure-failure + // gate itself lives at the repository (single source of + // truth — see `pollDeviceTokenOnce` and + // `Throwable.isAuthInfrastructureError()`); the VM only + // enforces direction. + val isLegalEscalation = + authPath == AuthPath.Backend && outcome.path == AuthPath.Direct + if (isLegalEscalation) { + logger.debug("Auth path escalated from $authPath to ${outcome.path}") + authPath = outcome.path + savedStateHandle[KEY_AUTH_PATH] = authPath.name + } else { + logger.warn( + "Refusing invalid auth path transition $authPath → ${outcome.path}", + ) + } } when (val result = outcome.result) { @@ -502,6 +516,7 @@ class AuthenticationViewModel( private fun saveToSavedState( deviceCode: String, startUi: GithubDeviceStartUi, + path: AuthPath, ) { savedStateHandle[KEY_DEVICE_CODE] = deviceCode savedStateHandle[KEY_USER_CODE] = startUi.userCode @@ -510,6 +525,7 @@ class AuthenticationViewModel( savedStateHandle[KEY_INTERVAL_SEC] = startUi.intervalSec savedStateHandle[KEY_EXPIRES_IN_SEC] = startUi.expiresInSec savedStateHandle[KEY_START_TIME_MILLIS] = System.currentTimeMillis() + savedStateHandle[KEY_AUTH_PATH] = path.name } private fun clearSavedState() { @@ -523,6 +539,13 @@ class AuthenticationViewModel( val expiresInSec = savedStateHandle.get(KEY_EXPIRES_IN_SEC) ?: return val intervalSec = savedStateHandle.get(KEY_INTERVAL_SEC) ?: 5 val startTimeMillis = savedStateHandle.get(KEY_START_TIME_MILLIS) ?: return + val restoredPath = + savedStateHandle.get(KEY_AUTH_PATH)?.let { + runCatching { AuthPath.valueOf(it) }.getOrNull() + } ?: run { + clearSavedState() + return + } val elapsedSec = ((System.currentTimeMillis() - startTimeMillis) / 1000).toInt() val remainingSec = expiresInSec - elapsedSec @@ -532,15 +555,7 @@ class AuthenticationViewModel( return } - // Always restart on Backend. A prior Direct escalation reflected the - // network at the time it happened; on a fresh process the network may - // be different (e.g., user switched away from a hostile WiFi, or back - // to one where github.com is filtered). The escalation logic in - // pollDeviceTokenOnce will re-promote to Direct if Backend is still - // failing on the new network. - authPath = AuthPath.Backend - // Drop any legacy auth_path bundle entry so it never resurrects. - savedStateHandle.remove(KEY_AUTH_PATH) + authPath = restoredPath logger.debug("Restored auth session on path=$authPath") val startUi =