Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

- **Build toolchain and libraries updated.** No user-visible change intended.
- Gradle 8.13 → 9.8.0, Android Gradle plugin 8.13.0 → 9.4.1 (built-in
Kotlin: the `org.jetbrains.kotlin.android` plugin is gone), Kotlin
2.0.21 → 2.4.21, KSP → 2.3.12, Compose BOM 2024.12.01 → 2026.09.00.
- core-ktx 1.19.1, navigation-compose 2.10.2, Room 2.8.5,
hilt-navigation-compose 1.4.0, appcompat 1.8.0, Hilt 2.60.1,
kotlinx-coroutines 1.11.0, kotlinx-serialization 1.11.0, Robolectric 4.17.
- `compileSdk` 36 → 37 (required by the new AndroidX libraries);
`targetSdk` stays 36, `minSdk` stays 24.

## [2.1.1] - 2026-10-12

- **In-app updates from GitHub Releases.** The app checks this repository's
Expand Down
15 changes: 11 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ and **not** a damage calculator.
| Gradle root project | `CoverDex` |
| `applicationId` / package | `com.marcogn.coverdex` |
| Database file | `coverdex.db` |
| minSdk / targetSdk / compileSdk | 24 / 36 / 36 |
| minSdk / targetSdk / compileSdk | 24 / 36 / 37 |

