Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ __pycache__/

graphify-out/

.local/
39 changes: 18 additions & 21 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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`
12 changes: 10 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Needed to prompt install of APKs from our app -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Required to detect whether an arbitrary tracked repo is already installed. Git Store is
distributed outside Play, where package-visibility filters cannot be declared in advance. -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />

<queries>
<package android:name="com.darkmintis.gitstore" />
Expand Down
10 changes: 6 additions & 4 deletions app/src/main/kotlin/com/darkmintis/gitstore/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -156,7 +157,12 @@ val homeModule: Module = module {
}

single<CachedTrendingDataSource> {
CachedTrendingDataSource()
CachedTrendingDataSource(
cacheFile = java.io.File(
androidContext().cacheDir,
TRENDING_CACHE_FILE_NAME
)
)
}

// ViewModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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)
Expand Down Expand Up @@ -114,10 +124,9 @@ class AndroidDownloader(
)
)
}
} else {
throw IllegalStateException("Download ID not found: $downloadId")
} finally {
cursor.close()
}
cursor.close()

if (!isDone) delay(500L)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,9 +51,16 @@ class AppsViewModel(

private var hasLoadedInitialData = false
private val activeUpdates = mutableMapOf<String, Job>()
private val updateOutcomes = mutableMapOf<String, Boolean>()
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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}


Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,7 +87,6 @@ fun DetailsRoot(
}
}

val context = LocalContext.current
DetailsScreen(
state = state,
snackbarHostState = snackbarHostState,
Expand All @@ -102,12 +100,6 @@ fun DetailsRoot(
onNavigateToDeveloperProfile(action.username)
}

is DetailsAction.OnMessage -> {
coroutineScope.launch {
snackbarHostState.showSnackbar(context.getString(action.messageText))
}
}

else -> {
viewModel.onAction(action)
}
Expand Down
Loading
Loading