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/.gitignore b/.gitignore index 85e977b..c47d6ac 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ __pycache__/ graphify-out/ +.local/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 085093f..67135c4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,29 +1,26 @@ -# 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 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 -- 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 +- All seven UI locales include the new saved-results notice ### 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 +- 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` 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" 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 @@ - + - + + 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) } 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/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) } 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..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 @@ -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 @@ -48,9 +51,16 @@ class AppsViewModel( private var hasLoadedInitialData = false private val activeUpdates = mutableMapOf() + private val updateOutcomes = mutableMapOf() 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 { @@ -349,19 +359,30 @@ 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}" } - cleanupUpdate(app.packageName, app.latestAssetName) + updateOutcomes[app.packageName] = false + withContext(NonCancellable) { + cleanupUpdate(app.packageName, app.latestAssetName) + } updateAppState(app.packageName, UpdateState.Idle) throw e } 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, @@ -417,8 +438,6 @@ class AppsViewModel( return@launch } - val previousStates = appsToUpdate.map { it.installedApp.packageName to it.updateState }.toMap() - _state.update { it.copy( updateAllProgress = UpdateAllProgress( @@ -436,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 { @@ -475,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 ) @@ -605,14 +635,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/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 4443f0c..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 @@ -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 { @@ -544,10 +553,6 @@ class DetailsViewModel( is DetailsAction.OpenDeveloperProfile -> { // Handled in composable } - - is DetailsAction.OnMessage -> { - // Handled in composable - } } } @@ -838,8 +843,8 @@ class DetailsViewModel( currentDownloadJob?.cancel() currentAssetName?.let { assetName -> - viewModelScope.launch { - downloader.cancelDownload(assetName) + cleanupScope.launch { + runCatching { downloader.cancelDownload(assetName) } } } } 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..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 @@ -9,15 +9,58 @@ 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.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 -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" + +/** + * 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. + * + * 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,63 +82,125 @@ 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() - null - } catch (e: SerializationException) { - Logger.e { "🔧 JSON parsing error: ${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: 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 + + val cached = runCatching { + json.decodeFromString(text) + }.getOrNull() + ?.takeIf { it.repositories.isNotEmpty() } + ?: 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( + // 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 + ) + cacheFile.parentFile?.mkdirs() + cacheFile.writeText(json.encodeToString(payload)) + }.onFailure { Logger.w { "Could not persist trending cache: ${it.message}" } } } /** @@ -106,6 +211,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..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 @@ -48,24 +48,38 @@ 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, + cachedUpdatedAt = cachedData.lastUpdated ) ) 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" + } + } } } 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..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 @@ -6,6 +6,9 @@ data class PaginatedRepos( val repos: List, val hasMore: Boolean, val nextPageIndex: Int, - val totalCount: Int? = null + val totalCount: Int? = null, + 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 50f7390..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,10 +22,12 @@ 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 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 @@ -36,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 @@ -75,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( @@ -168,6 +173,16 @@ fun HomeScreen( ) { FilterChips(state, onAction) + if (state.isShowingCachedData && state.repos.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + + CachedDataNotice( + savedAt = state.cachedUpdatedAt, + onRetry = { onAction(HomeAction.Refresh) }, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + Box(Modifier.fillMaxSize()) { LoadingState(state) @@ -267,6 +282,67 @@ 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, shows how old the snapshot is, and offers a + * retry instead of silently pretending all is well. + */ +@Composable +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(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(start = 12.dp, top = 8.dp, end = 4.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.WifiOff, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + + 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 + ) + } + } +} + @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..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 @@ -15,5 +15,9 @@ 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, + /** 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 5b791ef..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 @@ -119,7 +119,9 @@ class HomeViewModel( isLoadingMore = !isInitial, errorMessage = null, currentCategory = targetCategory, - repos = if (isInitial) emptyList() else it.repos + repos = if (isInitial) emptyList() else it.repos, + isShowingCachedData = false, + cachedUpdatedAt = null ) } @@ -171,6 +173,8 @@ class HomeViewModel( currentState.copy( 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 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() } 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 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)) + } +}