Android overhaul: stale comments and the module README
* Milestone and ownership notes that no longer hold ("owned by M5/M6/M8", "M8 replaces it", "(M8b)", "M7x add",
"proposed decision in the M5 report", which is D-027 and D-028) in build scripts, :core:design, :core:fakes, :data,
:app, :core:model and the settings string files.
* SyncEngine: the clock_skew KDoc sat orphaned above restampAfterClockSkew's; it now documents renewMutationId.
* libs.versions.toml header: no milestone, the drift date.
* README: the module table as built (no owners, no DebugFakesModule, which M8 removed), the test fixtures in the
architecture check and the shared screenshot harness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+14
-9
@@ -11,18 +11,18 @@ compile, unit tests (including every vector of `contracts/test-vectors/`), lint
|
||||
|---|---|---|
|
||||
| `:core:model` | `kvitt.jvm.library` | UI contract (§14.2.4), domain types `model.money` (§14.5.1), analytics types `model.analytics` (§14.5.4) |
|
||||
| `:domain` | `kvitt.jvm.library` | §14.5 functions; `VectorTest` runs every vector file, copied by `:domain:copyTestVectors` |
|
||||
| `:core:design` | `kvitt.android.library` + `kvitt.android.compose` | `KvittTheme`, tokens, shared components, `Canvas` charts, formatters and shared strings (M6, `3080804`; fixes M6b, `3a1bd14`; SPEC §6.2); M6 owns the module |
|
||||
| `:core:fakes` | `kvitt.android.library` | `FakeRepositories(scenario)`, `FakeScenarios.empty`; scenarios of feature X go in `fakes/<x>/` (added by M7a to M7f) |
|
||||
| `:data` | `kvitt.android.library` + KSP + Room 3 | Room entities and `KvittDatabase` v1, schema in `data/schemas/`; M5 owns the module |
|
||||
| `:adapter:fcm` | `kvitt.android.library` | shell; M8 owns it |
|
||||
| `:feature:*` | `kvitt.android.feature` | screens built by M7a to M7f (landed 2026-10-18), goldens in `feature/<x>/src/test/screenshots/` (full screen 411 × 891 dp at 1x, D-098); top-level screens draw their own `KvittNavigationBar` (SPEC §13.2, D-071); M7g does the cleanups after M6b |
|
||||
| `:app` | `kvitt.android.application` + compose + hilt + Roborazzi | Application, MainActivity, goldens in `app/src/test/screenshots/`; release binds `:data` (`DataBindingsModule`, `e6ad858`), debug binds the fakes in `src/debug/.../DebugFakesModule.kt` (`72f21b2`) until M8 deletes it; M8 owns the module |
|
||||
| `:core:design` | `kvitt.android.library` + `kvitt.android.compose` + `kvitt.android.screenshots` | `KvittTheme`, tokens, shared components, `Canvas` charts, formatters, shared failure texts and limits, shared strings (SPEC §6.2); its test fixtures are the shared test support (below) |
|
||||
| `:core:fakes` | `kvitt.android.library` | `FakeRepositories(scenario)`, `FakeScenarios.empty`; scenarios of feature X go in `fakes/<x>/` |
|
||||
| `:data` | `kvitt.android.library` + `kvitt.android.hilt` + KSP + Room 3 | Room entities and `KvittDatabase`, schema in `data/schemas/`, Ktor client, sync engine, repositories, updater |
|
||||
| `:adapter:fcm` | `kvitt.android.library` + `kvitt.android.hilt` | Firebase Cloud Messaging: the service, token calls and the mapping to `PushEvent` |
|
||||
| `:feature:*` | `kvitt.android.feature` | the screens; goldens in `feature/<x>/src/test/screenshots/` (full screen 411 × 891 dp at 1x, D-098); top-level screens draw their own `KvittNavigationBar` (SPEC §13.2, D-071) |
|
||||
| `:app` | `kvitt.android.application` + compose + hilt + screenshots | Application, MainActivity, `NavHost`, push and notifications; both variants bind `:data` (`DataBindingsModule` through `di/AppDataModule.kt`, D-081); goldens in `app/src/test/screenshots/` |
|
||||
|
||||
## Architecture check
|
||||
|
||||
`architectureCheck` runs in every module's `check` (`build-logic/.../ArchitectureRules.kt` holds the rules as data).
|
||||
It fails on a project dependency outside the §6.2 table, on `:core:fakes` in any configuration that is not a test or
|
||||
debug configuration, and, in `:core:model` and `:domain`, on an Android plugin, on a library outside
|
||||
debug configuration, on a module's test fixtures outside a test configuration, and, in `:core:model` and `:domain`, on an Android plugin, on a library outside
|
||||
`org.jetbrains.kotlin*`, and on any import or fully qualified reference outside `kotlin.*`, `kotlinx.*` and
|
||||
`se.hagstedt.kvitt.*` in `src/main`. A new module must be added to `ArchitectureRules.allowed`.
|
||||
|
||||
@@ -45,7 +45,7 @@ debug configuration, and, in `:core:model` and `:domain`, on an Android plugin,
|
||||
|
||||
A stateless overload that takes the `*State` and event lambdas is what previews and screenshot tests render.
|
||||
* ViewModels are `@HiltViewModel` classes that receive repositories **through the interfaces of `:core:model`** only.
|
||||
`:app` binds those interfaces to `:data` with Hilt (release since `e6ad858`, debug too once M8 deletes the temporary debug fakes module); tests and previews use `:core:fakes`.
|
||||
`:app` binds those interfaces to `:data` with Hilt in both variants; tests and previews use `:core:fakes`.
|
||||
* Features never reference another feature, the `NavHost` or `:data`/`:domain`. `:app` owns the `NavHost` and wires
|
||||
the lambdas.
|
||||
* Strings live in the feature's own `src/main/res/values/strings.xml` and `values-sv/strings.xml`, keys prefixed with
|
||||
@@ -55,7 +55,12 @@ debug configuration, and, in `:core:model` and `:domain`, on an Android plugin,
|
||||
|
||||
## Screenshot tests
|
||||
|
||||
Roborazzi on Robolectric. `roborazzi.test.verify=true` in `gradle.properties` makes every unit-test run compare with the
|
||||
Roborazzi on Robolectric, wired by `kvitt.android.screenshots` (the plugin, `outputDir` `src/test/screenshots` and the
|
||||
test libraries; the feature plugin applies it). The harness is shared: `:core:design`'s test fixtures
|
||||
(`src/testFixtures`, package `se.hagstedt.kvitt.design.testing`, `testImplementation(testFixtures(project(":core:design")))`,
|
||||
added to every feature by its plugin) hold `Variant`, `TestClock`, `TestEnvironment`, `ScreenSnapshots` (capture modes
|
||||
`Root`, `Screen` and `ScreenWhenIdle`), `MainDispatcherRule` and `withMain`. A module adds only its own frame around
|
||||
the content (`AnalyticsSnapshots`, `SettleSnapshots`, `SettingsSnapshots`, `:core:design`'s `Snapshots`). `roborazzi.test.verify=true` in `gradle.properties` makes every unit-test run compare with the
|
||||
committed goldens. Re-record an intended change with `tools/gradle-serial.sh recordRoborazziDebug` and open every
|
||||
changed PNG before committing.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
/** The M2b shell: the themed surface with the app name. M8 replaces it with the navigation graph. */
|
||||
/** The themed surface with the app name, shown until the session state has loaded (KvittApp). */
|
||||
@Composable
|
||||
fun KvittEmptyScreen(modifier: Modifier = Modifier) {
|
||||
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
|
||||
@@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
if (savedInstanceState == null) app.onLink(AppLinks.parse(intent))
|
||||
// `:core:design` reads "now" and the zone from :data's clock (M6 contract request).
|
||||
// `:core:design` reads "now" and the zone from :data's clock.
|
||||
val kvittClock = KvittClock({ Instant.fromEpochMilliseconds(clock.nowMillis()) }, TimeZone.of(clock.zone().id))
|
||||
setContent {
|
||||
KvittTheme {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import org.gradle.api.artifacts.ProjectDependency
|
||||
import org.gradle.api.plugins.BasePlugin
|
||||
import org.gradle.language.base.plugins.LifecycleBasePlugin
|
||||
|
||||
/** Registers `architectureCheck` in every module and makes `check` depend on it (SPEC §6.2, owned by M2b). */
|
||||
/** Registers `architectureCheck` in every module and makes `check` depend on it (SPEC §6.2). */
|
||||
class ArchitecturePlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
|
||||
+1
-1
@@ -569,7 +569,7 @@ fun TopBarSheet() {
|
||||
KvittTopAppBar(w("Ny utgift", "New expense"), navigation = TopBarNavigation.Close, actions = {
|
||||
KvittButton(w("Spara", "Save"), {}, icon = KvittIcon.Check)
|
||||
})
|
||||
// Edge to edge (M8b): a 24 dp status-bar inset as on a phone; the fill runs behind it, the content sits below it.
|
||||
// Edge to edge: a 24 dp status-bar inset as on a phone; the fill runs behind it, the content sits below it.
|
||||
KvittTopAppBar(w("Statistik", "Analytics"), scrolled = true, windowInsets = WindowInsets(top = StatusBarSample))
|
||||
}
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ private const val INVITE_MAX_LINES = 3
|
||||
/**
|
||||
* Groups the raw invite code for display (S-1): "7K3MQX9D2HPA" shows as "7K3M-QX9D-2HPA". The field's value stays the raw
|
||||
* normalised code, so the platform keeps the cursor where the user typed and never lands it before a hyphen that
|
||||
* appeared after the fourth or eighth character (M8b). A hyphen is shown only between groups, followed by a zero-width
|
||||
* appeared after the fourth or eighth character. A hyphen is shown only between groups, followed by a zero-width
|
||||
* space so a line may break after any group: the line breaker does not break between a hyphen and a digit
|
||||
* ("QX9D-2HPA"), which cut the value at 200 % font scale. A cursor at a group boundary sits after the separator.
|
||||
*/
|
||||
|
||||
@@ -228,7 +228,7 @@ object KvittDimens {
|
||||
const val DISABLED_CONTAINER_ALPHA = 0.12f
|
||||
const val DELETED_ALPHA = 0.62f
|
||||
|
||||
// Screen measures of design-spec §4 that the feature modules use (moved here from the features, M6b).
|
||||
// Screen measures of design-spec §4 that the feature modules use (no feature writes a size of its own).
|
||||
/** App mark (Sunny, `primary`, `receipt_long`): S-1 brand block 88 with icon 44; S-22 About 96 with icon 48. */
|
||||
val appMark = 88.dp
|
||||
val appMarkIcon = 44.dp
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The invite code field's grouping (S-1, M8b): the value is the raw normalised code and the hyphens are display only, so
|
||||
* The invite code field's grouping (S-1): the value is the raw normalised code and the hyphens are display only, so
|
||||
* typing one character at a time keeps the cursor after the last character, and deleting across a hyphen removes the
|
||||
* character before it. Each step mirrors the field: the edit lands at the original cursor, the field normalises the
|
||||
* text, and the cursor is shown through the offset mapping.
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import se.hagstedt.kvitt.design.KvittTheme
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* A snackbar shown with [showKvittSnackbar] follows a language change while it is on screen (M6b item 5), and
|
||||
* A snackbar shown with [showKvittSnackbar] follows a language change while it is on screen, and
|
||||
* [styledStringResource] keeps the `<b>` parts of a resource.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
|
||||
/**
|
||||
* Scenarios of the account screens (S-5, S-6; M7b) on the household of [HomeScenarios]. Open S-5 with the account ids
|
||||
* Scenarios of the account screens (S-5, S-6) on the household of [HomeScenarios]. Open S-5 with the account ids
|
||||
* named here; S-6 creates or edits against the same household.
|
||||
*/
|
||||
object AccountScenarios {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import se.hagstedt.kvitt.model.Share
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
|
||||
/**
|
||||
* Scenarios of S-11 (analytics) and S-12 (drill-down), M7e. All share FakeScenarios.empty's household and clock
|
||||
* Scenarios of S-11 (analytics) and S-12 (drill-down). All share FakeScenarios.empty's household and clock
|
||||
* (today 2026-10-17); the expense data is plain and deterministic so every screenshot is reproducible.
|
||||
*
|
||||
* - [loaded]: May to October 2026, eleven default categories, four accounts (one archived), settlements none.
|
||||
|
||||
@@ -23,7 +23,7 @@ import se.hagstedt.kvitt.model.UpdateState
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
|
||||
/**
|
||||
* The household of the design spec's frames (S-4, S-5, S-16, S-17; M7b): Jacob (the viewer) and Anna, the accounts
|
||||
* The household of the design spec's frames (S-4, S-5, S-16, S-17): Jacob (the viewer) and Anna, the accounts
|
||||
* Vardag, Huset, Japan 2027 and Gotland 2026 (archived). The phone's clock is 17 October 2026 14:02 in Stockholm, the
|
||||
* last sync three minutes earlier. Balances come from the expenses through `:domain`: Vardag +1 184,50 kr, Huset
|
||||
* −3 084,50 kr, Japan 2027 −1 350,00 kr, Gotland 2026 0, globally "Du är skyldig Anna 3 250,00 kr".
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ class OnlineScript {
|
||||
|
||||
/**
|
||||
* The fakes of [FakeRepositories] with the online calls of the settings screens routed through one [script]: the
|
||||
* error, loading and in-progress states of S-14, S-15, S-19, S-20, S-21 and S-22 (M7f). Reads pass straight through.
|
||||
* error, loading and in-progress states of S-14, S-15, S-19, S-20, S-21 and S-22. Reads pass straight through.
|
||||
*/
|
||||
class ScriptedSettingsRepositories(scenario: FakeScenario) {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ import se.hagstedt.kvitt.model.User
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
|
||||
/**
|
||||
* Scenarios of `:feature:settings` (M7f; S-13 to S-15, S-19 to S-22), as in the design spec's frames: Jacob (admin) and
|
||||
* Scenarios of `:feature:settings` (S-13 to S-15, S-19 to S-22), as in the design spec's frames: Jacob (admin) and
|
||||
* Anna, the accounts Vardag, Huset and Japan 2027, the 14 defaults plus the archived custom "Pant", four recurring
|
||||
* templates, both phones and one pending device invite. The phone's clock is 17 October 2026 14:02 Stockholm.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@ package se.hagstedt.kvitt.model.analytics
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
// Analytics input and output types (SPEC §14.5.4), package per the orchestrator's M2b decision.
|
||||
// Analytics input and output types (SPEC §14.5.4), package se.hagstedt.kvitt.model.analytics (D-017).
|
||||
|
||||
data class AnalyticsExpense(val date: LocalDate, val accountId: String, val categoryId: String, val amountOre: Long, val shares: Map<String, Long>)
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ import javax.inject.Provider
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** What `:app` (M8) provides: its version (§10.6) and build type (AC-2.8, AC-16.5). */
|
||||
/** What `:app` provides: its version (§10.6) and build type (AC-2.8, AC-16.5). */
|
||||
data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean)
|
||||
|
||||
@Qualifier
|
||||
|
||||
@@ -214,11 +214,6 @@ class SyncEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `clock_skew` keeps the row pending (§14.4.5). The server stores rejected results by `mutation_id` and replays them
|
||||
* (§14.4.4 steps 1 and 12), so the row gets a new mutation id; otherwise it could never sync after the clock is
|
||||
* fixed (proposed decision in the M5 report). A row edited during the request already has a new one.
|
||||
*/
|
||||
/**
|
||||
* D-027 (§14.4.5 step 3): on the first attempt after a `clock_skew` rejection, every pending row stamped later than
|
||||
* the phone's clock now is re-stamped to now, so a corrected clock heals it. Rows stamped by a clock that is still
|
||||
@@ -233,6 +228,11 @@ class SyncEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `clock_skew` keeps the row pending (§14.4.5). The server stores rejected results by `mutation_id` and replays them
|
||||
* (§14.4.4 steps 1 and 12), so the row gets a new mutation id; otherwise it could never sync after the clock is
|
||||
* fixed (D-027). A row edited during the request already has a new one.
|
||||
*/
|
||||
private suspend fun renewMutationId(pending: Pending) {
|
||||
val sent = pending.mutation.mutationId
|
||||
when (pending.entityType) {
|
||||
|
||||
@@ -36,7 +36,7 @@ fun interface LocalWriteListener {
|
||||
|
||||
/**
|
||||
* The sync triggers of AC-15.3 and §14.4.9: app start, every return to the foreground, 2 s after the last local write
|
||||
* (debounced), every 15 minutes in the background, and every FCM data message (expedited). `:app` (M8) calls
|
||||
* (debounced), every 15 minutes in the background, and every FCM data message (expedited). `:app` calls
|
||||
* [onAppStart], [onForeground] and [syncForPush].
|
||||
*/
|
||||
class SyncTriggers(
|
||||
@@ -85,7 +85,7 @@ class SyncTriggers(
|
||||
* WorkManager wiring (§14.4.1, §14.4.9): one-off work under the unique name `kvitt-sync` with `APPEND_OR_REPLACE`,
|
||||
* periodic work every 15 minutes with `KEEP`, both requiring a connected network, retries with exponential backoff
|
||||
* from 30 seconds. Periodic and one-off work cannot share a unique name in WorkManager, so the periodic one is
|
||||
* `kvitt-sync-periodic` (proposed decision in the M5 report).
|
||||
* `kvitt-sync-periodic` (D-028).
|
||||
*/
|
||||
class WorkManagerSyncWork(private val context: Context) : SyncWork {
|
||||
private val connected = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
||||
@@ -127,7 +127,7 @@ class SyncWorker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates [SyncWorker]s with the engine; `:app` (M8) installs it in its WorkManager `Configuration`. */
|
||||
/** Creates [SyncWorker]s with the engine; `:app` installs it in its WorkManager `Configuration`. */
|
||||
class KvittWorkerFactory(private val engine: () -> SyncEngine) : WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker? =
|
||||
if (workerClassName == SyncWorker::class.java.name) SyncWorker(appContext, workerParameters, engine()) else null
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.junit.runners.Parameterized
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* SPEC §14.3.5, M5 gate: every file under `contracts/examples/` decodes into its DTO and re-encodes to the same JSON
|
||||
* SPEC §14.3.5: every file under `contracts/examples/` decodes into its DTO and re-encodes to the same JSON
|
||||
* value (key order aside). One test case per file, so the case count equals the file count; [ExampleCoverageTest]
|
||||
* checks that no file is skipped. Sync bodies also round-trip their nested `entity` / `current` objects.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -262,7 +262,7 @@ class ExpenseEditorViewModelTest {
|
||||
fun editedAmounts_keepBothDecimals() = runTest(dispatcher) {
|
||||
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
|
||||
assertEquals("1247.60", vm.state.value.form?.amount?.raw)
|
||||
// The feature's own moneyInput is gone: :core:design's AmountInput.of restores money with both decimals (M6b).
|
||||
// :core:design's AmountInput.of restores money with both decimals.
|
||||
assertEquals("412.00", AmountInput.of(41_200).raw)
|
||||
assertEquals("0.05", AmountInput.of(5).raw)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
|
||||
<!-- Strings of :feature:settings, keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
|
||||
first. -->
|
||||
<resources>
|
||||
<string name="settings_profile_offline_banner">Kräver anslutning. Profilen kan bara ändras när telefonen är online.</string>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
|
||||
<!-- Strings of :feature:settings, keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
|
||||
first. -->
|
||||
<resources>
|
||||
<string name="settings_profile_offline_banner">Needs connection. Your profile can only be changed online.</string>
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import se.hagstedt.kvitt.model.User
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Navigation arguments of S-9 and S-10, read from the [SavedStateHandle] (`:app` puts them there, M8). S-9: none for
|
||||
* Navigation arguments of S-9 and S-10, read from the [SavedStateHandle] (`:app` puts them there). S-9: none for
|
||||
* the global settle-up, [ACCOUNT_ID] for the per-account variant, [SETTLEMENT_ID] for editing. S-10: [SETTLEMENT_ID].
|
||||
*/
|
||||
object SettleArgs {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Kvitt version catalog. Versions are the latest stable releases on Google Maven / Maven Central as of 2026-10-17
|
||||
# (milestone M2b). Drift: python3 tools/dependency-report/latest.py
|
||||
# Kvitt version catalog: the latest stable releases on Google Maven / Maven Central (no drift on 2026-10-18).
|
||||
# Drift: python3 tools/dependency-report/latest.py
|
||||
[versions]
|
||||
minSdk = "36"
|
||||
compileSdk = "37"
|
||||
@@ -27,8 +27,8 @@ coil = "3.6.3"
|
||||
junit = "4.13.2"
|
||||
robolectric = "4.17"
|
||||
roborazzi = "1.76.0"
|
||||
# M8 (2026-10-18): Firebase BoM 35.0.2 (firebase-messaging 26.0.0) and the Google services plugin 4.5.0, the latest
|
||||
# stable per firebase.google.com/support/release-notes/android
|
||||
# Firebase BoM 35.0.2 (firebase-messaging 26.0.0) and the Google services plugin 4.5.0, the latest stable per
|
||||
# firebase.google.com/support/release-notes/android on 2026-10-18
|
||||
firebaseBom = "35.0.2"
|
||||
googleServices = "4.5.0"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user