CoverDex is a rewrite of a working app, not a new one. It used to ship as a
React/Capacitor PWA on GitHub Pages; that path is gone (see
Expand Down Expand Up @@ -251,7 +251,8 @@ there.
- **Never `clearAllTables()`** — cache invalidation must name the cache
tables explicitly or it takes the user's teams and roster with it.
- **Every Robolectric test class needs `@Config(sdk = [26])`.** Robolectric's
shadow jar for this app's `compileSdk` (36) requires a newer JDK than CI
shadow jar for the SDK level it picks by default (this app's `targetSdk`,
36) requires a newer JDK than CI
runs (`android-ci.yml` pins JDK 17) — without the pin, the test passes
locally on a machine with a newer JDK and fails in CI with a confusing
`UnsupportedOperationException at DefaultSdkProvider.java` that doesn't
Expand Down Expand Up @@ -282,11 +283,17 @@ there.
`AssetManager.open()` call, whose Robolectric shadow is backed by
whatever directory AGP's generated `test_config.properties` names as
`android_merged_assets` — which is the actual `debug` variant's
`mergeDebugAssets` output, not a `debugUnitTest`-specific one (this AGP
version has no such task). `app/build.gradle.kts` wires the schemas into
`mergeDebugAssets` output, not a `debugUnitTest`-specific one (AGP 8.13
and 9.4.1 have no such task). `app/build.gradle.kts` wires the schemas into
`sourceSets["debug"].assets` instead, which is what actually makes
`Migration1To2Test` pass. See `docs/implementation-decisions.md`,
"Phase 2", for how this was verified rather than assumed.
- **A ViewModel built over a test's Room database must be cleared before
`@After` closes it.** Its `viewModelScope` otherwise keeps collecting a
Room flow, which can re-query after `database.close()`; Room 2.8 fails that
with "connection is closed", reported against whichever test is running
(an intermittent failure). Create test ViewModels through
`TrackedViewModels` (test sources) and call its `clear()` first in `@After`.
- **`ALTER TABLE ... ADD COLUMN` needs a `DEFAULT` when the column is
`NOT NULL`.** SQLite (and therefore Room's own migration SQL) rejects a
`NOT NULL` column added this way with no default — every pre-existing
Expand Down
21 changes: 13 additions & 8 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.kotlin.serialization)
Expand All @@ -9,7 +8,9 @@ plugins {

android {
namespace = "com.marcogn.coverdex"
compileSdk = 36
// 37 because core 1.19, navigation 2.10, hilt-navigation-compose 1.4 and lifecycle 2.11
// require it (their AAR metadata); targetSdk stays 36.
compileSdk = 37

defaultConfig {
applicationId = "com.marcogn.coverdex"
Expand Down Expand Up @@ -78,10 +79,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "17"
}

buildFeatures {
compose = true
buildConfig = true
Expand All @@ -104,19 +101,27 @@ android {
// backed by the real `debug` variant's merged assets directory on disk, not by the JVM test
// classpath. Adding the schemas to sourceSets["test"].assets (the officially documented
// pattern for instrumented androidTest) does nothing for a Robolectric-based unit test in
// this AGP version — there is no separate assets merge for the debugUnitTest variant, so the
// AGP 8.13 or 9.4.1 (both checked) — there is no separate assets merge for the debugUnitTest variant, so the
// only directory Robolectric's AssetManager ever sees is `debug`'s own mergeDebugAssets
// output. So the schemas must be wired in as a `debug` source set asset instead; the handful
// of KB this adds to debug-only APKs is an accepted cost. Without this, Migration1To2Test
// fails with a FileNotFoundException rather than a real migration error. See
// docs/implementation-decisions.md, "Phase 2".
sourceSets {
getByName("debug") {
assets.srcDirs("$projectDir/schemas")
assets.directories.add("$projectDir/schemas")
}
}
}

// AGP 9 compiles Kotlin itself (built-in Kotlin), so the org.jetbrains.kotlin.android plugin and
// its android { kotlinOptions } block are gone; the compiler options live here instead.
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
Expand Down
27 changes: 27 additions & 0 deletions app/src/test/java/com/marcogn/coverdex/TrackedViewModels.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.marcogn.coverdex

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore

/**
* Holds the ViewModels a test creates in a real [ViewModelStore], so [clear] cancels their
* `viewModelScope` the way a device does when the screen goes away. A test that owns a Room
* database calls [clear] before `database.close()`: otherwise a ViewModel's still-collecting Room
* flow can re-query on Room's executor after the close, which Room 2.8 fails with
* "connection is closed", reported against whichever test is running.
*/
class TrackedViewModels {
private val store = ViewModelStore()
private var created = 0

fun <T : ViewModel> add(viewModelClass: Class<T>, create: () -> T): T {
val factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <VM : ViewModel> create(modelClass: Class<VM>): VM = create() as VM
}
return ViewModelProvider(store, factory)["viewModel${created++}", viewModelClass]
}

fun clear() = store.clear()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.marcogn.coverdex.ui.surprise
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.marcogn.coverdex.MainDispatcherRule
import com.marcogn.coverdex.TrackedViewModels
import com.marcogn.coverdex.data.local.CoverDexDatabase
import com.marcogn.coverdex.data.repository.CustomPokemonRepositoryImpl
import com.marcogn.coverdex.data.repository.TeamRepositoryImpl
Expand Down Expand Up @@ -51,14 +52,19 @@ class SurpriseMeViewModelTest {

@After
fun closeDatabase() {
viewModels.clear()
database.close()
}

private fun viewModel() = SurpriseMeViewModel(
pokedexRepository = FakePokedexRepository(chart, pool = pool),
customPokemonRepository = customPokemonRepository,
teamRepository = teamRepository,
)
private val viewModels = TrackedViewModels()

private fun viewModel() = viewModels.add(SurpriseMeViewModel::class.java) {
SurpriseMeViewModel(
pokedexRepository = FakePokedexRepository(chart, pool = pool),
customPokemonRepository = customPokemonRepository,
teamRepository = teamRepository,
)
}

@Test
fun `locking and removing an anchor updates lockedMembers`() = runTest(mainDispatcherRule.dispatcher) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.marcogn.coverdex.MainDispatcherRule
import com.marcogn.coverdex.TrackedViewModels
import com.marcogn.coverdex.data.local.CoverDexDatabase
import com.marcogn.coverdex.data.repository.CustomPokemonRepositoryImpl
import com.marcogn.coverdex.data.repository.TeamRepositoryImpl
Expand Down Expand Up @@ -59,16 +60,22 @@ class AnalysisViewModelTest {

@After
fun closeDatabase() {
viewModels.clear()
database.close()
}

private fun viewModel(teamId: String, pool: List<PokemonEntry> = emptyList()) = AnalysisViewModel(
savedStateHandle = SavedStateHandle(mapOf("teamId" to teamId)),
teamRepository = teamRepository,
pokedexRepository = FakePokedexRepository(chart, pool = pool),
customPokemonRepository = customPokemonRepository,
settingsPreferences = settingsPreferences,
)
private val viewModels = TrackedViewModels()

private fun viewModel(teamId: String, pool: List<PokemonEntry> = emptyList()) =
viewModels.add(AnalysisViewModel::class.java) {
AnalysisViewModel(
savedStateHandle = SavedStateHandle(mapOf("teamId" to teamId)),
teamRepository = teamRepository,
pokedexRepository = FakePokedexRepository(chart, pool = pool),
customPokemonRepository = customPokemonRepository,
settingsPreferences = settingsPreferences,
)
}

private fun waterFlyingWithElectricMove(): TeamMember {
val thunderbolt = PokemonMove(
Expand Down
1 change: 0 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false
Expand Down
36 changes: 36 additions & 0 deletions docs/implementation-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1141,3 +1141,39 @@ and `branch-name.sh` hooks, the `self-review`, `deps-triage` and `issues-triage`
and gains `actions: write` (the kit deletes old runs after a successful release); the `ANDROID_*` →
`RELEASE_*` secret mapping stays. `CLAUDE.md` got the tooling list, the review-before-PR rule and the
session/branch naming rule. Left out: nothing. Brought to 1721624 in the same PR (only `branch-name.sh`, rewritten to deny only clear cases, and one line of `docs/claude.md`).

## Dependabot #44, #46 and #52 combined (2026-10-18)

The three open Dependabot PRs could not land one at a time, each red in CI for a reason the next
one fixes: AGP 9.4.1 (#44) needs Gradle ≥ 9.6.0; Gradle 9.8.0 (#46) removed an internal API that
AGP 8.13 uses; Hilt ≥ 2.59 (#46) requires AGP 9 for its Gradle plugin; and core 1.19,
navigation 2.10, hilt-navigation-compose 1.4 and (transitively) lifecycle 2.11 (#52) declare AGP
≥ 9.1 and `compileSdk` ≥ 37 in their AAR metadata. So they land together, in one PR.

- **Built-in Kotlin.** AGP 9 compiles Kotlin itself and the `org.jetbrains.kotlin.android` plugin is
not compatible with its new DSL ([AGP 9.0 release
notes](https://developer.android.com/build/releases/agp-9-0-0-release-notes)). The plugin is
removed (catalogue, root and app build scripts); `android { kotlinOptions }` becomes a top-level
`kotlin { compilerOptions }` block. No opt-out properties (`android.builtInKotlin=false`,
`android.newDsl=false`): the release notes say they go away in AGP 10.
- **`compileSdk` 37, `targetSdk` 36.** Only compiling against 37 is required; moving `targetSdk`
opts into new runtime behaviour and is a separate decision.
- **Room schemas on the `debug` assets** now use `assets.directories.add(...)`: `srcDirs(...)` is
deprecated in AGP 9's DSL. Same directory, same reason (see "Phase 2").
- **Gradle wrapper** regenerated with `./gradlew wrapper --gradle-version 9.8.0
--gradle-distribution-sha256-sum …` (the jar is byte-identical to Dependabot's), which also adds
`distributionSha256Sum`, so a corrupted or tampered distribution download fails instead of running.
- **`lifecycle` 2.8.7 → 2.11.0** in the catalogue: navigation 2.10 and hilt-navigation-compose 1.4
already resolve it to 2.11.0, so the catalogue now states what ships.
- **ViewModel tests clear their ViewModels before closing the database.** With Room 2.8,
`AnalysisViewModelTest` failed intermittently with "connection is closed": the ViewModels it
built were never cleared, so their `viewModelScope` Room flows re-queried after `@After`'s
`database.close()` (one run passed, the next failed). `TrackedViewModels` (test sources) holds
them in a real `ViewModelStore`; `clear()` cancels their scopes as a device does, and
`AnalysisViewModelTest` and `SurpriseMeViewModelTest` (the only ViewModel tests over Room)
call it before closing. A query already running when the scope is cancelled ends inside a
cancelled coroutine, where its exception is not reported. `mergeDebugUnitTestAssets` is still
absent on AGP 9.4.1 (re-checked), so the "Phase 2" schema-assets wiring stands unchanged.
- **Left as warnings, for a follow-up**: `androidx.hilt.navigation.compose.hiltViewModel` (moved to
`androidx.hilt.lifecycle.viewmodel.compose` in hilt 1.3) and `MenuAnchorType` (Material 3 1.4's
`ExposedDropdownMenuAnchorType`). Deprecations only; the build has no warnings-as-errors.
2 changes: 1 addition & 1 deletion docs/plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ network was reachable but `ANDROID_HOME` was unset and no SDK was installed.
Check, in this order, and do not burn time fighting it:

```bash
curl -sfo /dev/null -w '%{http_code}\n' https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/8.13.0/gradle-8.13.0.pom
curl -sfo /dev/null -w '%{http_code}\n' https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/9.4.1/gradle-9.4.1.pom
echo "$ANDROID_HOME"; command -v sdkmanager
```

Expand Down
2 changes: 1 addition & 1 deletion docs/plan/native-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ agent does not have to reverse-engineer intent from that code.
| `applicationId` / package | `com.marcogn.coverdex` |
| App label | CoverDex |
| Database file | `coverdex.db` |
| minSdk / targetSdk / compileSdk | 24 / 36 / 36 |
| minSdk / targetSdk / compileSdk | 24 / 36 / 37 |

`applicationId` is **unchanged** from the Capacitor build — the app keeps its
identity on any device that already has it installed. `minSdk` stays at 24
Expand Down
29 changes: 14 additions & 15 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
[versions]
agp = "8.13.0"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
coreKtx = "1.13.1"
lifecycle = "2.8.7"
agp = "9.4.1"
kotlin = "2.4.21"
ksp = "2.3.12"
coreKtx = "1.19.1"
lifecycle = "2.11.0"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
navigationCompose = "2.8.5"
room = "2.6.1"
hilt = "2.52"
hiltNavigationCompose = "1.2.0"
kotlinxSerializationJson = "1.7.3"
kotlinxCoroutines = "1.9.0"
composeBom = "2026.09.00"
navigationCompose = "2.10.2"
room = "2.8.5"
hilt = "2.60.1"
hiltNavigationCompose = "1.4.0"
kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.11.0"
coil = "2.7.0"
junit = "4.13.2"
androidxTestExtJunit = "1.3.0"
espresso = "3.6.1"
robolectric = "4.16.1"
robolectric = "4.17"
datastorePreferences = "1.1.1"
appcompat = "1.7.0"
appcompat = "1.8.0"
desugarJdkLibs = "2.1.5"

[libraries]
Expand Down Expand Up @@ -58,7 +58,6 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Expand Down
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
5 changes: 4 additions & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
11 changes: 4 additions & 7 deletions gradlew

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading