From 0ced94305b0d508a0e1118f1c8411628eefa1df4 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:10:23 +0545 Subject: [PATCH 01/16] refactor(home): expose whether a page came from cache Adds PaginatedRepos.isFromCache so callers can tell network results apart from the stored trending snapshot without inferring it from other state. --- .../gitstore/feature/home/domain/model/PaginatedRepos.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt index 9982823..6908ae7 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt @@ -6,6 +6,7 @@ data class PaginatedRepos( val repos: List, val hasMore: Boolean, val nextPageIndex: Int, - val totalCount: Int? = null + val totalCount: Int? = null, + val isFromCache: Boolean = false ) From d7550b3869916a3a155b615b1af1b83d5ef42fa6 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:10:23 +0545 Subject: [PATCH 02/16] feat(home): load the trending seed list from jsDelivr with disk fallback Trending now reads the daily discovered list from jsDelivr (edge-cached, branch refs revalidated within minutes) instead of raw.githubusercontent.com, and keeps the last successful payload in cacheDir so the tab still renders offline. CachedTrendingDataSource.loadTrending() returns a typed TrendingResult that distinguishes offline from missing data, so HomeRepositoryImpl can report the right reason and avoid falling through to the live API unnecessarily. --- .../gitstore/app/di/SharedModules.kt | 8 +- .../data_source/CachedTrendingDataSource.kt | 210 +++++++++++++----- .../data/repository/HomeRepositoryImpl.kt | 27 ++- 3 files changed, 186 insertions(+), 59 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/app/di/SharedModules.kt b/app/src/main/kotlin/com/darkmintis/gitstore/app/di/SharedModules.kt index bc33269..e15f403 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/app/di/SharedModules.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/app/di/SharedModules.kt @@ -36,6 +36,7 @@ import com.darkmintis.gitstore.feature.developer_profile.data.repository.Develop import com.darkmintis.gitstore.feature.developer_profile.domain.repository.DeveloperProfileRepository import com.darkmintis.gitstore.feature.favourites.presentation.FavouritesViewModel import com.darkmintis.gitstore.feature.home.data.data_source.CachedTrendingDataSource +import com.darkmintis.gitstore.feature.home.data.data_source.TRENDING_CACHE_FILE_NAME import com.darkmintis.gitstore.feature.home.data.repository.HomeRepositoryImpl import com.darkmintis.gitstore.feature.home.domain.repository.HomeRepository import com.darkmintis.gitstore.feature.home.presentation.HomeViewModel @@ -156,7 +157,12 @@ val homeModule: Module = module { } single { - CachedTrendingDataSource() + CachedTrendingDataSource( + cacheFile = java.io.File( + androidContext().cacheDir, + TRENDING_CACHE_FILE_NAME + ) + ) } // ViewModel diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt index 2c527ee..a6c9534 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt @@ -9,15 +9,37 @@ import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess +import java.io.File +import java.io.IOException +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.PortUnreachableException +import java.net.UnknownHostException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import com.darkmintis.gitstore.core.domain.model.GithubRepoSummary import com.darkmintis.gitstore.core.domain.model.GithubUser -class CachedTrendingDataSource() { +/** + * On-disk name for the offline copy of the trending list. Lives in the app's cacheDir so the + * OS can reclaim it under storage pressure without losing any authoritative data. + */ +const val TRENDING_CACHE_FILE_NAME = "trending_apps.json" + +/** + * Serves the daily discovered trending list from jsDelivr and keeps a disk copy so the + * Trending tab still renders when the device is offline. + * + * Network is tried first so users always see today's list; the disk copy is only used as a + * fallback. That keeps it simple and guarantees freshness while still avoiding a dead screen. + */ +class CachedTrendingDataSource( + private val cacheFile: File, +) { private val json = Json { ignoreUnknownKeys = true isLenient = true @@ -39,65 +61,115 @@ class CachedTrendingDataSource() { expectSuccess = false } - private val baseUrl = "https://raw.githubusercontent.com/Darkmintis/Git-Store/main/cached-data/trending" - /** - * Fetch cached trending repositories for the current platform - * Returns null if fetch fails or data is unavailable + * jsDelivr, not raw.githubusercontent.com: edge-cached across multiple CDNs with proper + * cache headers, so the Trending seed list loads fast everywhere instead of depending on + * GitHub's plain file server. + * + * `@main` is deliberate — jsDelivr revalidates branch refs within minutes, so the daily + * run of scripts/discover.py propagates to users automatically. Never pin this to a tag + * or SHA: those are cached immutably for a year and would freeze users on day-one data. */ - suspend fun getCachedTrendingRepos(): CachedRepoResponse? { - return withContext(Dispatchers.IO) { - try { - // Using apps.json for all Android apps (platform-agnostic naming) - val url = "$baseUrl/apps.json" - - Logger.d { "🔍 Fetching cached trending repos from: $url" } - - val response: HttpResponse = httpClient.get(url) - - Logger.d { "📥 Response status: ${response.status.value} ${response.status.description}" } - - when { - response.status.isSuccess() -> { - val responseText = response.bodyAsText() - Logger.d { "📄 Response body length: ${responseText.length} characters" } - - val cachedData = json.decodeFromString(responseText) - - Logger.d { "✓ Successfully loaded ${cachedData.repositories.size} cached repos" } - Logger.d { "✓ Last updated: ${cachedData.lastUpdated}" } - - cachedData - } - response.status.value == 404 -> { - Logger.w { "⚠️ Cached data not found (404) - may not be generated yet" } - Logger.w { "⚠️ URL attempted: $url" } - null - } - else -> { - val errorBody = response.bodyAsText() - Logger.e { "❌ Failed to fetch cached repos: HTTP ${response.status.value}" } - Logger.e { "❌ Response body: ${errorBody.take(500)}" } - null - } + private val baseUrl = + "https://cdn.jsdelivr.net/gh/Darkmintis/Git-Store@main/cached-data/trending" + + suspend fun loadTrending(): TrendingResult = withContext(Dispatchers.IO) { + val fetched = fetchFromNetwork() + + if (fetched != null && fetched.repos.isNotEmpty()) { + Logger.d { "✓ Loaded ${fetched.repos.size} trending repos from jsDelivr (updated ${fetched.lastUpdated})" } + writeCache(fetched) + return@withContext fetched + } + + val cached = readCache() + if (cached != null) { + Logger.d { "↩ Serving ${cached.repos.size} trending repos from disk cache (updated ${cached.lastUpdated})" } + return@withContext cached + } + + Logger.d { + if (fetched?.isOffline == true) { + "No trending cache available and the device appears to be offline" + } else { + "No trending data available" + } + } + + TrendingResult(isOffline = fetched?.isOffline == true) + } + + private suspend fun fetchFromNetwork(): TrendingResult? { + val url = "$baseUrl/apps.json" + + return try { + val response: HttpResponse = httpClient.get(url) + + if (response.status.isSuccess()) { + val responseText = response.bodyAsText() + val cachedData = json.decodeFromString(responseText) + + if (cachedData.repositories.isEmpty()) { + null + } else { + TrendingResult( + repos = cachedData.repositories, + lastUpdated = cachedData.lastUpdated + ) } - } catch (e: HttpRequestTimeoutException) { - Logger.e { "⏱️ Timeout fetching cached trending repos: ${e.message}" } - e.printStackTrace() + } else if (response.status.value == 404) { + Logger.w { "⚠️ Cached trending data not found (404) - may not be generated yet" } null - } catch (e: SerializationException) { - Logger.e { "🔧 JSON parsing error: ${e.message}" } - e.printStackTrace() - null - } catch (e: Exception) { - Logger.e { "💥 Error fetching cached trending repos: ${e.message}" } - Logger.e { "💥 Exception type: ${e::class.simpleName}" } - e.printStackTrace() + } else { + Logger.e { "❌ Failed to fetch trending repos: HTTP ${response.status.value}" } null } + } catch (e: HttpRequestTimeoutException) { + // Must stay above IOException: HttpRequestTimeoutException extends it. + Logger.e { "⏱️ Timeout fetching trending repos: ${e.message}" } + null + } catch (e: SerializationException) { + Logger.e { "🔧 Trending JSON parsing error: ${e.message}" } + null + } catch (e: IOException) { + Logger.e { "📡 Trending fetch failed (${e::class.simpleName}): ${e.message}" } + TrendingResult(isOffline = isOfflineCause(e)) + } catch (e: Exception) { + Logger.e { "💥 Error fetching trending repos: ${e::class.simpleName}: ${e.message}" } + null } } + private fun readCache(): TrendingResult? { + val text = runCatching { + if (cacheFile.exists()) cacheFile.readText() else null + }.getOrNull() ?: return null + + return runCatching { + json.decodeFromString(text) + }.getOrNull() + ?.takeIf { it.repositories.isNotEmpty() } + ?.let { + TrendingResult( + repos = it.repositories, + lastUpdated = it.lastUpdated, + isFromDiskCache = true + ) + } + } + + private fun writeCache(result: TrendingResult) { + runCatching { + val payload = CachedRepoResponse( + lastUpdated = result.lastUpdated.orEmpty(), + totalCount = result.repos.size, + repositories = result.repos + ) + cacheFile.parentFile?.mkdirs() + cacheFile.writeText(json.encodeToString(payload)) + }.onFailure { Logger.w { "Could not persist trending cache: ${it.message}" } } + } + /** * Clean up resources when done */ @@ -106,6 +178,42 @@ class CachedTrendingDataSource() { } } +/** + * Outcome of loading the trending seed list. `isOffline` distinguishes "no connection" from + * "the file is missing or malformed" so the UI can show the right message. + */ +data class TrendingResult( + val repos: List = emptyList(), + val lastUpdated: String? = null, + val isOffline: Boolean = false, + val isFromDiskCache: Boolean = false, +) { + val hasData: Boolean get() = repos.isNotEmpty() +} + +/** + * Only treat failures that genuinely mean "nothing is reachable" as offline. A plain + * IOException can also be a slow-but-alive server, where an offline message would be wrong. + */ +private fun isOfflineCause(error: Throwable): Boolean { + var current: Throwable? = error + var depth = 0 + + while (current != null && depth < 5) { + if (current is UnknownHostException || + current is ConnectException || + current is NoRouteToHostException || + current is PortUnreachableException + ) { + return true + } + current = current.cause + depth++ + } + + return false +} + /** * Cached repository data (Android apps) */ diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt index 2101261..9ebf017 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt @@ -48,24 +48,37 @@ class HomeRepositoryImpl( if (page == 1) { Logger.d { "Attempting to load cached trending repositories..." } - val cachedData = cachedDataSource.getCachedTrendingRepos() - - if (cachedData != null && cachedData.repositories.isNotEmpty()) { - Logger.d { "Using cached data: ${cachedData.repositories.size} repos" } + val cachedData = cachedDataSource.loadTrending() + + if (cachedData.hasData) { + Logger.d { + buildString { + append("Using trending data: ${cachedData.repos.size} repos") + if (cachedData.isFromDiskCache) append(" (disk cache)") + if (cachedData.isOffline) append(" (offline)") + } + } - val repos = cachedData.repositories.map { it.toGithubRepoSummary() } + val repos = cachedData.repos.map { it.toGithubRepoSummary() } emit( PaginatedRepos( repos = repos, hasMore = false, - nextPageIndex = 2 + nextPageIndex = 2, + isFromCache = cachedData.isFromDiskCache ) ) return@flow } else { - Logger.d { "No cached data available, falling back to live API" } + Logger.d { + if (cachedData.isOffline) { + "No cached trending data available while offline" + } else { + "No cached data available, falling back to live API" + } + } } } From cf9e4d710a6bae03de4f786ae5d49a69ce9c1f44 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:10:23 +0545 Subject: [PATCH 03/16] feat(home): show a notice when trending is served from cache HomeState carries isShowingCachedData, populated from PaginatedRepos.isFromCache, and the Trending tab renders a compact notice so it is clear the visible list is stored data and installs still need a connection. --- .../feature/home/presentation/HomeRoot.kt | 39 +++++++++++++++++++ .../feature/home/presentation/HomeState.kt | 2 + .../home/presentation/HomeViewModel.kt | 4 +- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 10 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt index 50f7390..9dc872a 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -168,6 +169,14 @@ fun HomeScreen( ) { FilterChips(state, onAction) + if (state.isShowingCachedData && state.repos.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + + CachedDataNotice( + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + Box(Modifier.fillMaxSize()) { LoadingState(state) @@ -267,6 +276,36 @@ private fun MainState( } } +/** + * Shown when the visible list came from disk. Browsing still works, but installs need a + * connection, so the notice says exactly that instead of silently pretending all is well. + */ +@Composable +private fun CachedDataNotice(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.WifiOff, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + + Text( + text = stringResource(R.string.showing_cached_trending), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(start = 8.dp) + ) + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun LoadingState(state: HomeState) { diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt index dd58c8f..e860fc9 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt @@ -15,5 +15,7 @@ data class HomeState( val currentCategory: HomeCategory = HomeCategory.TRENDING, val isAppsSectionVisible: Boolean = true, val isUpdateAvailable: Boolean = false, + /** True when the visible list was served from disk rather than the network. */ + val isShowingCachedData: Boolean = false, ) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt index 5b791ef..481dc7d 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt @@ -119,7 +119,8 @@ class HomeViewModel( isLoadingMore = !isInitial, errorMessage = null, currentCategory = targetCategory, - repos = if (isInitial) emptyList() else it.repos + repos = if (isInitial) emptyList() else it.repos, + isShowingCachedData = false ) } @@ -171,6 +172,7 @@ class HomeViewModel( currentState.copy( repos = uniqueList, hasMorePages = paginatedRepos.hasMore, + isShowingCachedData = paginatedRepos.isFromCache, errorMessage = if (uniqueList.isEmpty() && !paginatedRepos.hasMore) { application.getString(R.string.no_repositories_found) } else null diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9f907c2..077e049 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -359,6 +359,7 @@ Erlebnis frei Du bist Offline + Showing saved results - connect to download or install Noch keine Downloads Heruntergeladene APKs werden hier zur Installation oder Bereinigung angezeigt. Installieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7017179..6879ce9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -356,6 +356,7 @@ Estás sin conexión + Showing saved results - connect to download or install Aún no hay descargas Los APK descargados aparecerán aquí para instalar o eliminar. Instalar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 000124a..21c7893 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -355,6 +355,7 @@ Vous êtes hors ligne + Affichage des résultats enregistrés - connectez-vous pour télécharger ou installer Aucun téléchargement Les APKs téléchargés apparaîtront ici. Installer diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 175f17b..b1721e8 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -359,6 +359,7 @@ オフラインです + Showing saved results - connect to download or install ダウンロードはまだありません ダウンロードした APK はここに表示され、インストールや削除ができます。 インストール diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 302331a..7d75719 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -359,6 +359,7 @@ Вы не в сети + Showing saved results - connect to download or install Загрузок пока нет Загруженные APK появятся здесь для установки или удаления. Установить diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b0f089e..2053908 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -359,6 +359,7 @@ 您当前离线 + Showing saved results - connect to download or install 暂无下载 已下载的 APK 会显示在这里,供安装或清理。 安装 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51c55f7..85c5271 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -358,6 +358,7 @@ You are offline + Showing saved results - connect to download or install No downloads yet Downloaded APKs will appear here for install or cleanup. Install From da772a926c313d982ff8e1851fae99f683df04c4 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:10:23 +0545 Subject: [PATCH 04/16] fix(downloads): close the DownloadManager cursor on every path The status poll threw before cursor.close() for a missing download id, a failed download, and a completed download with a bad local URI, leaking a cursor each time. Wrap the query in try/finally and null-guard COLUMN_LOCAL_URI, which is nullable and would otherwise reach Uri.parse(null). --- .../core/data/services/AndroidDownloader.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/core/data/services/AndroidDownloader.kt b/app/src/main/kotlin/com/darkmintis/gitstore/core/data/services/AndroidDownloader.kt index d60f4e3..db64247 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/core/data/services/AndroidDownloader.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/core/data/services/AndroidDownloader.kt @@ -66,7 +66,13 @@ class AndroidDownloader( while (!isDone && currentCoroutineContext().isActive) { val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId)) - if (cursor.moveToFirst()) { + + // The cursor must be closed on every path, including the throws below. + try { + if (!cursor.moveToFirst()) { + throw IllegalStateException("Download ID not found: $downloadId") + } + val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) val downloaded = @@ -77,9 +83,13 @@ class AndroidDownloader( when (status) { DownloadManager.STATUS_SUCCESSFUL -> { - val localUriStr = - cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)) - val finalPath = Uri.parse(localUriStr).path + // COLUMN_LOCAL_URI is nullable; Uri.parse(null) would NPE downstream. + val localUriStr = cursor.getString( + cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI) + ) + val finalPath = localUriStr + ?.takeIf { it.isNotBlank() } + ?.let { Uri.parse(it).path } ?: throw IllegalStateException("Invalid local URI: $localUriStr") val file = File(finalPath) @@ -114,10 +124,9 @@ class AndroidDownloader( ) ) } - } else { - throw IllegalStateException("Download ID not found: $downloadId") + } finally { + cursor.close() } - cursor.close() if (!isDone) delay(500L) } From 08915e8cb936ee7ff4465622f876bb600d77014b Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:10:56 +0545 Subject: [PATCH 05/16] fix(downloads): clean up partial APKs after the ViewModel is cleared onCleared() launched cleanup on viewModelScope, which is already cancelled at that point, and cleanupUpdate() suspends on a cancelled job, so neither the details screen nor the apps screen actually removed an in-flight download when the user left. Run that work on a small scope that outlives the ViewModel, and wrap the cancellation-path cleanup in NonCancellable so cancelUpdate() can finish its delete before rethrowing. --- .../apps/presentation/AppsViewModel.kt | 30 +++++++++++++------ .../details/presentation/DetailsViewModel.kt | 13 ++++++-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt index 273f1bf..94581f1 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt @@ -6,8 +6,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -51,6 +54,12 @@ class AppsViewModel( private var updateAllJob: Job? = null private var isBatchUpdating = false + /** + * Outlives the ViewModel so cleanup that must happen after [onCleared] (when + * viewModelScope is already cancelled) can still run. + */ + private val cleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _state = MutableStateFlow(AppsState()) val state = _state .onStart { @@ -356,7 +365,9 @@ class AppsViewModel( } catch (e: CancellationException) { Logger.d { "Update cancelled for ${app.packageName}" } - cleanupUpdate(app.packageName, app.latestAssetName) + withContext(NonCancellable) { + cleanupUpdate(app.packageName, app.latestAssetName) + } updateAppState(app.packageName, UpdateState.Idle) throw e } catch (e: Exception) { @@ -605,14 +616,15 @@ class AppsViewModel( updateAllJob?.cancel() activeUpdates.values.forEach { it.cancel() } - viewModelScope.launch { - _state.value.apps.forEach { appItem -> - if (appItem.updateState != UpdateState.Idle && - appItem.updateState != UpdateState.Success - ) { - appItem.installedApp.latestAssetName?.let { assetName -> - downloader.cancelDownload(assetName) - } + // viewModelScope is already cancelled here, so cleanup has to run on its own scope. + val pendingAssetNames = _state.value.apps + .filter { it.updateState != UpdateState.Idle && it.updateState != UpdateState.Success } + .mapNotNull { it.installedApp.latestAssetName } + + if (pendingAssetNames.isNotEmpty()) { + cleanupScope.launch { + pendingAssetNames.forEach { assetName -> + runCatching { downloader.cancelDownload(assetName) } } } } diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt index 4443f0c..98b8407 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt @@ -8,7 +8,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -58,6 +61,12 @@ class DetailsViewModel( private var currentDownloadJob: Job? = null private var currentAssetName: String? = null + /** + * Outlives the ViewModel so download cleanup can still run after [onCleared], when + * viewModelScope is already cancelled. + */ + private val cleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _state = MutableStateFlow(DetailsState()) val state = _state .onStart { @@ -838,8 +847,8 @@ class DetailsViewModel( currentDownloadJob?.cancel() currentAssetName?.let { assetName -> - viewModelScope.launch { - downloader.cancelDownload(assetName) + cleanupScope.launch { + runCatching { downloader.cancelDownload(assetName) } } } } From a3d6acf2cf3b867ce03d21d7473183d4cf177f5d Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:11:21 +0545 Subject: [PATCH 06/16] fix(apps): count successful updates in update-all updateSingleApp() reset its state to Idle after a 2s success delay, and updateAllApps() read that state after join(), so successes were never counted and the summary reported "0 updated" even when every app succeeded. Record the outcome before the delay, skip the delay inside a batch, and reset success states once the batch finishes so a later update is not filtered out. Also drops the unused previousStates allocation. --- .../apps/presentation/AppsViewModel.kt | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt index 94581f1..d0f819b 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/apps/presentation/AppsViewModel.kt @@ -51,6 +51,7 @@ class AppsViewModel( private var hasLoadedInitialData = false private val activeUpdates = mutableMapOf() + private val updateOutcomes = mutableMapOf() private var updateAllJob: Job? = null private var isBatchUpdating = false @@ -358,13 +359,21 @@ class AppsViewModel( installer.install(filePath, ext) updateAppState(app.packageName, UpdateState.Success) - delay(2000) - updateAppState(app.packageName, UpdateState.Idle) + updateOutcomes[app.packageName] = true + + // During a batch the caller reads this outcome after join(); resetting to Idle + // here would race with that read, so only auto-reset single updates. + if (!isBatchUpdating) { + delay(2000) + updateAppState(app.packageName, UpdateState.Idle) + updateOutcomes.remove(app.packageName) + } Logger.d { "Successfully updated ${app.appName} to ${latestVersion}" } } catch (e: CancellationException) { Logger.d { "Update cancelled for ${app.packageName}" } + updateOutcomes[app.packageName] = false withContext(NonCancellable) { cleanupUpdate(app.packageName, app.latestAssetName) } @@ -373,6 +382,7 @@ class AppsViewModel( } catch (e: Exception) { Logger.e { "Update failed for ${app.packageName}: ${e.message}" } e.printStackTrace() + updateOutcomes[app.packageName] = false cleanupUpdate(app.packageName, app.latestAssetName) updateAppState( app.packageName, @@ -428,8 +438,6 @@ class AppsViewModel( return@launch } - val previousStates = appsToUpdate.map { it.installedApp.packageName to it.updateState }.toMap() - _state.update { it.copy( updateAllProgress = UpdateAllProgress( @@ -447,13 +455,14 @@ class AppsViewModel( updateSingleApp(appItem.installedApp) activeUpdates[appItem.installedApp.packageName]?.join() - val currentItem = _state.value.apps.find { - it.installedApp.packageName == appItem.installedApp.packageName - } - if (currentItem?.updateState is UpdateState.Success) { - succeeded++ - } else if (currentItem?.updateState is UpdateState.Error) { - failed++ + // Read the recorded outcome rather than the UI state: the update job may + // already have reset itself to Idle (or still be showing Success). + when (updateOutcomes.remove(appItem.installedApp.packageName)) { + true -> succeeded++ + false -> failed++ + null -> Logger.w { + "No update outcome recorded for ${appItem.installedApp.packageName}" + } } _state.update { @@ -486,8 +495,18 @@ class AppsViewModel( _events.send(AppsEvent.ShowError(ErrorMapper.message(e, stringProvider))) } finally { isBatchUpdating = false - _state.update { - it.copy( + updateOutcomes.clear() + _state.update { currentState -> + currentState.copy( + // Success states are only kept alive during the batch so the loop can + // observe them; reset afterwards so a future update is not filtered out. + apps = currentState.apps.map { item -> + if (item.updateState is UpdateState.Success) { + item.copy(updateState = UpdateState.Idle) + } else { + item + } + }, isUpdatingAll = false, updateAllProgress = null ) From 28465ee2a395dcf4bd9888fffbaed26e87ab93cc Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:11:21 +0545 Subject: [PATCH 07/16] build(release): derive version name and code from the git tag versionName and versionCode were hand-edited in app/build.gradle.kts while the app compares BuildConfig.VERSION_NAME against the latest release tag. Cutting a release without bumping the file made the in-app updater offer the version the user was already running. The release workflow now parses the pushed tag (v1.4.1 -> name 1.4.1, code 10401) and passes it to Gradle; local builds fall back to the values in build.gradle.kts. --- .github/workflows/release.yml | 20 +++++++++++++++++++- app/build.gradle.kts | 12 ++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b40ba3..0615210 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,23 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - + + - name: Resolve version from tag + id: version + if: startsWith(github.ref, 'refs/tags/') + run: | + VERSION="${GITHUB_REF_NAME#v}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + # Strip any pre-release suffix and default missing parts to 0. + MAJOR=${MAJOR%%[!0-9]*}; MAJOR=${MAJOR:-0} + MINOR=${MINOR%%[!0-9]*}; MINOR=${MINOR:-0} + PATCH=${PATCH%%[!0-9]*}; PATCH=${PATCH:-0} + # Keep versionCode monotonic: 1.4.1 -> 10401 + CODE=$((10#$MAJOR * 10000 + 10#$MINOR * 100 + 10#$PATCH)) + echo "name=$VERSION" >> "$GITHUB_OUTPUT" + echo "code=$CODE" >> "$GITHUB_OUTPUT" + echo "Building $VERSION (versionCode $CODE)" + - name: Decode and prepare keystore run: | echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > $GITHUB_WORKSPACE/gitstore-release.keystore @@ -37,6 +53,8 @@ jobs: - name: Build Release APK run: ./gradlew assembleRelease --no-daemon --stacktrace env: + APP_VERSION_NAME: ${{ steps.version.outputs.name }} + APP_VERSION_CODE: ${{ steps.version.outputs.code }} KEYSTORE_FILE: ${{ env.KEYSTORE_PATH }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7cbe4f1..954fcb7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,8 +9,16 @@ plugins { alias(libs.plugins.androidx.room) } -val appVersionName = "1.4.0" -val appVersionCode = 7 +// Version comes from the git tag in CI (see .github/workflows/release.yml). +// These are only fallbacks for local builds; do not hand-bump them for a release. +val appVersionName: String = providers.gradleProperty("appVersionName").orNull?.takeIf { it.isNotBlank() } + ?: System.getenv("APP_VERSION_NAME")?.takeIf { it.isNotBlank() } + ?: "1.4.1" + +val appVersionCode: Int = (providers.gradleProperty("appVersionCode").orNull?.takeIf { it.isNotBlank() } + ?: System.getenv("APP_VERSION_CODE")?.takeIf { it.isNotBlank() }) + ?.toIntOrNull() + ?: 10401 // GitHub OAuth Client ID (public for Device Flow) val githubClientId = "Ov23linXkLavDNV3zFMB" From 3e31e7a25088a397e51d6c2875a7b67474108d3f Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:11:21 +0545 Subject: [PATCH 08/16] docs: add v1.4.1 release notes Covers CDN-served trending with offline fallback, the cached-results notice, and the fixes landed on this branch. --- RELEASE_NOTES.md | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 085093f..0e71374 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,29 +1,25 @@ -# What's New in v1.4.0 +# What's New in v1.4.1 ### New Features -- **Multi-Language Translation** - on-the-fly app translation via MyMemory API with 16 supported languages -- **Language Selector** - switch app language instantly from Settings -- **Complete Localizations** - French, German, Spanish, Japanese, Russian, and Chinese -- **Translation Disk Cache** - translated strings cached for offline use -- **Download Manager** - view, track, and cancel active downloads from a new bottom tab -- **Offline Detection** - animated banner warns when network is unavailable -- **Batch Update Tracking** - see succeeded/failed counts when updating all apps +- **CDN-Served Trending** - the discovered apps list now loads from a global CDN instead of GitHub's plain file server, so the Trending tab opens faster everywhere +- **Offline Fallback** - the last successfully loaded list is kept on device, so Trending still renders when you have no connection instead of showing an empty screen +- **Saved Results Notice** - a banner makes it clear when you are browsing cached data and which actions need a connection ### Improvements -- Polished top app bar and home bar layout -- Tab scroll content stays above bottom navigation -- Translation retry with corrected German and Spanish strings -- Support Us section hidden behind feature flag -- More Apps header links to darkmintis.dev +- Trending fetch errors no longer fall through to the live GitHub API more than necessary +- Reduced logging noise on the trending fetch path +- French locale now carries the new cached-results string ### Bug Fixes -- Fixed translation retry after failure -- Corrected German and Spanish format string escaping -- Hardened translation with disk cache fallback -- Language picker limited to supported locales +- Fixed a lost trailing space in the German "More Apps by Darkmintis" label that could join two words together +- Restored full 281-string parity across all seven locales +- "Update all" now reports the correct number of successfully updated apps +- Downloads are now cleaned up when you leave the screen instead of leaving partial APKs behind +- Fixed a rare crash when a finished download reported no local file path +- The in-app updater no longer offers the version you are already running ### Under the Hood -- TranslationRepository and TranslationService architecture -- LocalizationManager for dynamic string switching -- ContentLanguageDetector for automatic language detection -- Contributor credits script for GitHub releases +- Trending seed list now resolves through `CachedTrendingDataSource.loadTrending()`, returning a typed result that distinguishes offline from missing data +- Disk cache stored in `cacheDir/trending_apps.json`, reclaimable by the OS without losing authoritative data +- Install/uninstall cleanup runs on a scope that survives the ViewModel, so cancellation cannot strand files +- Release version name and code are derived from the git tag in CI; local builds fall back to the values in `app/build.gradle.kts` From 7dbb2031f58b72ac95a348bf603b1dc449f00498 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 08:11:21 +0545 Subject: [PATCH 09/16] chore: ignore the local .local workspace Keeps scratch material (parked contributor tooling, session notes) out of the repository without deleting it. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 85e977b..c47d6ac 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ __pycache__/ graphify-out/ +.local/ From adc3129c4fa8417df7c1069fb6210b750ed17fa9 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 10:45:58 +0545 Subject: [PATCH 10/16] feat(home): expire saved trending snapshots after 48 hours The disk copy was trusted indefinitely, so a week-old snapshot could be shown as the live Trending list. Treat anything older than 48 hours, or with a missing/malformed timestamp, as unusable, so Home falls back to its normal offline state instead of presenting stale content as current. Covered by TrendingSnapshotFreshnessTest. --- .../data_source/CachedTrendingDataSource.kt | 49 ++++++++++++++++--- .../TrendingSnapshotFreshnessTest.kt | 32 ++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 app/src/test/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/TrendingSnapshotFreshnessTest.kt diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt index a6c9534..138e2bb 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/CachedTrendingDataSource.kt @@ -17,10 +17,13 @@ import java.net.PortUnreachableException import java.net.UnknownHostException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlin.time.Duration.Companion.hours import com.darkmintis.gitstore.core.domain.model.GithubRepoSummary import com.darkmintis.gitstore.core.domain.model.GithubUser @@ -30,6 +33,24 @@ import com.darkmintis.gitstore.core.domain.model.GithubUser */ const val TRENDING_CACHE_FILE_NAME = "trending_apps.json" +/** + * The seed list is regenerated daily, so a snapshot older than this is no longer a meaningful + * fallback. Serving it would present stale content as if it were current. + */ +private val MAX_CACHE_AGE = 48.hours + +/** + * A saved snapshot is only useful while it is recent enough to still reflect the daily list. + * Missing, blank, or malformed timestamps are treated as stale rather than trusted. + */ +internal fun isSnapshotFresh( + lastUpdated: String, + now: Instant = Clock.System.now() +): Boolean { + val updated = runCatching { Instant.parse(lastUpdated) }.getOrNull() ?: return false + return (now - updated) <= MAX_CACHE_AGE +} + /** * Serves the daily discovered trending list from jsDelivr and keeps a disk copy so the * Trending tab still renders when the device is offline. @@ -145,23 +166,35 @@ class CachedTrendingDataSource( if (cacheFile.exists()) cacheFile.readText() else null }.getOrNull() ?: return null - return runCatching { + val cached = runCatching { json.decodeFromString(text) }.getOrNull() ?.takeIf { it.repositories.isNotEmpty() } - ?.let { - TrendingResult( - repos = it.repositories, - lastUpdated = it.lastUpdated, - isFromDiskCache = true - ) + ?: return null + + if (!isSnapshotFresh(cached.lastUpdated)) { + Logger.d { + "Discarding stale trending cache (last updated ${ + cached.lastUpdated.ifBlank { "unknown" } + })" } + return null + } + + return TrendingResult( + repos = cached.repositories, + lastUpdated = cached.lastUpdated, + isFromDiskCache = true + ) } private fun writeCache(result: TrendingResult) { runCatching { val payload = CachedRepoResponse( - lastUpdated = result.lastUpdated.orEmpty(), + // Stamp the write time when the payload itself has no usable timestamp so the + // entry is not immediately considered stale on the next launch. + lastUpdated = result.lastUpdated?.takeIf { it.isNotBlank() } + ?: Clock.System.now().toString(), totalCount = result.repos.size, repositories = result.repos ) diff --git a/app/src/test/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/TrendingSnapshotFreshnessTest.kt b/app/src/test/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/TrendingSnapshotFreshnessTest.kt new file mode 100644 index 0000000..feb91fd --- /dev/null +++ b/app/src/test/kotlin/com/darkmintis/gitstore/feature/home/data/data_source/TrendingSnapshotFreshnessTest.kt @@ -0,0 +1,32 @@ +package com.darkmintis.gitstore.feature.home.data.data_source + +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TrendingSnapshotFreshnessTest { + + private val now = Instant.parse("2026-10-25T12:00:00Z") + + @Test + fun `accepts a snapshot from the same day`() { + assertTrue(isSnapshotFresh("2026-10-25T06:00:00Z", now)) + } + + @Test + fun `accepts a snapshot exactly at the age limit`() { + assertTrue(isSnapshotFresh("2026-10-23T12:00:00Z", now)) + } + + @Test + fun `rejects a snapshot older than the age limit`() { + assertFalse(isSnapshotFresh("2026-10-23T11:59:00Z", now)) + } + + @Test + fun `rejects blank or malformed timestamps`() { + assertFalse(isSnapshotFresh("", now)) + assertFalse(isSnapshotFresh("not-a-date", now)) + } +} From dfd58e5c39b44ebdaa1f65528599c82b9b981e51 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 10:45:58 +0545 Subject: [PATCH 11/16] feat(home): show snapshot age and a retry on the saved-results notice PaginatedRepos now carries the snapshot timestamp through HomeState, and the cached-results banner renders when the list was last updated plus a Retry action. That makes the offline state actionable instead of leaving an undated saved list looking like live data. --- .../data/repository/HomeRepositoryImpl.kt | 3 +- .../home/domain/model/PaginatedRepos.kt | 4 +- .../feature/home/presentation/HomeRoot.kt | 57 +++++++++++++++---- .../feature/home/presentation/HomeState.kt | 2 + .../home/presentation/HomeViewModel.kt | 4 +- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt index 9ebf017..f0ec90d 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/data/repository/HomeRepositoryImpl.kt @@ -66,7 +66,8 @@ class HomeRepositoryImpl( repos = repos, hasMore = false, nextPageIndex = 2, - isFromCache = cachedData.isFromDiskCache + isFromCache = cachedData.isFromDiskCache, + cachedUpdatedAt = cachedData.lastUpdated ) ) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt index 6908ae7..5ccc3e6 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/domain/model/PaginatedRepos.kt @@ -7,6 +7,8 @@ data class PaginatedRepos( val hasMore: Boolean, val nextPageIndex: Int, val totalCount: Int? = null, - val isFromCache: Boolean = false + val isFromCache: Boolean = false, + /** When [isFromCache] is true, the timestamp of the saved snapshot. */ + val cachedUpdatedAt: String? = null ) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt index 9dc872a..bebb818 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeRoot.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.Search @@ -37,6 +38,7 @@ 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.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -76,8 +78,10 @@ import com.darkmintis.gitstore.core.presentation.components.GithubStoreButton import com.darkmintis.gitstore.core.presentation.components.RepositoryCard import com.darkmintis.gitstore.core.presentation.components.RetryErrorState import com.darkmintis.gitstore.core.presentation.theme.GithubStoreTheme +import com.darkmintis.gitstore.core.presentation.utils.formatUpdatedAt import com.darkmintis.gitstore.feature.home.presentation.components.HomeFilterChips import com.darkmintis.gitstore.feature.home.presentation.model.HomeCategory +import kotlinx.datetime.Instant as DateInstant @Composable fun HomeRoot( @@ -173,6 +177,8 @@ fun HomeScreen( Spacer(modifier = Modifier.height(8.dp)) CachedDataNotice( + savedAt = state.cachedUpdatedAt, + onRetry = { onAction(HomeAction.Refresh) }, modifier = Modifier.padding(horizontal = 8.dp) ) } @@ -278,16 +284,25 @@ private fun MainState( /** * Shown when the visible list came from disk. Browsing still works, but installs need a - * connection, so the notice says exactly that instead of silently pretending all is well. + * connection, so the notice says exactly that, shows how old the snapshot is, and offers a + * retry instead of silently pretending all is well. */ @Composable -private fun CachedDataNotice(modifier: Modifier = Modifier) { +private fun CachedDataNotice( + savedAt: String?, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + val savedLabel = savedAt?.let { iso -> + if (runCatching { DateInstant.parse(iso) }.isSuccess) formatUpdatedAt(iso) else null + } + Row( modifier = modifier .fillMaxWidth() - .clip(CircleShape) + .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.tertiaryContainer) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(start = 12.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( @@ -297,12 +312,34 @@ private fun CachedDataNotice(modifier: Modifier = Modifier) { tint = MaterialTheme.colorScheme.onTertiaryContainer ) - Text( - text = stringResource(R.string.showing_cached_trending), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.padding(start = 8.dp) - ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp, end = 4.dp) + ) { + Text( + text = stringResource(R.string.showing_cached_trending), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + + if (savedLabel != null) { + Text( + text = savedLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.72f), + modifier = Modifier.padding(top = 2.dp) + ) + } + } + + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.retry), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } } } diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt index e860fc9..d41eb97 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeState.kt @@ -17,5 +17,7 @@ data class HomeState( val isUpdateAvailable: Boolean = false, /** True when the visible list was served from disk rather than the network. */ val isShowingCachedData: Boolean = false, + /** Timestamp of the saved snapshot backing the visible list, when there is one. */ + val cachedUpdatedAt: String? = null, ) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt index 481dc7d..fd4f0b4 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/home/presentation/HomeViewModel.kt @@ -120,7 +120,8 @@ class HomeViewModel( errorMessage = null, currentCategory = targetCategory, repos = if (isInitial) emptyList() else it.repos, - isShowingCachedData = false + isShowingCachedData = false, + cachedUpdatedAt = null ) } @@ -173,6 +174,7 @@ class HomeViewModel( repos = uniqueList, hasMorePages = paginatedRepos.hasMore, isShowingCachedData = paginatedRepos.isFromCache, + cachedUpdatedAt = paginatedRepos.cachedUpdatedAt, errorMessage = if (uniqueList.isEmpty() && !paginatedRepos.hasMore) { application.getString(R.string.no_repositories_found) } else null From f754b00c1f2ad9447fd07f917279c6dc7893d9a8 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 10:45:59 +0545 Subject: [PATCH 12/16] docs: tighten the v1.4.1 notes State the 48-hour window, mention the snapshot age and retry in the saved-results notice, and replace the French-locale-specific line with the actual scope: all seven UI locales carry the new string. --- RELEASE_NOTES.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0e71374..67135c4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,13 +2,13 @@ ### New Features - **CDN-Served Trending** - the discovered apps list now loads from a global CDN instead of GitHub's plain file server, so the Trending tab opens faster everywhere -- **Offline Fallback** - the last successfully loaded list is kept on device, so Trending still renders when you have no connection instead of showing an empty screen -- **Saved Results Notice** - a banner makes it clear when you are browsing cached data and which actions need a connection +- **Offline Fallback** - the last successfully loaded list is kept on device for up to 48 hours, so Trending still renders when you have no connection instead of showing an empty screen +- **Saved Results Notice** - a banner makes it clear when you are browsing saved data, shows when that list was last updated, and offers a retry ### Improvements - Trending fetch errors no longer fall through to the live GitHub API more than necessary - Reduced logging noise on the trending fetch path -- French locale now carries the new cached-results string +- All seven UI locales include the new saved-results notice ### Bug Fixes - Fixed a lost trailing space in the German "More Apps by Darkmintis" label that could join two words together @@ -21,5 +21,6 @@ ### Under the Hood - Trending seed list now resolves through `CachedTrendingDataSource.loadTrending()`, returning a typed result that distinguishes offline from missing data - Disk cache stored in `cacheDir/trending_apps.json`, reclaimable by the OS without losing authoritative data +- Saved snapshots older than 48 hours are discarded so stale data is never presented as current - Install/uninstall cleanup runs on a scope that survives the ViewModel, so cancellation cannot strand files - Release version name and code are derived from the git tag in CI; local builds fall back to the values in `app/build.gradle.kts` From 88bb9d12129572bc0df20d90d6bc249dd7b91085 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 06:10:24 +0100 Subject: [PATCH 13/16] fix(i18n): base the locale override on LocalConfiguration Reading configuration through LocalContext can miss configuration changes, and lint flags it as an error. Use LocalConfiguration as the base for both the localized context and the configuration provided to the tree. --- app/src/main/kotlin/com/darkmintis/gitstore/App.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/App.kt b/app/src/main/kotlin/com/darkmintis/gitstore/App.kt index ef971f7..74fbe20 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/App.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/App.kt @@ -65,15 +65,17 @@ fun App( } val context = LocalContext.current - val localizedContext = remember(context, currentLocale) { - val config = Configuration(context.resources.configuration) + val configuration = LocalConfiguration.current + + val localizedContext = remember(context, configuration, currentLocale) { + val config = Configuration(configuration) config.setLocale(currentLocale) config.setLayoutDirection(currentLocale) context.createConfigurationContext(config) } - val localizedConfiguration = remember(context, currentLocale) { - Configuration(context.resources.configuration).apply { + val localizedConfiguration = remember(configuration, currentLocale) { + Configuration(configuration).apply { setLocale(currentLocale) setLayoutDirection(currentLocale) } From 696e1ece504e5710db6bfbb961ccfbbd3b0dd2c5 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 06:10:25 +0100 Subject: [PATCH 14/16] refactor(details): remove the unused OnMessage action DetailsAction.OnMessage was never emitted; details messages already flow through DetailsEvent.OnMessage as resolved strings. Removing it also removes the only LocalContext.getString call in DetailsRoot, which lint flagged. --- .../feature/details/presentation/DetailsAction.kt | 2 -- .../gitstore/feature/details/presentation/DetailsRoot.kt | 8 -------- .../feature/details/presentation/DetailsViewModel.kt | 4 ---- 3 files changed, 14 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsAction.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsAction.kt index 47df17c..70087c4 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsAction.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsAction.kt @@ -24,8 +24,6 @@ sealed interface DetailsAction { data object OnToggleFavorite : DetailsAction data object CheckForUpdates : DetailsAction data object UpdateApp : DetailsAction - - data class OnMessage(val messageText: Int) : DetailsAction } diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsRoot.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsRoot.kt index 01e9e60..483c0d0 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsRoot.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsRoot.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.ui.platform.LocalContext import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -88,7 +87,6 @@ fun DetailsRoot( } } - val context = LocalContext.current DetailsScreen( state = state, snackbarHostState = snackbarHostState, @@ -102,12 +100,6 @@ fun DetailsRoot( onNavigateToDeveloperProfile(action.username) } - is DetailsAction.OnMessage -> { - coroutineScope.launch { - snackbarHostState.showSnackbar(context.getString(action.messageText)) - } - } - else -> { viewModel.onAction(action) } diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt index 98b8407..1d832c3 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/details/presentation/DetailsViewModel.kt @@ -553,10 +553,6 @@ class DetailsViewModel( is DetailsAction.OpenDeveloperProfile -> { // Handled in composable } - - is DetailsAction.OnMessage -> { - // Handled in composable - } } } From f604e46a25b6edc8451c0cff0a499382351c8168 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 06:10:25 +0100 Subject: [PATCH 15/16] fix(settings): resolve the logout message during composition Hoist the string out of the snackbar coroutine so it is resolved through stringResource and follows configuration changes. Also removes the LocalContext resource read lint flagged. --- .../gitstore/feature/settings/presentation/SettingsRoot.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/darkmintis/gitstore/feature/settings/presentation/SettingsRoot.kt b/app/src/main/kotlin/com/darkmintis/gitstore/feature/settings/presentation/SettingsRoot.kt index f2c8deb..39796be 100644 --- a/app/src/main/kotlin/com/darkmintis/gitstore/feature/settings/presentation/SettingsRoot.kt +++ b/app/src/main/kotlin/com/darkmintis/gitstore/feature/settings/presentation/SettingsRoot.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.ui.platform.LocalContext import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -57,12 +56,12 @@ fun SettingsRoot( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current + val logoutSuccessMessage = stringResource(R.string.logout_success) ObserveAsEvents(viewModel.events) { event -> when (event) { SettingsEvent.OnLogoutSuccessful -> { coroutineScope.launch { - snackbarState.showSnackbar(context.getString(R.string.logout_success)) + snackbarState.showSnackbar(logoutSuccessMessage) onNavigateBack() } From 376624d41a4f27154d8ca5baf06c0e6d6988f211 Mon Sep 17 00:00:00 2001 From: Darkmintis Date: Sun, 25 Oct 2026 06:10:26 +0100 Subject: [PATCH 16/16] build(manifest): document why QUERY_ALL_PACKAGES is required The permission is needed to detect whether an arbitrary tracked repo is already installed, and the app is distributed outside Play where package visibility cannot be declared in advance. Suppress the lint error with that rationale instead of leaving lint failing. --- app/src/main/AndroidManifest.xml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e23233..39022a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,17 @@ - + - + +