Skip to content

Commit 8e0b729

Browse files
committed
feat: implement persistent device flow polling and manual status refresh
- Implement `SavedStateHandle` in `AuthenticationViewModel` to persist and restore device flow session state across process death. - Add background polling for GitHub device tokens using a dedicated `pollingJob` with a 15-second interval. - Update `AuthenticationRepository` to support single poll attempts with granular error handling for pending, expired, or denied authorizations. - Enhance `AuthenticationRoot` UI with a "Check status" button to allow users to manually trigger a token poll. - Add `OnResumed` lifecycle effect to automatically trigger a status check when the user returns to the app from the browser. - Include new localized strings for polling status and manual refresh actions. - Improve error categorization and logging for the authentication workflow.
1 parent 223c091 commit 8e0b729

7 files changed

Lines changed: 277 additions & 18 deletions

File tree

core/presentation/src/commonMain/composeResources/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,8 @@
444444
<string name="auth_hint_try_again">Please try signing in again to get a new code.</string>
445445
<string name="auth_hint_check_connection">Please check your internet connection and try again.</string>
446446
<string name="auth_hint_denied">You denied the authorization request. Try again if this was unintentional.</string>
447+
<string name="auth_check_status">I already authorized</string>
448+
<string name="auth_polling_status">Checking…</string>
447449

448450
<!-- Read More / Show Less -->
449451
<string name="read_more">Read More</string>

feature/auth/data/src/commonMain/kotlin/zed/rainxch/auth/data/repository/AuthenticationRepositoryImpl.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,61 @@ class AuthenticationRepositoryImpl(
223223
}
224224
}
225225

226+
override suspend fun pollDeviceTokenOnce(deviceCode: String): Result<GithubDeviceTokenSuccess?> =
227+
withContext(Dispatchers.IO) {
228+
val clientId = BuildKonfig.GITHUB_CLIENT_ID
229+
try {
230+
val res = GitHubAuthApi.pollDeviceToken(clientId, deviceCode)
231+
val success = res.getOrNull()?.toDomain()
232+
233+
if (success != null) {
234+
logger.debug("✅ Single poll: Token received! Saving...")
235+
saveTokenWithVerification(success)
236+
Result.success(success)
237+
} else {
238+
val error = res.exceptionOrNull()
239+
val errorMsg = (error?.message ?: "").lowercase()
240+
241+
when {
242+
"authorization_pending" in errorMsg || "slow_down" in errorMsg -> {
243+
Result.success(null)
244+
}
245+
246+
"access_denied" in errorMsg -> {
247+
Result.failure(
248+
Exception("Authentication was denied. Please try again if this was a mistake."),
249+
)
250+
}
251+
252+
"expired_token" in errorMsg ||
253+
"expired_device_code" in errorMsg ||
254+
"token_expired" in errorMsg -> {
255+
Result.failure(
256+
Exception("Authorization code expired. Please try again."),
257+
)
258+
}
259+
260+
"bad_verification_code" in errorMsg ||
261+
"incorrect_device_code" in errorMsg -> {
262+
Result.failure(
263+
Exception("Invalid verification code. Please restart authentication."),
264+
)
265+
}
266+
267+
else -> {
268+
logger.debug("⚠️ Single poll unknown error: $errorMsg")
269+
Result.success(null)
270+
}
271+
}
272+
}
273+
} catch (e: CancellationException) {
274+
throw e
275+
} catch (e: Exception) {
276+
logger.debug("⚠️ Single poll network error: ${e.message}")
277+
Result.success(null)
278+
}
279+
}
280+
226281
private fun isNetworkError(errorMsg: String): Boolean =
227282
errorMsg.contains("unable to resolve") ||
228283
errorMsg.contains("no address") ||

feature/auth/domain/src/commonMain/kotlin/zed/rainxch/auth/domain/repository/AuthenticationRepository.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,12 @@ interface AuthenticationRepository {
1010
suspend fun startDeviceFlow(): GithubDeviceStart
1111

1212
suspend fun awaitDeviceToken(start: GithubDeviceStart): GithubDeviceTokenSuccess
13+
14+
/**
15+
* Single poll attempt. Returns:
16+
* - [Result.success] with non-null [GithubDeviceTokenSuccess] if user authorized
17+
* - [Result.success] with null if authorization is still pending (keep polling)
18+
* - [Result.failure] on terminal errors (denied, expired, invalid code)
19+
*/
20+
suspend fun pollDeviceTokenOnce(deviceCode: String): Result<GithubDeviceTokenSuccess?>
1321
}

feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,8 @@ sealed interface AuthenticationAction {
2222
) : AuthenticationAction
2323

2424
data object SkipLogin : AuthenticationAction
25+
26+
data object PollNow : AuthenticationAction
27+
28+
data object OnResumed : AuthenticationAction
2529
}

feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import androidx.compose.material.icons.Icons
1616
import androidx.compose.material.icons.filled.ContentCopy
1717
import androidx.compose.material.icons.filled.DoneAll
1818
import androidx.compose.material.icons.filled.OpenWith
19+
import androidx.compose.material.icons.filled.Refresh
1920
import androidx.compose.material3.Card
2021
import androidx.compose.material3.CircularWavyProgressIndicator
2122
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
2223
import androidx.compose.material3.Icon
2324
import androidx.compose.material3.IconButton
2425
import androidx.compose.material3.IconButtonDefaults
2526
import androidx.compose.material3.MaterialTheme
27+
import androidx.compose.material3.OutlinedButton
2628
import androidx.compose.material3.Scaffold
2729
import androidx.compose.material3.Text
2830
import androidx.compose.material3.TextButton
@@ -37,6 +39,8 @@ import androidx.compose.ui.text.font.FontWeight
3739
import androidx.compose.ui.text.style.TextAlign
3840
import androidx.compose.ui.tooling.preview.Preview
3941
import androidx.compose.ui.unit.dp
42+
import androidx.lifecycle.Lifecycle
43+
import androidx.lifecycle.compose.LifecycleEventEffect
4044
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4145
import org.jetbrains.compose.resources.painterResource
4246
import org.jetbrains.compose.resources.stringResource
@@ -48,8 +52,10 @@ import zed.rainxch.core.presentation.theme.GithubStoreTheme
4852
import zed.rainxch.core.presentation.utils.ObserveAsEvents
4953
import zed.rainxch.githubstore.core.presentation.res.Res
5054
import zed.rainxch.githubstore.core.presentation.res.app_icon
55+
import zed.rainxch.githubstore.core.presentation.res.auth_check_status
5156
import zed.rainxch.githubstore.core.presentation.res.auth_code_expires_in
5257
import zed.rainxch.githubstore.core.presentation.res.auth_error_with_message
58+
import zed.rainxch.githubstore.core.presentation.res.auth_polling_status
5359
import zed.rainxch.githubstore.core.presentation.res.continue_as_guest
5460
import zed.rainxch.githubstore.core.presentation.res.copy_code
5561
import zed.rainxch.githubstore.core.presentation.res.enter_code_on_github
@@ -71,6 +77,10 @@ fun AuthenticationRoot(
7177
) {
7278
val state by viewModel.state.collectAsStateWithLifecycle()
7379

80+
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
81+
viewModel.onAction(AuthenticationAction.OnResumed)
82+
}
83+
7484
ObserveAsEvents(viewModel.events) { event ->
7585
when (event) {
7686
AuthenticationEvents.OnNavigateToMain -> {
@@ -306,6 +316,32 @@ fun StateDevicePrompt(
306316
},
307317
)
308318

319+
Spacer(Modifier.height(12.dp))
320+
321+
OutlinedButton(
322+
onClick = { onAction(AuthenticationAction.PollNow) },
323+
enabled = !state.isPolling,
324+
shape = RoundedCornerShape(16.dp),
325+
) {
326+
Icon(
327+
imageVector = Icons.Default.Refresh,
328+
contentDescription = null,
329+
modifier = Modifier.size(18.dp),
330+
)
331+
332+
Spacer(Modifier.size(8.dp))
333+
334+
Text(
335+
text =
336+
if (state.isPolling) {
337+
stringResource(Res.string.auth_polling_status)
338+
} else {
339+
stringResource(Res.string.auth_check_status)
340+
},
341+
style = MaterialTheme.typography.bodyMedium,
342+
)
343+
}
344+
309345
Spacer(Modifier.weight(2f))
310346
}
311347
}

feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ data class AuthenticationState(
66
val loginState: AuthLoginState = AuthLoginState.LoggedOut,
77
val copied: Boolean = false,
88
val info: String? = null,
9+
val isPolling: Boolean = false,
910
)

0 commit comments

Comments
 (0)