From ece0f76d2c2a84b45f3816a58102d08201f1726f Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 28 Feb 2026 05:57:27 +0500 Subject: [PATCH 1/4] feat(auth): Implement session expiration handling and improved login flow This commit introduces a mechanism to detect and handle expired GitHub sessions and enhances the device authentication UI with countdown timers and better error categorization. - **feat(auth)**: Added `SessionExpiredDialog` to notify users when their session is invalid or revoked. - **feat(auth)**: Integrated `UnauthorizedInterceptor` (401 handler) into the Ktor `HttpClient` to automatically trigger session expiration events. - **feat(auth)**: Added a countdown timer to the device login screen to show remaining time for code verification. - **feat(auth)**: Introduced `SkipLogin` (Continue as Guest) option to the authentication flow. - **feat(auth)**: Improved error handling in `AuthenticationViewModel` with specific recovery hints (e.g., connection issues, denied access). - **refactor(core)**: Updated `AuthenticationState` and `TokenStore` to manage session expiration events and token lifecycle (including `saved_at` timestamp). - **refactor(cache)**: Added `clearAll()` to `CacheManager` to ensure all local data is purged upon logout or session expiration. - **i18n**: Added string resources for session expiration, auth hints, and logout notes. - **chore**: Updated `LogoutDialog` to include a note about revoking access via GitHub settings. --- .../kotlin/zed/rainxch/githubstore/Main.kt | 13 ++++ .../zed/rainxch/githubstore/MainAction.kt | 1 + .../zed/rainxch/githubstore/MainState.kt | 1 + .../zed/rainxch/githubstore/MainViewModel.kt | 9 +++ .../app/components/SessionExpiredDialog.kt | 77 +++++++++++++++++++ .../rainxch/core/data/cache/CacheManager.kt | 5 ++ .../core/data/data_source/TokenStore.kt | 1 + .../data_source/impl/DefaultTokenStore.kt | 14 +++- .../zed/rainxch/core/data/di/SharedModule.kt | 5 +- .../data/dto/GithubDeviceTokenSuccessDto.kt | 3 +- .../core/data/network/GitHubClientProvider.kt | 6 ++ .../core/data/network/HttpClientFactory.kt | 12 +++ .../interceptor/UnauthorizedInterceptor.kt | 48 ++++++++++++ .../repository/AuthenticationStateImpl.kt | 12 +++ .../domain/repository/AuthenticationState.kt | 3 + .../composeResources/values/strings.xml | 17 ++++ .../auth/presentation/AuthenticationAction.kt | 1 + .../auth/presentation/AuthenticationRoot.kt | 56 ++++++++++++++ .../presentation/AuthenticationViewModel.kt | 72 ++++++++++++++++- .../auth/presentation/model/AuthLoginState.kt | 6 +- .../data/repository/ProfileRepositoryImpl.kt | 2 +- .../presentation/components/LogoutDialog.kt | 6 ++ 22 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt create mode 100644 core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 686cef05..65a950e7 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -16,6 +16,7 @@ import zed.rainxch.githubstore.app.deeplink.DeepLinkParser import zed.rainxch.githubstore.app.navigation.AppNavigation import zed.rainxch.githubstore.app.navigation.GithubStoreGraph import zed.rainxch.githubstore.app.components.RateLimitDialog +import zed.rainxch.githubstore.app.components.SessionExpiredDialog @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -70,6 +71,18 @@ fun App(deepLinkUri: String? = null) { } } + if (state.showSessionExpiredDialog) { + SessionExpiredDialog( + onDismiss = { + viewModel.onAction(MainAction.DismissSessionExpiredDialog) + }, + onSignIn = { + viewModel.onAction(MainAction.DismissSessionExpiredDialog) + navBackStack.navigate(GithubStoreGraph.AuthenticationScreen) + } + ) + } + AppNavigation( navController = navBackStack ) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt index 02b68d58..4700f16d 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt @@ -2,4 +2,5 @@ package zed.rainxch.githubstore sealed interface MainAction { data object DismissRateLimitDialog : MainAction + data object DismissSessionExpiredDialog : MainAction } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt index ebede0fb..aa5dd430 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt @@ -8,6 +8,7 @@ data class MainState( val isLoggedIn: Boolean = false, val rateLimitInfo: RateLimitInfo? = null, val showRateLimitDialog: Boolean = false, + val showSessionExpiredDialog: Boolean = false, val currentColorTheme: AppTheme = AppTheme.OCEAN, val isAmoledTheme: Boolean = false, val isDarkTheme: Boolean? = null, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt index 36c2b854..8fe7f665 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt @@ -89,6 +89,12 @@ class MainViewModel( } } + viewModelScope.launch { + authenticationState.sessionExpiredEvent.collect { + _state.update { it.copy(showSessionExpiredDialog = true) } + } + } + viewModelScope.launch(Dispatchers.IO) { syncUseCase().onSuccess { installedAppsRepository.checkAllForUpdates() @@ -101,6 +107,9 @@ class MainViewModel( MainAction.DismissRateLimitDialog -> { _state.update { it.copy(showRateLimitDialog = false) } } + MainAction.DismissSessionExpiredDialog -> { + _state.update { it.copy(showSessionExpiredDialog = false) } + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt new file mode 100644 index 00000000..30be5a9b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/SessionExpiredDialog.kt @@ -0,0 +1,77 @@ +package zed.rainxch.githubstore.app.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.* + +@Composable +fun SessionExpiredDialog( + onDismiss: () -> Unit, + onSignIn: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.LockOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text( + text = stringResource(Res.string.session_expired_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(Res.string.session_expired_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + + Text( + text = stringResource(Res.string.session_expired_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + }, + confirmButton = { + Button(onClick = onSignIn) { + Text( + text = stringResource(Res.string.sign_in_again), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(Res.string.continue_as_guest), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + ) +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt index ce2a8f47..6fc974ac 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -85,6 +85,11 @@ class CacheManager( cacheDao.deleteByPrefix(prefix) } + suspend fun clearAll() { + memoryCache.clear() + cacheDao.deleteAll() + } + suspend fun cleanupExpired() { val currentTime = now() val expiredKeys = memoryCache.entries diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt index 569bc7f7..197c2c39 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt @@ -9,4 +9,5 @@ interface TokenStore { fun blockingCurrentToken() : GithubDeviceTokenSuccessDto? suspend fun save(token: GithubDeviceTokenSuccessDto) suspend fun clear() + suspend fun isTokenExpired(): Boolean } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt index 0cd3d2bc..34c75c76 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt @@ -19,7 +19,10 @@ class DefaultTokenStore( private val json = Json { ignoreUnknownKeys = true } override suspend fun save(token: GithubDeviceTokenSuccessDto) { - val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), token) + val stamped = token.copy( + savedAtEpochMillis = token.savedAtEpochMillis ?: System.currentTimeMillis() + ) + val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), stamped) dataStore.edit { preferences -> preferences[TOKEN_KEY] = jsonString } @@ -50,8 +53,15 @@ class DefaultTokenStore( }.getOrNull() } - override suspend fun clear() { dataStore.edit { it.remove(TOKEN_KEY) } } + + override suspend fun isTokenExpired(): Boolean { + val token = currentToken() ?: return true + val savedAt = token.savedAtEpochMillis ?: return false + val expiresIn = token.expiresIn ?: return false + val expiresAtMillis = savedAt + (expiresIn * 1000L) + return System.currentTimeMillis() > expiresAtMillis + } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index a97408e7..b50b9a31 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -143,6 +143,7 @@ val networkModule = module { GitHubClientProvider( tokenStore = get(), rateLimitRepository = get(), + authenticationState = get(), proxyConfigFlow = ProxyManager.currentProxyConfig ) } @@ -150,7 +151,9 @@ val networkModule = module { single { createGitHubHttpClient( tokenStore = get(), - rateLimitRepository = get() + rateLimitRepository = get(), + authenticationState = get(), + scope = get() ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt index 5505450c..77d3dcad 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt @@ -10,5 +10,6 @@ data class GithubDeviceTokenSuccessDto( @SerialName("expires_in") val expiresIn: Long? = null, @SerialName("scope") val scope: String? = null, @SerialName("refresh_token") val refreshToken: String? = null, - @SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null + @SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null, + @SerialName("saved_at") val savedAtEpochMillis: Long? = null ) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt index ef9ce955..1cd99705 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt @@ -14,11 +14,13 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.RateLimitRepository class GitHubClientProvider( private val tokenStore: TokenStore, private val rateLimitRepository: RateLimitRepository, + private val authenticationState: AuthenticationState, proxyConfigFlow: StateFlow ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -28,6 +30,8 @@ class GitHubClientProvider( private var currentClient: HttpClient = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, + authenticationState = authenticationState, + scope = scope, proxyConfig = proxyConfigFlow.value ) @@ -41,6 +45,8 @@ class GitHubClientProvider( currentClient = createGitHubHttpClient( tokenStore = tokenStore, rateLimitRepository = rateLimitRepository, + authenticationState = authenticationState, + scope = scope, proxyConfig = proxyConfig ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt index da2c54a5..e7da52d7 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt @@ -11,10 +11,13 @@ import io.ktor.serialization.kotlinx.json.* import io.ktor.util.network.UnresolvedAddressException import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json +import kotlinx.coroutines.CoroutineScope import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor +import zed.rainxch.core.data.network.interceptor.UnauthorizedInterceptor import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.RateLimitException +import zed.rainxch.core.domain.repository.AuthenticationState import zed.rainxch.core.domain.repository.RateLimitRepository import java.io.IOException import kotlin.coroutines.cancellation.CancellationException @@ -24,6 +27,8 @@ expect fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient fun createGitHubHttpClient( tokenStore: TokenStore, rateLimitRepository: RateLimitRepository, + authenticationState: AuthenticationState? = null, + scope: CoroutineScope? = null, proxyConfig: ProxyConfig = ProxyConfig.None ): HttpClient { val json = Json { @@ -36,6 +41,13 @@ fun createGitHubHttpClient( this.rateLimitRepository = rateLimitRepository } + if (authenticationState != null && scope != null) { + install(UnauthorizedInterceptor) { + this.authenticationState = authenticationState + this.scope = scope + } + } + install(ContentNegotiation) { json(json) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt new file mode 100644 index 00000000..b602ae15 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/UnauthorizedInterceptor.kt @@ -0,0 +1,48 @@ +package zed.rainxch.core.data.network.interceptor + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.client.statement.HttpReceivePipeline +import io.ktor.util.AttributeKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import zed.rainxch.core.domain.repository.AuthenticationState + +class UnauthorizedInterceptor( + private val authenticationState: AuthenticationState, + private val scope: CoroutineScope +) { + + class Config { + var authenticationState: AuthenticationState? = null + var scope: CoroutineScope? = null + } + + companion object Plugin : HttpClientPlugin { + override val key: AttributeKey = + AttributeKey("UnauthorizedInterceptor") + + override fun prepare(block: Config.() -> Unit): UnauthorizedInterceptor { + val config = Config().apply(block) + return UnauthorizedInterceptor( + authenticationState = requireNotNull(config.authenticationState) { + "AuthenticationState must be provided" + }, + scope = requireNotNull(config.scope) { + "CoroutineScope must be provided" + } + ) + } + + override fun install(plugin: UnauthorizedInterceptor, scope: HttpClient) { + scope.receivePipeline.intercept(HttpReceivePipeline.After) { + if (subject.status.value == 401) { + plugin.scope.launch { + plugin.authenticationState.notifySessionExpired() + } + } + proceedWith(subject) + } + } + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt index 50ff8dbe..99e31766 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt @@ -1,6 +1,9 @@ package zed.rainxch.core.data.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.repository.AuthenticationState @@ -8,6 +11,10 @@ import zed.rainxch.core.domain.repository.AuthenticationState class AuthenticationStateImpl ( private val tokenStore: TokenStore, ) : AuthenticationState { + + private val _sessionExpiredEvent = MutableSharedFlow(extraBufferCapacity = 1) + override val sessionExpiredEvent: SharedFlow = _sessionExpiredEvent.asSharedFlow() + override fun isUserLoggedIn(): Flow { return tokenStore .tokenFlow() @@ -19,4 +26,9 @@ class AuthenticationStateImpl ( override suspend fun isCurrentlyUserLoggedIn(): Boolean { return tokenStore.currentToken() != null } + + override suspend fun notifySessionExpired() { + tokenStore.clear() + _sessionExpiredEvent.emit(Unit) + } } \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt index 248cc391..ca3c7fe5 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/AuthenticationState.kt @@ -1,8 +1,11 @@ package zed.rainxch.core.domain.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow interface AuthenticationState { fun isUserLoggedIn() : Flow suspend fun isCurrentlyUserLoggedIn() : Boolean + val sessionExpiredEvent: SharedFlow + suspend fun notifySessionExpired() } \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index db5106c0..a9ddb448 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -400,4 +400,21 @@ Login Your Starred Repositories from GitHub Your Favourite Repositories saved locally + + + Session Expired + Your GitHub session has expired or the token was revoked. Please sign in again to continue using authenticated features. + You can still browse as a guest with limited API requests. + Sign In Again + Continue as Guest + + + This will clear your local session and cached data. To fully revoke access, visit GitHub Settings > Applications. + + + Code expires in %1$s + The device code has expired. + Please try signing in again to get a new code. + Please check your internet connection and try again. + You denied the authorization request. Try again if this was unintentional. \ No newline at end of file diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt index 3e2e80a8..9aff95df 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationAction.kt @@ -9,4 +9,5 @@ sealed interface AuthenticationAction { data object MarkLoggedOut : AuthenticationAction data object MarkLoggedIn : AuthenticationAction data class OnInfo(val message: String) : AuthenticationAction + data object SkipLogin : AuthenticationAction } \ No newline at end of file diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt index 66f03a87..f4ddd58d 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt @@ -25,8 +25,10 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -145,6 +147,16 @@ fun AuthenticationScreen( color = MaterialTheme.colorScheme.error ) + authState.recoveryHint?.let { hint -> + Spacer(Modifier.height(8.dp)) + Text( + text = hint, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(12.dp)) GithubStoreButton( @@ -154,6 +166,19 @@ fun AuthenticationScreen( }, modifier = Modifier.fillMaxWidth(.7f) ) + + Spacer(Modifier.height(8.dp)) + + TextButton( + onClick = { onAction(AuthenticationAction.SkipLogin) } + ) { + Text( + text = stringResource(Res.string.continue_as_guest), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + Spacer(Modifier.weight(2f)) } } @@ -224,6 +249,25 @@ fun StateDevicePrompt( Spacer(Modifier.height(16.dp)) + if (authState.remainingSeconds > 0) { + val minutes = authState.remainingSeconds / 60 + val seconds = authState.remainingSeconds % 60 + val formatted = remember(minutes, seconds) { + "%d:%02d".format(minutes, seconds) + } + Text( + text = stringResource(Res.string.auth_code_expires_in, formatted), + style = MaterialTheme.typography.bodyMedium, + color = if (authState.remainingSeconds < 60) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.outline + } + ) + + Spacer(Modifier.height(16.dp)) + } + GithubStoreButton( text = stringResource(Res.string.open_github), onClick = { @@ -309,6 +353,18 @@ fun StateLoggedOut( }, modifier = Modifier.fillMaxWidth() ) + + Spacer(Modifier.height(8.dp)) + + TextButton( + onClick = { onAction(AuthenticationAction.SkipLogin) } + ) { + Text( + text = stringResource(Res.string.continue_as_guest), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } } } 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 96218cd8..0fff7771 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 @@ -6,7 +6,9 @@ import zed.rainxch.githubstore.core.presentation.res.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart @@ -32,6 +34,7 @@ class AuthenticationViewModel( ) : ViewModel() { private var hasLoadedInitialData = false + private var countdownJob: Job? = null private val _state: MutableStateFlow = MutableStateFlow(AuthenticationState()) @@ -80,6 +83,38 @@ class AuthenticationViewModel( ) } } + AuthenticationAction.SkipLogin -> { + _events.trySend(AuthenticationEvents.OnNavigateToMain) + } + } + } + + private fun startCountdown(start: GithubDeviceStart) { + countdownJob?.cancel() + countdownJob = viewModelScope.launch { + var remaining = start.expiresInSec + while (remaining > 0) { + _state.update { currentState -> + val loginState = currentState.loginState + if (loginState is AuthLoginState.DevicePrompt) { + currentState.copy( + loginState = loginState.copy(remainingSeconds = remaining) + ) + } else { + return@launch + } + } + delay(1000L) + remaining-- + } + _state.update { + it.copy( + loginState = AuthLoginState.Error( + message = getString(Res.string.auth_error_code_expired), + recoveryHint = getString(Res.string.auth_hint_try_again) + ) + ) + } } } @@ -93,11 +128,16 @@ class AuthenticationViewModel( withContext(Dispatchers.Main.immediate) { _state.update { it.copy( - loginState = AuthLoginState.DevicePrompt(start), + loginState = AuthLoginState.DevicePrompt( + start = start, + remainingSeconds = start.expiresInSec + ), copied = false ) } + startCountdown(start) + try { clipboardHelper.copy( label = getString(Res.string.enter_code_on_github), @@ -105,7 +145,7 @@ class AuthenticationViewModel( ) _state.update { it.copy(copied = true) } } catch (e: Exception) { - logger.debug("⚠️ Failed to copy to clipboard: ${e.message}") + logger.debug("Failed to copy to clipboard: ${e.message}") } } @@ -113,6 +153,8 @@ class AuthenticationViewModel( authenticationRepository.awaitDeviceToken(start = start) } + countdownJob?.cancel() + withContext(Dispatchers.Main.immediate) { _state.update { it.copy(loginState = AuthLoginState.LoggedIn) } _events.trySend(AuthenticationEvents.OnNavigateToMain) @@ -121,11 +163,14 @@ class AuthenticationViewModel( } catch (e: CancellationException) { throw e } catch (t: Throwable) { + countdownJob?.cancel() + val (message, hint) = categorizeError(t) withContext(Dispatchers.Main.immediate) { _state.update { it.copy( loginState = AuthLoginState.Error( - t.message ?: getString(Res.string.error_unknown) + message = message, + recoveryHint = hint ) ) } @@ -134,6 +179,27 @@ class AuthenticationViewModel( } } + private suspend fun categorizeError(t: Throwable): Pair { + val msg = t.message ?: return getString(Res.string.error_unknown) to null + val lowerMsg = msg.lowercase() + return when { + "timeout" in lowerMsg || "timed out" in lowerMsg -> + msg to getString(Res.string.auth_hint_check_connection) + "network" in lowerMsg || "unresolvedaddress" in lowerMsg || "connect" in lowerMsg -> + msg to getString(Res.string.auth_hint_check_connection) + "expired" in lowerMsg || "expire" in lowerMsg -> + msg to getString(Res.string.auth_hint_try_again) + "denied" in lowerMsg || "access_denied" in lowerMsg -> + msg to getString(Res.string.auth_hint_denied) + else -> msg to null + } + } + + override fun onCleared() { + super.onCleared() + countdownJob?.cancel() + } + private fun openGitHub(start: GithubDeviceStart) { viewModelScope.launch(Dispatchers.Main.immediate) { try { diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt index 86f6596e..37bf6ec3 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/model/AuthLoginState.kt @@ -6,9 +6,13 @@ sealed interface AuthLoginState { data object LoggedOut : AuthLoginState data class DevicePrompt( val start: GithubDeviceStart, + val remainingSeconds: Int = 0, ) : AuthLoginState data object Pending : AuthLoginState data object LoggedIn : AuthLoginState - data class Error(val message: String) : AuthLoginState + data class Error( + val message: String, + val recoveryHint: String? = null, + ) : AuthLoginState } \ No newline at end of file diff --git a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt index 9490243f..653bed76 100644 --- a/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt +++ b/feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/repository/ProfileRepositoryImpl.kt @@ -82,6 +82,6 @@ class ProfileRepositoryImpl( override suspend fun logout() { tokenStore.clear() - cacheManager.invalidate(CACHE_KEY) + cacheManager.clearAll() } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt index 57446e0d..b4475962 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt @@ -59,6 +59,12 @@ fun LogoutDialog( color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + text = stringResource(Res.string.logout_revocation_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, From 6b32a2cdb7014d55ee2f2c4cabc0911df3416543 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 28 Feb 2026 09:44:55 +0500 Subject: [PATCH 2/4] feat(auth): improve session management and update scheduling This commit enhances the authentication lifecycle and background update synchronization. It introduces thread-safe session expiration handling, improves the reliability of background update checks on Android, and refines the authentication UI and token management. - **feat(android)**: Updated `UpdateScheduler` to include an immediate one-time update check in addition to periodic work, ensuring users see updates sooner after initialization. - **fix(core)**: Implemented a `Mutex` in `AuthenticationStateImpl` to prevent race conditions during session expiration and token clearing. - **refactor(core)**: Replaced `System.currentTimeMillis()` with `Clock.System` from kotlinx-datetime in `DefaultTokenStore` for better multiplatform compatibility. - **fix(auth)**: Fixed a bug in `AuthenticationViewModel` where `remainingSeconds` was reset to 0 during clipboard copy actions in the device prompt state. - **ui(auth)**: Improved the expiration timer format in `AuthenticationRoot` to use padded zeros (e.g., "05:01" instead of "5:01"). - **chore**: Updated `UpdateScheduler` to always use `ExistingPeriodicWorkPolicy.UPDATE` to ensure configuration changes are applied. --- .../core/data/services/UpdateScheduler.kt | 32 +++++++++++++------ .../data_source/impl/DefaultTokenStore.kt | 5 +-- .../repository/AuthenticationStateImpl.kt | 13 ++++++-- .../auth/presentation/AuthenticationRoot.kt | 2 +- .../presentation/AuthenticationViewModel.kt | 8 +++-- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt index 0184dfcb..6a0854d1 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt @@ -4,7 +4,9 @@ import android.content.Context import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import co.touchlab.kermit.Logger @@ -13,11 +15,11 @@ import java.util.concurrent.TimeUnit object UpdateScheduler { private const val DEFAULT_INTERVAL_HOURS = 6L + private const val IMMEDIATE_CHECK_WORK_NAME = "github_store_immediate_update_check" fun schedule( context: Context, intervalHours: Long = DEFAULT_INTERVAL_HOURS, - replace: Boolean = false ) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -34,25 +36,37 @@ object UpdateScheduler { ) .build() - val policy = if (replace) { - ExistingPeriodicWorkPolicy.UPDATE - } else { - ExistingPeriodicWorkPolicy.KEEP - } - + // UPDATE replaces any existing schedule so new intervals and code changes take effect WorkManager.getInstance(context) .enqueueUniquePeriodicWork( uniqueWorkName = UpdateCheckWorker.WORK_NAME, - existingPeriodicWorkPolicy = policy, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, request = request ) - Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h (policy=$policy)" } + // Run an immediate one-time check so users get notified sooner + // rather than waiting up to intervalHours for the first periodic run. + // Uses KEEP policy so it doesn't re-enqueue if one is already pending. + val immediateRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInitialDelay(1, TimeUnit.MINUTES) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork( + IMMEDIATE_CHECK_WORK_NAME, + ExistingWorkPolicy.KEEP, + immediateRequest + ) + + Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h + immediate check" } } fun cancel(context: Context) { WorkManager.getInstance(context) .cancelUniqueWork(UpdateCheckWorker.WORK_NAME) + WorkManager.getInstance(context) + .cancelUniqueWork(IMMEDIATE_CHECK_WORK_NAME) Logger.i { "UpdateScheduler: Cancelled periodic update checks" } } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt index 34c75c76..de191f5c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.data.dto.GithubDeviceTokenSuccessDto +import kotlin.time.Clock class DefaultTokenStore( private val dataStore: DataStore, @@ -20,7 +21,7 @@ class DefaultTokenStore( override suspend fun save(token: GithubDeviceTokenSuccessDto) { val stamped = token.copy( - savedAtEpochMillis = token.savedAtEpochMillis ?: System.currentTimeMillis() + savedAtEpochMillis = token.savedAtEpochMillis ?: Clock.System.now().toEpochMilliseconds() ) val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), stamped) dataStore.edit { preferences -> @@ -62,6 +63,6 @@ class DefaultTokenStore( val savedAt = token.savedAtEpochMillis ?: return false val expiresIn = token.expiresIn ?: return false val expiresAtMillis = savedAt + (expiresIn * 1000L) - return System.currentTimeMillis() > expiresAtMillis + return Clock.System.now().toEpochMilliseconds() > expiresAtMillis } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt index 99e31766..ba1bc5d6 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/AuthenticationStateImpl.kt @@ -5,16 +5,20 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import zed.rainxch.core.data.data_source.TokenStore import zed.rainxch.core.domain.repository.AuthenticationState -class AuthenticationStateImpl ( +class AuthenticationStateImpl( private val tokenStore: TokenStore, ) : AuthenticationState { private val _sessionExpiredEvent = MutableSharedFlow(extraBufferCapacity = 1) override val sessionExpiredEvent: SharedFlow = _sessionExpiredEvent.asSharedFlow() + private val sessionExpiredMutex = Mutex() + override fun isUserLoggedIn(): Flow { return tokenStore .tokenFlow() @@ -28,7 +32,10 @@ class AuthenticationStateImpl ( } override suspend fun notifySessionExpired() { - tokenStore.clear() - _sessionExpiredEvent.emit(Unit) + sessionExpiredMutex.withLock { + if (tokenStore.currentToken() == null) return@withLock + tokenStore.clear() + _sessionExpiredEvent.emit(Unit) + } } } \ No newline at end of file diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt index f4ddd58d..97b75ff9 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationRoot.kt @@ -253,7 +253,7 @@ fun StateDevicePrompt( val minutes = authState.remainingSeconds / 60 val seconds = authState.remainingSeconds % 60 val formatted = remember(minutes, seconds) { - "%d:%02d".format(minutes, seconds) + "%02d:%02d".format(minutes, seconds) } Text( text = stringResource(Res.string.auth_code_expires_in, formatted), 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 0fff7771..79aba7ec 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 @@ -220,16 +220,20 @@ class AuthenticationViewModel( ) _state.update { + val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 + it.copy( - loginState = AuthLoginState.DevicePrompt(start), + loginState = AuthLoginState.DevicePrompt(start, currentRemaining), copied = true ) } } catch (e: Exception) { logger.debug("⚠️ Failed to copy to clipboard: ${e.message}") _state.update { + val currentRemaining = (it.loginState as? AuthLoginState.DevicePrompt)?.remainingSeconds ?: 0 + it.copy( - loginState = AuthLoginState.DevicePrompt(start), + loginState = AuthLoginState.DevicePrompt(start, currentRemaining), copied = false ) } From f0f971bf90bb53a9964e2f70e1dea277480c4197 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 28 Feb 2026 10:04:35 +0500 Subject: [PATCH 3/4] feat(ui): add visual indicator for recently updated repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a "hot" indicator (🔥) for repositories updated within the last week and cleans up unused resources and time formatting logic. - **feat(ui)**: Updated `RepositoryCard` to prepend a fire emoji to the release date if the repository was updated within the last 7 days. - **refactor(core)**: Introduced `hasWeekNotPassed` utility in `TimeFormatters.kt` to check repository update freshness. - **refactor(core)**: Removed unused `formatUpdatedAt` functions and simplified `TimeFormatters.kt`. - **chore(android)**: Removed default Android launcher background and foreground vector drawables. - **chore**: Cleaned up unused imports and Material3 Experimental APIs in `RepositoryCard.kt`. --- .../drawable-v24/ic_launcher_foreground.xml | 30 ---- .../res/drawable/ic_launcher_background.xml | 170 ------------------ .../presentation/components/RepositoryCard.kt | 30 ++-- .../core/presentation/utils/TimeFormatters.kt | 54 +----- 4 files changed, 25 insertions(+), 259 deletions(-) delete mode 100644 composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml delete mode 100644 composeApp/src/androidMain/res/drawable/ic_launcher_background.xml diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml deleted file mode 100644 index e93e11ad..00000000 --- a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt index b2745c4a..f9c9d637 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/components/RepositoryCard.kt @@ -4,11 +4,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -23,10 +22,8 @@ import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.outlined.CallSplit import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -39,13 +36,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.skydoves.landscapist.coil3.CoilImage -import com.skydoves.landscapist.components.rememberImageComponent -import com.skydoves.landscapist.crossfade.CrossfadePlugin -import zed.rainxch.githubstore.core.presentation.res.* import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import zed.rainxch.core.domain.model.GithubRepoSummary @@ -53,7 +47,13 @@ import zed.rainxch.core.domain.model.GithubUser import zed.rainxch.core.presentation.model.DiscoveryRepository import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.formatReleasedAt -import zed.rainxch.core.presentation.utils.formatUpdatedAt +import zed.rainxch.core.presentation.utils.hasWeekNotPassed +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.forked_repository +import zed.rainxch.githubstore.core.presentation.res.home_view_details +import zed.rainxch.githubstore.core.presentation.res.installed +import zed.rainxch.githubstore.core.presentation.res.open_in_browser +import zed.rainxch.githubstore.core.presentation.res.update_available @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) @Composable @@ -237,8 +237,16 @@ fun RepositoryCard( Spacer(Modifier.height(12.dp)) + val releasedAtText = buildAnnotatedString { + if (hasWeekNotPassed(discoveryRepository.repository.updatedAt)) { + append("🔥 ") + } + + append(formatReleasedAt(discoveryRepository.repository.updatedAt)) + } + Text( - text = formatReleasedAt(discoveryRepository.repository.updatedAt), + text = releasedAtText, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.outline, maxLines = 1, diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt index cdc90950..438f8232 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt @@ -8,30 +8,18 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime import kotlin.time.Instant -@OptIn(ExperimentalTime::class) -@Composable -fun formatUpdatedAt(isoInstant: String): String { +fun hasWeekNotPassed(isoInstant: String): Boolean { val updated = Instant.parse(isoInstant) - val now = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds()) - val diff: Duration = now - updated - - val hoursDiff = diff.inWholeHours - val daysDiff = diff.inWholeDays + val now = Clock.System.now() + val diff = now - updated - return when { - hoursDiff < 1 -> stringResource(Res.string.updated_just_now) - hoursDiff < 24 -> stringResource(Res.string.updated_hours_ago, hoursDiff) - daysDiff == 1L -> stringResource(Res.string.updated_yesterday) - daysDiff < 7 -> stringResource(Res.string.updated_days_ago, daysDiff) - else -> { - val date = updated.toLocalDateTime(TimeZone.currentSystemDefault()).date - stringResource(Res.string.updated_on_date, date.toString()) - } - } + return diff < 7.days } + @OptIn(ExperimentalTime::class) @Composable fun formatReleasedAt(isoInstant: String): String { @@ -54,36 +42,6 @@ fun formatReleasedAt(isoInstant: String): String { } } -@OptIn(ExperimentalTime::class) -suspend fun formatUpdatedAt(epochMillis: Long): String { - val updated = Instant.fromEpochMilliseconds(epochMillis) - val now = Clock.System.now() - val diff: Duration = now - updated - - val hoursDiff = diff.inWholeHours - val daysDiff = diff.inWholeDays - - return when { - hoursDiff < 1 -> - getString(Res.string.updated_just_now) - - hoursDiff < 24 -> - getString(Res.string.updated_hours_ago, hoursDiff) - - daysDiff == 1L -> - getString(Res.string.updated_yesterday) - - daysDiff < 7 -> - getString(Res.string.updated_days_ago, daysDiff) - - else -> { - val date = updated - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - getString(Res.string.updated_on_date, date.toString()) - } - } -} @OptIn(ExperimentalTime::class) suspend fun formatAddedAt(epochMillis: Long): String { val updated = Instant.fromEpochMilliseconds(epochMillis) From 1f1e2a1113158555029f1154b227bc5d9fec5930 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Sat, 28 Feb 2026 10:10:21 +0500 Subject: [PATCH 4/4] i18n: Add session expiry and authentication error strings in multiple languages This commit adds localized string resources for session expiration dialogs and authentication errors across several languages (Turkish, Spanish, Chinese, French, Hindi, Italian, Japanese, Korean, Polish, Bengali, and Russian). It also includes a small fix to prevent crashes during date parsing. - **i18n**: Added strings for session expiration titles, messages, and hints (`session_expired_title`, `session_expired_message`, etc.). - **i18n**: Added strings for authentication lifecycle and errors, including code expiration and connection hints (`auth_code_expires_in`, `auth_error_code_expired`, `auth_hint_denied`). - **fix(core)**: Wrapped `Instant.parse` in a try-catch block within `TimeFormatters.kt` to gracefully handle invalid ISO strings by returning `false` instead of throwing an `IllegalArgumentException`. --- .../composeResources/values-bn/strings-bn.xml | 13 +++++++++++++ .../composeResources/values-es/strings-es.xml | 13 +++++++++++++ .../composeResources/values-fr/strings-fr.xml | 13 +++++++++++++ .../composeResources/values-hi/strings-hi.xml | 13 +++++++++++++ .../composeResources/values-it/strings-it.xml | 13 +++++++++++++ .../composeResources/values-ja/strings-ja.xml | 13 +++++++++++++ .../composeResources/values-kr/strings-kr.xml | 13 +++++++++++++ .../composeResources/values-pl/strings-pl.xml | 13 +++++++++++++ .../composeResources/values-ru/strings-ru.xml | 13 +++++++++++++ .../composeResources/values-tr/strings-tr.xml | 13 +++++++++++++ .../values-zh-rCN/strings-zh-rCN.xml | 13 +++++++++++++ .../core/presentation/utils/TimeFormatters.kt | 7 ++++++- 12 files changed, 149 insertions(+), 1 deletion(-) diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 71a5791b..671e6bc6 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -407,4 +407,17 @@ GitHub-এ আপনার স্টার করা রিপোজিটরি স্থানীয়ভাবে সংরক্ষিত আপনার প্রিয় রিপোজিটরি + + সেশনের মেয়াদ শেষ + আপনার GitHub সেশনের মেয়াদ শেষ হয়ে গেছে বা টোকেনটি প্রত্যাহার করা হয়েছে। প্রমাণিত বৈশিষ্ট্যগুলি ব্যবহার চালিয়ে যেতে অনুগ্রহ করে আবার সাইন ইন করুন। + আপনি সীমিত API অনুরোধ সহ অতিথি হিসেবে ব্রাউজ করতে পারেন। + আবার সাইন ইন করুন + অতিথি হিসেবে চালিয়ে যান + এটি আপনার স্থানীয় সেশন এবং ক্যাশ ডেটা মুছে ফেলবে। সম্পূর্ণরূপে অ্যাক্সেস প্রত্যাহার করতে, GitHub Settings > Applications এ যান। + কোডের মেয়াদ শেষ হবে %1$s এ + ডিভাইস কোডের মেয়াদ শেষ হয়ে গেছে। + একটি নতুন কোড পেতে অনুগ্রহ করে আবার সাইন ইন করার চেষ্টা করুন। + অনুগ্রহ করে আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন। + আপনি অনুমোদনের অনুরোধ প্রত্যাখ্যান করেছেন। এটি অনিচ্ছাকৃত হলে আবার চেষ্টা করুন। + diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index f1b6d9d7..29367d9b 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -372,4 +372,17 @@ Tus repositorios destacados de GitHub Tus repositorios favoritos guardados localmente + + Sesión expirada + Tu sesión de GitHub ha expirado o el token fue revocado. Inicia sesión nuevamente para continuar usando las funciones autenticadas. + Puedes seguir navegando como invitado con solicitudes de API limitadas. + Iniciar sesión de nuevo + Continuar como invitado + Esto borrará tu sesión local y los datos en caché. Para revocar el acceso completamente, visita GitHub Settings > Applications. + El código expira en %1$s + El código del dispositivo ha expirado. + Intenta iniciar sesión de nuevo para obtener un nuevo código. + Revisa tu conexión a internet e intenta de nuevo. + Rechazaste la solicitud de autorización. Intenta de nuevo si fue involuntario. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index 4280fd37..61422b74 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -372,4 +372,17 @@ Vos dépôts étoilés sur GitHub Vos dépôts favoris enregistrés localement + + Session expirée + Votre session GitHub a expiré ou le jeton a été révoqué. Veuillez vous reconnecter pour continuer à utiliser les fonctionnalités authentifiées. + Vous pouvez toujours naviguer en tant qu\'invité avec des requêtes API limitées. + Se reconnecter + Continuer en tant qu\'invité + Cela effacera votre session locale et les données en cache. Pour révoquer complètement l\'accès, visitez GitHub Settings > Applications. + Le code expire dans %1$s + Le code de l\'appareil a expiré. + Veuillez réessayer de vous connecter pour obtenir un nouveau code. + Vérifiez votre connexion internet et réessayez. + Vous avez refusé la demande d\'autorisation. Réessayez si c\'était involontaire. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 3d5c9fa8..e42699d6 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -407,4 +407,17 @@ GitHub पर आपकी स्टार की गई रिपॉजिटरी स्थानीय रूप से सहेजी गई आपकी पसंदीदा रिपॉजिटरी + + सत्र समाप्त + आपका GitHub सत्र समाप्त हो गया है या टोकन रद्द कर दिया गया है। प्रमाणित सुविधाओं का उपयोग जारी रखने के लिए कृपया फिर से साइन इन करें। + आप सीमित API अनुरोधों के साथ अतिथि के रूप में ब्राउज़ कर सकते हैं। + फिर से साइन इन करें + अतिथि के रूप में जारी रखें + यह आपका स्थानीय सत्र और कैश डेटा साफ़ कर देगा। पूर्ण रूप से पहुँच रद्द करने के लिए, GitHub Settings > Applications पर जाएँ। + कोड %1$s में समाप्त होगा + डिवाइस कोड की अवधि समाप्त हो गई है। + नया कोड प्राप्त करने के लिए कृपया फिर से साइन इन करें। + कृपया अपना इंटरनेट कनेक्शन जाँचें और पुनः प्रयास करें। + आपने प्राधिकरण अनुरोध अस्वीकार कर दिया। यदि यह अनजाने में हुआ तो पुनः प्रयास करें। + diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index 95787604..8c0c1c23 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -408,4 +408,17 @@ I tuoi repository preferiti su GitHub I tuoi repository preferiti salvati localmente + + Sessione scaduta + La tua sessione GitHub è scaduta o il token è stato revocato. Accedi di nuovo per continuare a utilizzare le funzionalità autenticate. + Puoi continuare a navigare come ospite con richieste API limitate. + Accedi di nuovo + Continua come ospite + Questo cancellerà la sessione locale e i dati nella cache. Per revocare completamente l\'accesso, visita GitHub Settings > Applications. + Il codice scade tra %1$s + Il codice del dispositivo è scaduto. + Riprova ad accedere per ottenere un nuovo codice. + Controlla la tua connessione internet e riprova. + Hai rifiutato la richiesta di autorizzazione. Riprova se è stato involontario. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 4faefcb4..2f77ab29 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -371,4 +371,17 @@ ログイン GitHubのスター付きリポジトリ ローカルに保存されたお気に入りリポジトリ + + + セッション期限切れ + GitHubセッションの有効期限が切れたか、トークンが取り消されました。認証機能を引き続き使用するには、再度サインインしてください。 + 制限されたAPIリクエストでゲストとして閲覧を続けることができます。 + 再度サインイン + ゲストとして続行 + ローカルセッションとキャッシュデータが消去されます。アクセスを完全に取り消すには、GitHub Settings > Applicationsにアクセスしてください。 + コードの有効期限: %1$s + デバイスコードの有効期限が切れました。 + 新しいコードを取得するために再度サインインしてください。 + インターネット接続を確認して再試行してください。 + 認証リクエストを拒否しました。意図しない場合は再試行してください。 \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml index c3a73a83..ea518a21 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -404,4 +404,17 @@ 로그인 GitHub에서 별표한 저장소 로컬에 저장된 즐겨찾기 저장소 + + + 세션 만료 + GitHub 세션이 만료되었거나 토큰이 취소되었습니다. 인증된 기능을 계속 사용하려면 다시 로그인하세요. + 제한된 API 요청으로 게스트로 계속 탐색할 수 있습니다. + 다시 로그인 + 게스트로 계속 + 로컬 세션과 캐시 데이터가 삭제됩니다. 접근을 완전히 취소하려면 GitHub Settings > Applications를 방문하세요. + 코드 만료까지 %1$s + 디바이스 코드가 만료되었습니다. + 새 코드를 받으려면 다시 로그인해 주세요. + 인터넷 연결을 확인하고 다시 시도하세요. + 인증 요청을 거부했습니다. 의도하지 않은 경우 다시 시도하세요. \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index d87c1591..894f0dd0 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -370,4 +370,17 @@ Twoje repozytoria oznaczone gwiazdką na GitHubie Twoje ulubione repozytoria zapisane lokalnie + + Sesja wygasła + Twoja sesja GitHub wygasła lub token został unieważniony. Zaloguj się ponownie, aby kontynuować korzystanie z funkcji wymagających uwierzytelnienia. + Możesz nadal przeglądać jako gość z ograniczoną liczbą zapytań API. + Zaloguj się ponownie + Kontynuuj jako gość + Spowoduje to wyczyszczenie lokalnej sesji i danych z pamięci podręcznej. Aby całkowicie cofnąć dostęp, odwiedź GitHub Settings > Applications. + Kod wygasa za %1$s + Kod urządzenia wygasł. + Spróbuj zalogować się ponownie, aby uzyskać nowy kod. + Sprawdź połączenie internetowe i spróbuj ponownie. + Odrzuciłeś żądanie autoryzacji. Spróbuj ponownie, jeśli było to niezamierzone. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index b28c02b0..045bbff8 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -372,4 +372,17 @@ Ваши избранные репозитории на GitHub Ваши избранные репозитории, сохранённые локально + + Сессия истекла + Ваша сессия GitHub истекла или токен был отозван. Пожалуйста, войдите снова для продолжения использования авторизованных функций. + Вы можете продолжить просмотр как гость с ограниченным количеством API-запросов. + Войти снова + Продолжить как гость + Это очистит вашу локальную сессию и кэшированные данные. Чтобы полностью отозвать доступ, перейдите в GitHub Settings > Applications. + Код истекает через %1$s + Срок действия кода устройства истёк. + Пожалуйста, попробуйте войти снова для получения нового кода. + Проверьте подключение к интернету и попробуйте снова. + Вы отклонили запрос авторизации. Попробуйте снова, если это было непреднамеренно. + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index cdc302b9..8f4fa07b 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -404,4 +404,17 @@ GitHub'daki yıldızlı repolarınız Yerel olarak kaydedilen favori repolarınız + + Oturum Süresi Doldu + GitHub oturumunuzun süresi doldu veya token iptal edildi. Kimliği doğrulanmış özellikleri kullanmaya devam etmek için lütfen tekrar giriş yapın. + Sınırlı API istekleriyle misafir olarak gezinmeye devam edebilirsiniz. + Tekrar Giriş Yap + Misafir olarak devam et + Bu işlem yerel oturumunuzu ve önbellek verilerinizi temizleyecektir. Erişimi tamamen iptal etmek için GitHub Settings > Applications sayfasını ziyaret edin. + Kodun süresi %1$s sonra dolacak + Cihaz kodunun süresi doldu. + Yeni bir kod almak için lütfen tekrar giriş yapmayı deneyin. + Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin. + Yetkilendirme isteğini reddettiniz. İstemeden yaptıysanız tekrar deneyin. + diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index fb031268..918ea9cb 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -372,4 +372,17 @@ 登录 您在 GitHub 上收藏的仓库 本地保存的收藏仓库 + + + 会话已过期 + 您的 GitHub 会话已过期或令牌已被撤销。请重新登录以继续使用认证功能。 + 您仍可以访客身份浏览,但 API 请求次数有限。 + 重新登录 + 以访客身份继续 + 这将清除您的本地会话和缓存数据。要完全撤销访问权限,请访问 GitHub Settings > Applications。 + 验证码将在 %1$s 后过期 + 设备验证码已过期。 + 请重新登录以获取新的验证码。 + 请检查您的网络连接并重试。 + 您拒绝了授权请求。如果是误操作,请重试。 \ No newline at end of file diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt index 438f8232..06181a72 100644 --- a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/TimeFormatters.kt @@ -12,8 +12,13 @@ import kotlin.time.Duration.Companion.days import kotlin.time.ExperimentalTime import kotlin.time.Instant +@OptIn(ExperimentalTime::class) fun hasWeekNotPassed(isoInstant: String): Boolean { - val updated = Instant.parse(isoInstant) + val updated = try { + Instant.parse(isoInstant) + } catch (_: IllegalArgumentException) { + return false + } val now = Clock.System.now() val diff = now - updated