Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ package zed.rainxch.githubstore

sealed interface MainAction {
data object DismissRateLimitDialog : MainAction
data object DismissSessionExpiredDialog : MainAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -101,6 +107,9 @@ class MainViewModel(
MainAction.DismissRateLimitDialog -> {
_state.update { it.copy(showRateLimitDialog = false) }
}
MainAction.DismissSessionExpiredDialog -> {
_state.update { it.copy(showSessionExpiredDialog = false) }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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<UpdateCheckWorker>()
.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" }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ interface TokenStore {
fun blockingCurrentToken() : GithubDeviceTokenSuccessDto?
suspend fun save(token: GithubDeviceTokenSuccessDto)
suspend fun clear()
suspend fun isTokenExpired(): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Preferences>,
Expand All @@ -19,7 +20,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 ?: Clock.System.now().toEpochMilliseconds()
)
val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), stamped)
dataStore.edit { preferences ->
preferences[TOKEN_KEY] = jsonString
}
Expand Down Expand Up @@ -50,8 +54,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 Clock.System.now().toEpochMilliseconds() > expiresAtMillis
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,17 @@ val networkModule = module {
GitHubClientProvider(
tokenStore = get(),
rateLimitRepository = get(),
authenticationState = get(),
proxyConfigFlow = ProxyManager.currentProxyConfig
)
}

single<HttpClient> {
createGitHubHttpClient(
tokenStore = get(),
rateLimitRepository = get()
rateLimitRepository = get(),
authenticationState = get(),
scope = get()
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProxyConfig>
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
Expand All @@ -28,6 +30,8 @@ class GitHubClientProvider(
private var currentClient: HttpClient = createGitHubHttpClient(
tokenStore = tokenStore,
rateLimitRepository = rateLimitRepository,
authenticationState = authenticationState,
scope = scope,
proxyConfig = proxyConfigFlow.value
)

Expand All @@ -41,6 +45,8 @@ class GitHubClientProvider(
currentClient = createGitHubHttpClient(
tokenStore = tokenStore,
rateLimitRepository = rateLimitRepository,
authenticationState = authenticationState,
scope = scope,
proxyConfig = proxyConfig
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
Loading