diff --git a/android/adapter/fcm/build.gradle.kts b/android/adapter/fcm/build.gradle.kts
index 95c3651..ef36c41 100644
--- a/android/adapter/fcm/build.gradle.kts
+++ b/android/adapter/fcm/build.gradle.kts
@@ -1,8 +1,13 @@
-// :adapter:fcm — Firebase Cloud Messaging, the only module that names the vendor (owned by M8). M2b ships the shell.
+// :adapter:fcm — Firebase Cloud Messaging, the only module that names the vendor (SPEC §6.2, owned by M8): the
+// FirebaseMessagingService, the FCM token calls, and the mapping of data messages to the project's own PushEvent.
plugins {
id("kvitt.android.library")
+ id("kvitt.android.hilt")
}
dependencies {
implementation(project(":core:model"))
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.messaging)
+ implementation(libs.kotlinx.coroutines.core)
}
diff --git a/android/adapter/fcm/src/main/AndroidManifest.xml b/android/adapter/fcm/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6ff649a
--- /dev/null
+++ b/android/adapter/fcm/src/main/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt
deleted file mode 100644
index 5572eca..0000000
--- a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package se.hagstedt.kvitt.adapter.fcm
-
-/**
- * Module shell. M8 adds the FirebaseMessagingService, token registration and the mapping of data messages to the
- * project's own push events (SPEC §6.2, §14.6), once the Firebase licence and google-services.json are in place.
- */
-internal object FcmAdapter {
- /** Push payload version this adapter understands (SPEC §14.6). */
- const val PAYLOAD_VERSION: Int = 1
-}
diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmRegistration.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmRegistration.kt
new file mode 100644
index 0000000..37f1eca
--- /dev/null
+++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmRegistration.kt
@@ -0,0 +1,23 @@
+package se.hagstedt.kvitt.adapter.fcm
+
+import com.google.android.gms.tasks.Task
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+/**
+ * FCM registration (AC-13.7, AC-3.3). Since firebase-messaging 26 an app instance is addressed by its Firebase
+ * Installation ID (FID): [register] makes Firebase call `KvittMessagingService.onRegistered` with it, which `:app`
+ * sends as the device's `fcm_token` (E-17; the HTTP v1 `token` field the server fills accepts an FID, D-087).
+ */
+object FcmRegistration {
+ /** Registers this app instance; the FID arrives through [PushListener.onRegistered]. True on success. */
+ suspend fun register(): Boolean = FirebaseMessaging.getInstance().register().awaitCompletion().isSuccessful
+
+ /** Unregisters this app instance, so a revoked device stops receiving pushes on this install. True on success. */
+ suspend fun unregister(): Boolean = FirebaseMessaging.getInstance().unregister().awaitCompletion().isSuccessful
+
+ private suspend fun Task.awaitCompletion(): Task = suspendCancellableCoroutine { continuation ->
+ addOnCompleteListener { task -> continuation.resume(task) }
+ }
+}
diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/KvittMessagingService.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/KvittMessagingService.kt
new file mode 100644
index 0000000..62a13ed
--- /dev/null
+++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/KvittMessagingService.kt
@@ -0,0 +1,25 @@
+package se.hagstedt.kvitt.adapter.fcm
+
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+/**
+ * Receives Kvitt's data-only messages (§14.6) and registrations (AC-13.7) and hands them to `:app` at once. The work
+ * itself (a sync waited for up to 20 s, then the notification) runs in WorkManager, since Firebase gives
+ * `onMessageReceived` only a short execution window.
+ */
+@AndroidEntryPoint
+class KvittMessagingService : FirebaseMessagingService() {
+ @Inject
+ lateinit var listener: PushListener
+
+ override fun onMessageReceived(message: RemoteMessage) {
+ listener.onPush(PushMapping.map(message.data))
+ }
+
+ override fun onRegistered(installationId: String) {
+ listener.onRegistered(installationId)
+ }
+}
diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt
new file mode 100644
index 0000000..9324d90
--- /dev/null
+++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt
@@ -0,0 +1,44 @@
+package se.hagstedt.kvitt.adapter.fcm
+
+/**
+ * One push of payload v1 (SPEC §14.6), as the project's own record. [type] is kept as the open string the server sent
+ * so `:app` can treat an unknown type as "sync only" (§14.6 phone step 4). Empty [entityType] and [entityId] for
+ * `sync_batch`. No personal content ever reaches this record (AC-13.8).
+ *
+ * SPEC §6.2 places `PushEvent` in `:core:model`; it is not there at the M8 start, so it lives here until a contract
+ * change moves it (contract request in the M8 report).
+ */
+data class PushEvent(
+ val type: String,
+ val entityType: String,
+ val entityId: String,
+ val actorUserId: String,
+ val count: Int,
+)
+
+/** What `:app` does with FCM: handle a push, and register this instance's id (AC-13.7). Bound by `:app` through Hilt. */
+interface PushListener {
+ /** A data message. [event] is null when it is not payload v1 (`v` other than `1`): sync only, no notification. */
+ fun onPush(event: PushEvent?)
+
+ /** Firebase registered this app instance, at first or with a new Firebase Installation ID ([FcmRegistration]). */
+ fun onRegistered(installationId: String)
+}
+
+/** Maps an FCM data map (all values strings, §14.6) to a [PushEvent]. */
+object PushMapping {
+ const val PAYLOAD_VERSION = "1"
+
+ /** Null for a message of another payload version or without a `type`. */
+ fun map(data: Map): PushEvent? {
+ if (data["v"] != PAYLOAD_VERSION) return null
+ val type = data["type"]?.takeIf { it.isNotEmpty() } ?: return null
+ return PushEvent(
+ type = type,
+ entityType = data["entity_type"].orEmpty(),
+ entityId = data["entity_id"].orEmpty(),
+ actorUserId = data["actor_user_id"].orEmpty(),
+ count = data["count"]?.toIntOrNull()?.coerceAtLeast(0) ?: 1,
+ )
+ }
+}
diff --git a/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt b/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt
new file mode 100644
index 0000000..040cc3d
--- /dev/null
+++ b/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt
@@ -0,0 +1,56 @@
+package se.hagstedt.kvitt.adapter.fcm
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/** SPEC §14.6: the data map of payload v1 to [PushEvent]; other versions are "sync only" (null). */
+class PushMappingTest {
+ private fun v1(type: String, entityType: String, entityId: String, count: String = "1") = mapOf(
+ "v" to "1",
+ "type" to type,
+ "entity_type" to entityType,
+ "entity_id" to entityId,
+ "actor_user_id" to "0192-actor",
+ "count" to count,
+ )
+
+ @Test
+ fun mapsEveryTypeOfTheTable() {
+ val expenseTypes = listOf("expense_created", "expense_updated", "expense_deleted", "recurring_expense_created")
+ expenseTypes.forEach { type ->
+ assertEquals(PushEvent(type, "expense", "e-1", "0192-actor", 1), PushMapping.map(v1(type, "expense", "e-1")))
+ }
+ listOf("settlement_created", "settlement_updated", "settlement_deleted").forEach { type ->
+ assertEquals(PushEvent(type, "settlement", "s-1", "0192-actor", 1), PushMapping.map(v1(type, "settlement", "s-1")))
+ }
+ }
+
+ @Test
+ fun syncBatchCarriesTheCountAndEmptyEntity() {
+ assertEquals(PushEvent("sync_batch", "", "", "0192-actor", 7), PushMapping.map(v1("sync_batch", "", "", count = "7")))
+ }
+
+ @Test
+ fun unknownTypeIsKeptForTheAppToIgnore() {
+ assertEquals("future_type", PushMapping.map(v1("future_type", "expense", "e-1"))?.type)
+ }
+
+ @Test
+ fun otherPayloadVersionsAreSyncOnly() {
+ assertNull(PushMapping.map(v1("expense_created", "expense", "e-1") + ("v" to "2")))
+ assertNull(PushMapping.map(v1("expense_created", "expense", "e-1") - "v"))
+ assertNull(PushMapping.map(emptyMap()))
+ }
+
+ @Test
+ fun missingTypeIsSyncOnly() {
+ assertNull(PushMapping.map(v1("", "expense", "e-1")))
+ }
+
+ @Test
+ fun unreadableCountFallsBackToOne() {
+ assertEquals(1, PushMapping.map(v1("sync_batch", "", "", count = "many"))?.count)
+ assertEquals(0, PushMapping.map(v1("sync_batch", "", "", count = "-3"))?.count)
+ }
+}
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 2fd70a9..fff7ac8 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,11 +1,25 @@
-// :app — MainActivity, Application, Hilt wiring; navigation, bindings, deep links and notifications arrive with M8.
+// :app — MainActivity, Application, Hilt wiring (every :core:model interface bound to :data, D-041 and D-085),
+// navigation and deep links, push handling and notifications, network security and release signing (SPEC §6.2, M8).
+import java.util.Properties
+
plugins {
id("kvitt.android.application")
id("kvitt.android.compose")
id("kvitt.android.hilt")
+ alias(libs.plugins.google.services)
alias(libs.plugins.roborazzi)
}
+// SPEC §10.6: release signing from android/local.properties (git-ignored); without the keys the release build is unsigned.
+val localProperties = Properties().apply {
+ val file = rootProject.file("local.properties")
+ if (file.isFile) file.inputStream().use { load(it) }
+}
+val releaseStoreFile: String? = localProperties.getProperty("kvitt.release.storeFile")
+
+// SPEC §10.5.3: the pinned Caddy root replaces the system anchors for icehack.asuscomm.com only when this is true.
+val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean()
+
android {
namespace = "se.hagstedt.kvitt"
defaultConfig {
@@ -18,10 +32,28 @@ android {
buildFeatures {
buildConfig = true
}
+ signingConfigs {
+ if (releaseStoreFile != null) {
+ create("release") {
+ storeFile = file(releaseStoreFile)
+ storePassword = localProperties.getProperty("kvitt.release.storePassword")
+ keyAlias = localProperties.getProperty("kvitt.release.keyAlias")
+ keyPassword = localProperties.getProperty("kvitt.release.keyPassword")
+ }
+ }
+ }
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
+ release {
+ if (releaseStoreFile != null) signingConfig = signingConfigs.getByName("release")
+ }
+ }
+ sourceSets {
+ // The pinned-root network security config (and the committed kvitt_root_ca.crt in src/release/res/raw) only
+ // when kvitt.tls.pinnedRoot=true; otherwise src/main's system-anchors config applies.
+ if (pinnedRoot) getByName("release").res.directories.add("src/releasePinnedRoot/res")
}
}
@@ -42,14 +74,24 @@ dependencies {
implementation(project(":feature:settle"))
implementation(project(":feature:analytics"))
implementation(project(":feature:settings"))
- debugImplementation(project(":core:fakes"))
testImplementation(project(":core:fakes"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.process)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
+ implementation(libs.coil)
+ implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.androidx.compose.ui.test.junit4)
+ testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
diff --git a/android/app/src/debug/kotlin/se/hagstedt/kvitt/debug/DebugFakesModule.kt b/android/app/src/debug/kotlin/se/hagstedt/kvitt/debug/DebugFakesModule.kt
deleted file mode 100644
index 727b224..0000000
--- a/android/app/src/debug/kotlin/se/hagstedt/kvitt/debug/DebugFakesModule.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-// Temporary until M8 binds :data; M8 deletes this file.
-package se.hagstedt.kvitt.debug
-
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import se.hagstedt.kvitt.fakes.FakeRepositories
-import se.hagstedt.kvitt.fakes.FakeScenarios
-import se.hagstedt.kvitt.model.AboutRepository
-import se.hagstedt.kvitt.model.AccountRepository
-import se.hagstedt.kvitt.model.AnalyticsRepository
-import se.hagstedt.kvitt.model.CategoryRepository
-import se.hagstedt.kvitt.model.ConnectivityRepository
-import se.hagstedt.kvitt.model.DeviceRepository
-import se.hagstedt.kvitt.model.ExpenseRepository
-import se.hagstedt.kvitt.model.HomeRepository
-import se.hagstedt.kvitt.model.InviteRepository
-import se.hagstedt.kvitt.model.ProfileRepository
-import se.hagstedt.kvitt.model.RecurringRepository
-import se.hagstedt.kvitt.model.SessionRepository
-import se.hagstedt.kvitt.model.SettlementRepository
-import se.hagstedt.kvitt.model.SyncRepository
-import se.hagstedt.kvitt.model.UpdateRepository
-import javax.inject.Singleton
-
-/**
- * Debug builds only (src/debug): binds every `:core:model` repository interface of SPEC §14.2.4 to one set of fakes at
- * [FakeScenarios.empty], so the features' `@HiltViewModel`s have bindings in the debug graph before M8 binds `:data`.
- * Fakes never reach a release build: this source set and `debugImplementation(project(":core:fakes"))` are debug only.
- */
-@Module
-@InstallIn(SingletonComponent::class)
-object DebugFakesModule {
- @Provides @Singleton
- fun fakes(): FakeRepositories = FakeRepositories(FakeScenarios.empty)
-
- @Provides fun session(f: FakeRepositories): SessionRepository = f.session
- @Provides fun home(f: FakeRepositories): HomeRepository = f.home
- @Provides fun accounts(f: FakeRepositories): AccountRepository = f.accounts
- @Provides fun expenses(f: FakeRepositories): ExpenseRepository = f.expenses
- @Provides fun settlements(f: FakeRepositories): SettlementRepository = f.settlements
- @Provides fun analytics(f: FakeRepositories): AnalyticsRepository = f.analytics
- @Provides fun profile(f: FakeRepositories): ProfileRepository = f.profile
- @Provides fun categories(f: FakeRepositories): CategoryRepository = f.categories
- @Provides fun recurring(f: FakeRepositories): RecurringRepository = f.recurring
- @Provides fun devices(f: FakeRepositories): DeviceRepository = f.devices
- @Provides fun invites(f: FakeRepositories): InviteRepository = f.invites
- @Provides fun sync(f: FakeRepositories): SyncRepository = f.sync
- @Provides fun update(f: FakeRepositories): UpdateRepository = f.update
- @Provides fun about(f: FakeRepositories): AboutRepository = f.about
- @Provides fun connectivity(f: FakeRepositories): ConnectivityRepository = f.connectivity
-}
diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 0000000..c55cd22
--- /dev/null
+++ b/android/app/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 19a64a1..2c076e2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,21 +1,61 @@
-
+
+
+
+
+
+
+
+
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:windowSoftInputMode="adjustResize">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppLifecycle.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppLifecycle.kt
new file mode 100644
index 0000000..5b50ef0
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppLifecycle.kt
@@ -0,0 +1,66 @@
+package se.hagstedt.kvitt
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.work.Configuration
+import androidx.work.DelegatingWorkerFactory
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.data.di.AppBuildInfo
+import se.hagstedt.kvitt.data.sync.KvittWorkerFactory
+import se.hagstedt.kvitt.data.sync.SyncTriggers
+import se.hagstedt.kvitt.di.AppScope
+import se.hagstedt.kvitt.model.SessionRepository
+import se.hagstedt.kvitt.model.UpdateRepository
+import se.hagstedt.kvitt.push.PushNotifier
+import se.hagstedt.kvitt.push.PushTokens
+import se.hagstedt.kvitt.push.PushWorkerFactory
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+
+/**
+ * The process-wide triggers: sync on app start and every return to the foreground (AC-15.3, `SyncTriggers`), the
+ * update check on cold start and every resume in release builds only (AC-16.2, AC-16.5; `UpdateRepository.check`
+ * limits itself to once per 6 hours while resumed), and the push-token registration (AC-13.7).
+ */
+@Singleton
+class AppLifecycle @Inject constructor(
+ private val triggers: SyncTriggers,
+ private val update: UpdateRepository,
+ private val build: AppBuildInfo,
+ private val session: SessionRepository,
+ private val tokens: PushTokens,
+ @param:AppScope private val scope: CoroutineScope,
+) {
+ fun start() {
+ triggers.onAppStart()
+ tokens.start(session.session)
+ ProcessLifecycleOwner.get().lifecycle.addObserver(
+ object : DefaultLifecycleObserver {
+ private var first = true
+
+ override fun onStart(owner: LifecycleOwner) {
+ if (first) first = false else triggers.onForeground()
+ if (!build.debug) scope.launch { update.check() }
+ }
+ },
+ )
+ }
+}
+
+/** WorkManager's factory (D-041): `:data`'s sync worker and `:app`'s push worker. */
+@Singleton
+class AppWorkerFactory @Inject constructor(
+ kvitt: KvittWorkerFactory,
+ triggers: Provider,
+ notifier: Provider,
+) : DelegatingWorkerFactory() {
+ init {
+ addFactory(kvitt)
+ addFactory(PushWorkerFactory({ triggers.get() }, { notifier.get() }))
+ }
+
+ fun configuration(): Configuration = Configuration.Builder().setWorkerFactory(this).build()
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt
new file mode 100644
index 0000000..bc58691
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt
@@ -0,0 +1,68 @@
+package se.hagstedt.kvitt
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.link.AppLink
+import se.hagstedt.kvitt.link.OpenTarget
+import se.hagstedt.kvitt.model.SessionRepository
+import se.hagstedt.kvitt.model.SessionState
+import se.hagstedt.kvitt.model.SyncRepository
+import javax.inject.Inject
+
+/** The app shell's state: the session (which gate the graph shows), pending links and the post-pairing permission ask. */
+@HiltViewModel
+class AppViewModel @Inject constructor(
+ session: SessionRepository,
+ private val sync: SyncRepository,
+) : ViewModel() {
+ /** Null until the first emission (the shell shows the empty themed screen meanwhile). */
+ val session: StateFlow = session.session.stateIn(viewModelScope, SharingStarted.Eagerly, null)
+
+ private val inviteState = MutableStateFlow(null)
+
+ /** The last invite link (AC-2.6), consumed by S-1 as its prefill. */
+ val invite: StateFlow = inviteState.asStateFlow()
+
+ private val openState = MutableStateFlow(null)
+
+ /** A notification tap waiting for the paired graph (AC-13.6). */
+ val open: StateFlow = openState.asStateFlow()
+
+ private val askNotificationsState = MutableStateFlow(false)
+
+ /** AC-2.9: true once the full sync after a pairing has finished; the activity then asks for POST_NOTIFICATIONS. */
+ val askNotifications: StateFlow = askNotificationsState.asStateFlow()
+
+ fun onLink(link: AppLink?) {
+ when (link) {
+ is AppLink.Invite -> inviteState.value = link
+ is AppLink.Open -> openState.value = link.target
+ null -> Unit
+ }
+ }
+
+ fun openHandled() {
+ openState.value = null
+ }
+
+ /** S-1 succeeded: wait for the first successful sync (AC-2.9), then ask for the notification permission. */
+ fun onPaired() {
+ inviteState.value = null
+ viewModelScope.launch {
+ sync.status().first { it.summary.lastSuccessAt != null && !it.summary.running }
+ askNotificationsState.value = true
+ }
+ }
+
+ fun notificationsAsked() {
+ askNotificationsState.value = false
+ }
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt
index df82e56..292c1c3 100644
--- a/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt
@@ -1,8 +1,39 @@
package se.hagstedt.kvitt
import android.app.Application
+import androidx.work.Configuration
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
+import se.hagstedt.kvitt.push.Notifications
+import javax.inject.Inject
-/** Hilt's root. M8 adds the bindings from the `:core:model` interfaces to `:data`. */
+/**
+ * Hilt's root. WorkManager is configured here with `:data`'s `KvittWorkerFactory` (its default initializer is removed in
+ * the manifest), Coil's singleton is `:data`'s authenticated `ImageLoader` (AC-4.6), and the notification channel is
+ * created at every start (§14.6).
+ */
@HiltAndroidApp
-class KvittApplication : Application()
+class KvittApplication : Application(), Configuration.Provider, SingletonImageLoader.Factory {
+ @Inject
+ lateinit var workerFactory: AppWorkerFactory
+
+ @Inject
+ lateinit var imageLoader: Lazy
+
+ @Inject
+ lateinit var lifecycle: AppLifecycle
+
+ override val workManagerConfiguration: Configuration
+ get() = workerFactory.configuration()
+
+ override fun onCreate() {
+ super.onCreate()
+ Notifications.ensureChannel(this)
+ lifecycle.start()
+ }
+
+ override fun newImageLoader(context: PlatformContext): ImageLoader = imageLoader.get()
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt
index 6a7d7c4..c10cd24 100644
--- a/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt
@@ -1,21 +1,45 @@
package se.hagstedt.kvitt
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.datetime.TimeZone
+import se.hagstedt.kvitt.data.util.Clock
import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.KvittClock
+import se.hagstedt.kvitt.link.AppLinks
+import se.hagstedt.kvitt.nav.KvittApp
+import javax.inject.Inject
+import kotlin.time.Instant
+/** The single activity (§8.8): launcher, invite links (AC-2.6) and notification taps (AC-13.6). */
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ @Inject
+ lateinit var clock: Clock
+
+ private val app: AppViewModel by viewModels()
+
override fun onCreate(savedInstanceState: Bundle?) {
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).
+ val kvittClock = KvittClock({ Instant.fromEpochMilliseconds(clock.nowMillis()) }, TimeZone.of(clock.zone().id))
setContent {
KvittTheme {
- KvittEmptyScreen()
+ KvittApp(app, kvittClock, updateCheckAvailable = !BuildConfig.DEBUG)
}
}
}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ app.onLink(AppLinks.parse(intent))
+ }
}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppDataModule.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppDataModule.kt
new file mode 100644
index 0000000..ff2d9d7
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppDataModule.kt
@@ -0,0 +1,15 @@
+package se.hagstedt.kvitt.di
+
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import se.hagstedt.kvitt.data.di.DataBindingsModule
+
+/**
+ * Both variants: every `:core:model` repository interface bound to its `:data` implementation by including
+ * [DataBindingsModule] (`@DisableInstallInCheck`, D-041). The debug build talks to the development server too (D-001),
+ * so it binds `:data` as well (D-085); fakes are bound only in tests and the features' previews.
+ */
+@Module(includes = [DataBindingsModule::class])
+@InstallIn(SingletonComponent::class)
+interface AppDataModule
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt
new file mode 100644
index 0000000..623f795
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt
@@ -0,0 +1,33 @@
+package se.hagstedt.kvitt.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import se.hagstedt.kvitt.adapter.fcm.PushListener
+import se.hagstedt.kvitt.push.PushHandler
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+/** The application's own coroutine scope (token registration, update checks). */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class AppScope
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+ @Provides @Singleton @AppScope
+ fun scope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface AppBindings {
+ @Binds
+ fun pushListener(handler: PushHandler): PushListener
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt
new file mode 100644
index 0000000..6a7e130
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt
@@ -0,0 +1,70 @@
+package se.hagstedt.kvitt.link
+
+import android.content.Context
+import android.content.Intent
+import se.hagstedt.kvitt.MainActivity
+import se.hagstedt.kvitt.feature.onboarding.InviteLink
+
+/** Where a notification tap leads (AC-13.6, §14.6 phone step 3). */
+sealed interface OpenTarget {
+ data object Home : OpenTarget
+ data class Expense(val id: String) : OpenTarget
+ data class Settlement(val id: String) : OpenTarget
+}
+
+/** What an intent asks of the app. */
+sealed interface AppLink {
+ /** An invite link (AC-2.6): S-1 with both fields filled. [serverUrl] null when the link names none. */
+ data class Invite(val serverUrl: String?, val code: String) : AppLink
+
+ /** A notification tap. */
+ data class Open(val target: OpenTarget) : AppLink
+}
+
+/**
+ * Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…` and
+ * `https://icehack.asuscomm.com:8443/i/` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader
+ * S-1 uses for pasted links), and the notification taps this app builds ([openIntent]).
+ */
+object AppLinks {
+ const val ACTION_OPEN = "se.hagstedt.kvitt.action.OPEN"
+ private const val EXTRA_KIND = "se.hagstedt.kvitt.extra.OPEN_KIND"
+ private const val EXTRA_ID = "se.hagstedt.kvitt.extra.OPEN_ID"
+ private const val KIND_HOME = "home"
+ private const val KIND_EXPENSE = "expense"
+ private const val KIND_SETTLEMENT = "settlement"
+
+ fun parse(intent: Intent?): AppLink? {
+ if (intent == null) return null
+ return when (intent.action) {
+ Intent.ACTION_VIEW -> parseUri(intent.dataString)
+ ACTION_OPEN -> parseOpen(intent.getStringExtra(EXTRA_KIND), intent.getStringExtra(EXTRA_ID))
+ else -> null
+ }
+ }
+
+ /** An invite link, or null for anything else (a landing link with a bad code, another path, another scheme). */
+ fun parseUri(uri: String?): AppLink.Invite? {
+ val text = uri?.trim()?.takeIf { it.startsWith("kvitt:", ignoreCase = true) || it.startsWith("https:", ignoreCase = true) }
+ return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code) }
+ }
+
+ fun parseOpen(kind: String?, id: String?): AppLink.Open? = when (kind) {
+ KIND_HOME -> AppLink.Open(OpenTarget.Home)
+ KIND_EXPENSE -> id?.takeIf { it.isNotEmpty() }?.let { AppLink.Open(OpenTarget.Expense(it)) }
+ KIND_SETTLEMENT -> id?.takeIf { it.isNotEmpty() }?.let { AppLink.Open(OpenTarget.Settlement(it)) }
+ else -> null
+ }
+
+ /** The explicit intent of a notification tap. */
+ fun openIntent(context: Context, target: OpenTarget): Intent = Intent(context, MainActivity::class.java)
+ .setAction(ACTION_OPEN)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ .apply {
+ when (target) {
+ OpenTarget.Home -> putExtra(EXTRA_KIND, KIND_HOME)
+ is OpenTarget.Expense -> putExtra(EXTRA_KIND, KIND_EXPENSE).putExtra(EXTRA_ID, target.id)
+ is OpenTarget.Settlement -> putExtra(EXTRA_KIND, KIND_SETTLEMENT).putExtra(EXTRA_ID, target.id)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt
new file mode 100644
index 0000000..2e45560
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt
@@ -0,0 +1,401 @@
+package se.hagstedt.kvitt.nav
+
+import android.Manifest
+import android.app.Activity
+import android.content.pm.PackageManager
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.LocalActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalResources
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.AppViewModel
+import se.hagstedt.kvitt.KvittEmptyScreen
+import se.hagstedt.kvitt.design.KvittTheme
+import se.hagstedt.kvitt.design.component.AvatarPictures
+import se.hagstedt.kvitt.design.component.KvittClock
+import se.hagstedt.kvitt.design.component.KvittDestination
+import se.hagstedt.kvitt.design.component.KvittHousehold
+import se.hagstedt.kvitt.design.component.KvittSnackbar
+import se.hagstedt.kvitt.design.component.LocalAvatarPictures
+import se.hagstedt.kvitt.design.component.LocalKvittClock
+import se.hagstedt.kvitt.design.component.LocalKvittHousehold
+import se.hagstedt.kvitt.feature.account.AccountDetailScreen
+import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
+import se.hagstedt.kvitt.feature.account.AccountEditorScreen
+import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
+import se.hagstedt.kvitt.feature.analytics.DrillDownScreen
+import se.hagstedt.kvitt.feature.expense.ExpenseDetailScreen
+import se.hagstedt.kvitt.feature.expense.ExpenseEditorScreen
+import se.hagstedt.kvitt.feature.home.HomeScreen
+import se.hagstedt.kvitt.feature.home.SyncStatusScreen
+import se.hagstedt.kvitt.feature.onboarding.PairingDone
+import se.hagstedt.kvitt.feature.onboarding.PairingScreen
+import se.hagstedt.kvitt.feature.onboarding.ProfileStepScreen
+import se.hagstedt.kvitt.feature.onboarding.RemovedScreen
+import se.hagstedt.kvitt.feature.onboarding.WaitingScreen
+import se.hagstedt.kvitt.feature.settings.about.AboutScreen
+import se.hagstedt.kvitt.feature.settings.categories.ArchiveResult
+import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorScreen
+import se.hagstedt.kvitt.feature.settings.categories.CategoryListScreen
+import se.hagstedt.kvitt.feature.settings.devices.DevicesScreen
+import se.hagstedt.kvitt.feature.settings.hub.SettingsHubScreen
+import se.hagstedt.kvitt.feature.settings.invites.InvitesScreen
+import se.hagstedt.kvitt.feature.settings.profile.ProfileScreen
+import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorScreen
+import se.hagstedt.kvitt.feature.settings.recurring.RecurringListScreen
+import se.hagstedt.kvitt.feature.settle.SettleScreen
+import se.hagstedt.kvitt.feature.settle.SettlementDetailScreen
+import se.hagstedt.kvitt.link.OpenTarget
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.SessionState
+import se.hagstedt.kvitt.model.SettlementId
+import se.hagstedt.kvitt.model.User
+import se.hagstedt.kvitt.core.design.R as DesignR
+import se.hagstedt.kvitt.feature.account.R as AccountR
+
+/**
+ * The app shell: the session gate (S-1, S-2, S-18 before the paired graph; SPEC §14.4.8, AC-3.3), the paired graph of
+ * §13.2 with the bottom destinations drawn by each top-level screen, the composition locals `:core:design` needs
+ * (household, clock, avatar pictures), result snackbars, notification taps and the permission ask of AC-2.9.
+ */
+@Composable
+fun KvittApp(viewModel: AppViewModel, clock: KvittClock, updateCheckAvailable: Boolean) {
+ val session by viewModel.session.collectAsStateWithLifecycle()
+ val current = session ?: run {
+ KvittEmptyScreen()
+ return
+ }
+ val paired = current as? SessionState.Paired
+ val household = paired?.let { KvittHousehold(it.me.id, listOfNotNull(it.me.id, it.partner?.id)) } ?: KvittHousehold(null, emptyList())
+ CompositionLocalProvider(
+ LocalKvittHousehold provides household,
+ LocalKvittClock provides clock,
+ LocalAvatarPictures provides UserPictures,
+ ) {
+ AppGraph(viewModel, current, updateCheckAvailable)
+ }
+ NotificationPermission(viewModel)
+}
+
+/** `:data`'s Coil singleton maps a [User] to the authenticated E-11 request (user id and `pictureVersion`, AC-4.6). */
+private val UserPictures = AvatarPictures { user -> user.takeIf { it.pictureVersion != null } }
+
+@Composable
+private fun NotificationPermission(viewModel: AppViewModel) {
+ val ask by viewModel.askNotifications.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { viewModel.notificationsAsked() }
+ LaunchedEffect(ask) {
+ if (!ask) return@LaunchedEffect
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ viewModel.notificationsAsked()
+ } else {
+ launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+}
+
+@Composable
+private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheckAvailable: Boolean) {
+ val nav = rememberNavController()
+ val snackbar = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+ val start = remember { startRoute(session) }
+ val entry by nav.currentBackStackEntryAsState()
+ val route = entry?.destination?.route
+ val shell = Shell(nav, snackbar, scope)
+
+ // The session gate: revocation (S-18) and a lost pairing (S-1 "Pair this phone again") replace the whole stack.
+ LaunchedEffect(session) {
+ val at = nav.currentDestination?.route
+ when (session) {
+ SessionState.Revoked -> if (at != Routes.REMOVED) shell.replaceAll(Routes.REMOVED)
+ SessionState.Unpaired, SessionState.Unrecognised -> if (at != Routes.PAIRING) shell.replaceAll(Routes.PAIRING)
+ is SessionState.Paired -> Unit
+ }
+ }
+ val open by viewModel.open.collectAsStateWithLifecycle()
+ LaunchedEffect(open, session is SessionState.Paired, route) {
+ val target = open ?: return@LaunchedEffect
+ if (session !is SessionState.Paired || route == null || route in Routes.ONBOARDING) return@LaunchedEffect
+ viewModel.openHandled()
+ when (target) {
+ OpenTarget.Home -> shell.top(KvittDestination.Home)
+ is OpenTarget.Expense -> nav.navigate(Routes.expense(ExpenseId(target.id)))
+ is OpenTarget.Settlement -> nav.navigate(Routes.settlement(SettlementId(target.id)))
+ }
+ }
+
+ Box(Modifier.fillMaxSize()) {
+ NavHost(nav, startDestination = start) {
+ onboarding(viewModel, shell)
+ topLevel(session, shell)
+ accountsAndExpenses(shell)
+ settings(shell, updateCheckAvailable)
+ }
+ // Home shows result snackbars in its own scaffold; every other screen gets them here.
+ if (route != Routes.HOME) {
+ SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().imePadding()) { data ->
+ KvittSnackbar(data.visuals.message, Modifier.padding(KvittTheme.spacing.s4), data.visuals.actionLabel, { data.performAction() })
+ }
+ }
+ }
+}
+
+private fun startRoute(session: SessionState): String = when (session) {
+ is SessionState.Paired -> Routes.HOME
+ SessionState.Revoked -> Routes.REMOVED
+ SessionState.Unpaired, SessionState.Unrecognised -> Routes.PAIRING
+}
+
+/** Navigation helpers shared by the graph sections. */
+private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState, val scope: CoroutineScope) {
+ fun replaceAll(route: String) = nav.navigate(route) {
+ popUpTo(nav.graph.id) { inclusive = true }
+ launchSingleTop = true
+ }
+
+ /** A bottom destination (§13.2): one copy each, Home at the root, state kept per destination. */
+ fun top(destination: KvittDestination) {
+ val route = when (destination) {
+ KvittDestination.Home -> Routes.HOME
+ KvittDestination.Analytics -> Routes.ANALYTICS
+ KvittDestination.Settings -> Routes.SETTINGS
+ }
+ if (nav.graph.findNode(Routes.HOME) == null || nav.currentDestination?.route == route) return
+ nav.navigate(route) {
+ popUpTo(Routes.HOME) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+
+ fun back() {
+ nav.popBackStack()
+ }
+
+ fun show(message: String?, action: String? = null, onAction: () -> Unit = {}) {
+ if (message == null) return
+ scope.launch {
+ val result = snackbar.showSnackbar(message, actionLabel = action, withDismissAction = false)
+ if (result == SnackbarResult.ActionPerformed) onAction()
+ }
+ }
+}
+
+private fun NavGraphBuilder.onboarding(viewModel: AppViewModel, shell: Shell) {
+ composable(Routes.PAIRING) {
+ val invite by viewModel.invite.collectAsStateWithLifecycle()
+ PairingScreen(
+ onPaired = { done ->
+ viewModel.onPaired()
+ // AC-2.10: a partner confirms the profile (S-2) before Home.
+ shell.replaceAll(if (done == PairingDone.ProfileStep) Routes.PROFILE_STEP else Routes.HOME)
+ },
+ initialServerUrl = invite?.serverUrl,
+ initialCode = invite?.code,
+ )
+ }
+ composable(Routes.PROFILE_STEP) {
+ ProfileStepScreen(onDone = { shell.replaceAll(Routes.HOME) })
+ }
+ composable(Routes.REMOVED) {
+ val activity: Activity? = LocalActivity.current
+ // S-18: back closes the app; only "Pair again" leads on.
+ BackHandler { activity?.finish() }
+ RemovedScreen(onPairAgain = { shell.replaceAll(Routes.PAIRING) })
+ }
+}
+
+private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
+ composable(Routes.HOME) {
+ val partner: User? = (session as? SessionState.Paired)?.partner
+ if (session is SessionState.Paired && partner == null) {
+ // AC-5.1: until the partner has joined, Home is "Waiting for your partner" (S-3).
+ WaitingScreen(onInvitePartner = { shell.nav.navigate(Routes.INVITES) }, onNavigate = shell::top)
+ } else {
+ HomeScreen(
+ onAddExpense = { shell.nav.navigate(Routes.expenseEditor()) },
+ onSettleUp = { shell.nav.navigate(Routes.settle()) },
+ onOpenAccount = { shell.nav.navigate(Routes.account(it)) },
+ onNewAccount = { shell.nav.navigate(Routes.accountEditor(null)) },
+ onOpenSyncStatus = { shell.nav.navigate(Routes.SYNC_STATUS) },
+ onNavigate = shell::top,
+ snackbarHostState = shell.snackbar,
+ )
+ }
+ }
+ composable(Routes.ANALYTICS) {
+ AnalyticsScreen(onOpenDrillDown = { shell.nav.navigate(Routes.drillDown(it)) }, onNavigate = shell::top)
+ }
+ composable(Routes.DRILL_DOWN, arguments = Routes.drillDownArgs) {
+ DrillDownScreen(onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, onBack = shell::back)
+ }
+ composable(Routes.SETTINGS) {
+ SettingsHubScreen(
+ onOpenProfile = { shell.nav.navigate(Routes.PROFILE) },
+ onOpenCategories = { shell.nav.navigate(Routes.CATEGORIES) },
+ onOpenRecurring = { shell.nav.navigate(Routes.RECURRING) },
+ onOpenDevices = { shell.nav.navigate(Routes.DEVICES) },
+ onOpenInvites = { shell.nav.navigate(Routes.INVITES) },
+ onOpenSyncStatus = { shell.nav.navigate(Routes.SYNC_STATUS) },
+ onOpenAbout = { shell.nav.navigate(Routes.ABOUT) },
+ onNavigate = shell::top,
+ )
+ }
+ composable(Routes.SYNC_STATUS) {
+ SyncStatusScreen(
+ onBack = shell::back,
+ onOpenExpense = { shell.nav.navigate(Routes.expense(it)) },
+ onOpenSettlement = { shell.nav.navigate(Routes.settlement(it)) },
+ )
+ }
+}
+
+private fun NavGraphBuilder.accountsAndExpenses(shell: Shell) {
+ composable(Routes.ACCOUNT, arguments = Routes.accountArgs) {
+ val resources = LocalResources.current
+ AccountDetailScreen(
+ onBack = shell::back,
+ onOpenExpense = { shell.nav.navigate(Routes.expense(it)) },
+ onOpenSettlement = { shell.nav.navigate(Routes.settlement(it)) },
+ onAddExpense = { shell.nav.navigate(Routes.expenseEditor(account = it)) },
+ onSettle = { shell.nav.navigate(Routes.settle(account = it)) },
+ onEdit = { shell.nav.navigate(Routes.accountEditor(it)) },
+ onArchived = { _, name ->
+ shell.nav.popBackStack(Routes.HOME, inclusive = false)
+ // design-spec S-5: "Vardag arkiverades" with "Ångra" on Home. The undo label (common_undo) is not in
+ // :core:design yet, so the snackbar has no action until it is (M8 report, contract request).
+ shell.show(resources.getString(AccountR.string.account_archived_snackbar, name))
+ },
+ onDeleted = { shell.nav.popBackStack(Routes.HOME, inclusive = false) },
+ )
+ }
+ composable(Routes.ACCOUNT_EDITOR, arguments = Routes.accountEditorArgs) { entry ->
+ val editing = entry.arguments?.getString(AccountDetailViewModel.ARG_ACCOUNT_ID) != null
+ AccountEditorScreen(
+ onSaved = { id ->
+ shell.back()
+ if (!editing) shell.nav.navigate(Routes.account(id))
+ },
+ onClose = shell::back,
+ )
+ }
+ composable(Routes.EXPENSE_EDITOR, arguments = Routes.expenseEditorArgs) {
+ ExpenseEditorScreen(
+ onClose = shell::back,
+ onSaved = { _, text ->
+ shell.back()
+ shell.show(text)
+ },
+ onDeleted = { text ->
+ // Deleting from the editor also closes the detail it was opened from.
+ if (!shell.nav.popBackStack(Routes.EXPENSE, inclusive = true)) shell.back()
+ shell.show(text)
+ },
+ )
+ }
+ composable(Routes.EXPENSE, arguments = Routes.expenseArgs) {
+ ExpenseDetailScreen(
+ onBack = shell::back,
+ onEdit = { shell.nav.navigate(Routes.expenseEditor(id = it)) },
+ onDeleted = { text ->
+ shell.back()
+ shell.show(text)
+ },
+ )
+ }
+ composable(Routes.SETTLE, arguments = Routes.settleArgs) {
+ val resources = LocalResources.current
+ SettleScreen(
+ onClose = shell::back,
+ onSaved = {
+ shell.back()
+ shell.show(resources.getString(DesignR.string.snackbar_payment_saved))
+ },
+ )
+ }
+ composable(Routes.SETTLEMENT, arguments = Routes.settlementArgs) {
+ val resources = LocalResources.current
+ SettlementDetailScreen(
+ onBack = shell::back,
+ onEdit = { shell.nav.navigate(Routes.settle(editing = it)) },
+ onDeleted = {
+ shell.back()
+ shell.show(resources.getString(DesignR.string.snackbar_payment_deleted))
+ },
+ )
+ }
+}
+
+private fun NavGraphBuilder.settings(shell: Shell, updateCheckAvailable: Boolean) {
+ composable(Routes.PROFILE) { ProfileScreen(onBack = shell::back) }
+ composable(Routes.CATEGORIES) { entry ->
+ // The editor's archive result (S-15) comes back through this entry's SavedStateHandle.
+ val name by entry.savedStateHandle.getStateFlow(ARCHIVE_NAME, null).collectAsStateWithLifecycle()
+ val archived by entry.savedStateHandle.getStateFlow(ARCHIVE_ARCHIVED, true).collectAsStateWithLifecycle()
+ CategoryListScreen(
+ onBack = shell::back,
+ onOpenCategory = { id -> shell.nav.navigate(Routes.categoryEditor(id)) },
+ onNewCategory = { shell.nav.navigate(Routes.categoryEditor(null)) },
+ archiveResult = name?.let { ArchiveResult(it, archived) },
+ onArchiveResultShown = { entry.savedStateHandle[ARCHIVE_NAME] = null },
+ )
+ }
+ composable(Routes.CATEGORY_EDITOR, arguments = Routes.categoryEditorArgs) {
+ CategoryEditorScreen(
+ onClose = shell::back,
+ onArchived = { result ->
+ shell.nav.previousBackStackEntry?.savedStateHandle?.apply {
+ set(ARCHIVE_NAME, result.name)
+ set(ARCHIVE_ARCHIVED, result.archived)
+ }
+ shell.back()
+ },
+ )
+ }
+ composable(Routes.RECURRING) {
+ RecurringListScreen(
+ onBack = shell::back,
+ onOpenTemplate = { shell.nav.navigate(Routes.templateEditor(it)) },
+ onNewTemplate = { shell.nav.navigate(Routes.templateEditor(null)) },
+ )
+ }
+ composable(Routes.TEMPLATE_EDITOR, arguments = Routes.templateEditorArgs) { RecurringEditorScreen(onClose = shell::back) }
+ composable(Routes.DEVICES) { DevicesScreen(onBack = shell::back) }
+ composable(Routes.INVITES) { InvitesScreen(onBack = shell::back) }
+ composable(Routes.ABOUT) {
+ // The open-source licences screen does not exist yet (no strings for it outside :app; M8 report).
+ AboutScreen(onBack = shell::back, onOpenLicences = {}, updateCheckAvailable = updateCheckAvailable)
+ }
+}
+
+private const val ARCHIVE_NAME = "kvitt.categoryArchive.name"
+private const val ARCHIVE_ARCHIVED = "kvitt.categoryArchive.archived"
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt
new file mode 100644
index 0000000..eadb362
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt
@@ -0,0 +1,97 @@
+package se.hagstedt.kvitt.nav
+
+import android.net.Uri
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
+import se.hagstedt.kvitt.feature.analytics.DrillDownArgs
+import se.hagstedt.kvitt.feature.expense.ExpenseDetailViewModel
+import se.hagstedt.kvitt.feature.expense.ExpenseEditorViewModel
+import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorViewModel
+import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel
+import se.hagstedt.kvitt.feature.settle.SettleArgs
+import se.hagstedt.kvitt.model.AccountId
+import se.hagstedt.kvitt.model.CategoryId
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.SettlementId
+import se.hagstedt.kvitt.model.TemplateId
+
+/**
+ * The navigation graph's routes (§13.2). Argument names are the features' `SavedStateHandle` keys, so each feature
+ * ViewModel reads its own argument; optional arguments are query parameters (absent = new / global).
+ */
+object Routes {
+ const val PAIRING = "pairing"
+ const val PROFILE_STEP = "profile_step"
+ const val REMOVED = "removed"
+
+ const val HOME = "home"
+ const val ANALYTICS = "analytics"
+ const val SETTINGS = "settings"
+
+ private val ACCOUNT_ID = AccountDetailViewModel.ARG_ACCOUNT_ID
+ private val EXPENSE_ID = ExpenseEditorViewModel.ARG_EXPENSE_ID
+ private val EXPENSE_ACCOUNT_ID = ExpenseEditorViewModel.ARG_ACCOUNT_ID
+
+ val ACCOUNT = "account/{$ACCOUNT_ID}"
+ val ACCOUNT_EDITOR = "account_editor?$ACCOUNT_ID={$ACCOUNT_ID}"
+ val EXPENSE_EDITOR = "expense_editor?$EXPENSE_ID={$EXPENSE_ID}&$EXPENSE_ACCOUNT_ID={$EXPENSE_ACCOUNT_ID}"
+ val EXPENSE = "expense/{${ExpenseDetailViewModel.ARG_EXPENSE_ID}}"
+ val SETTLE = "settle?${SettleArgs.ACCOUNT_ID}={${SettleArgs.ACCOUNT_ID}}&${SettleArgs.SETTLEMENT_ID}={${SettleArgs.SETTLEMENT_ID}}"
+ val SETTLEMENT = "settlement/{${SettleArgs.SETTLEMENT_ID}}"
+ val DRILL_DOWN = "drill_down?" + DrillDownArgs.KEYS.joinToString("&") { "$it={$it}" }
+ const val SYNC_STATUS = "sync_status"
+
+ const val PROFILE = "settings/profile"
+ const val CATEGORIES = "settings/categories"
+ val CATEGORY_EDITOR = "settings/category?${CategoryEditorViewModel.ARG_CATEGORY_ID}={${CategoryEditorViewModel.ARG_CATEGORY_ID}}"
+ const val RECURRING = "settings/recurring"
+ val TEMPLATE_EDITOR = "settings/template?${RecurringEditorViewModel.ARG_TEMPLATE_ID}={${RecurringEditorViewModel.ARG_TEMPLATE_ID}}"
+ const val DEVICES = "settings/devices"
+ const val INVITES = "settings/invites"
+ const val ABOUT = "settings/about"
+
+ /** Routes outside the paired app: the session gate (`KvittApp`) moves between them. */
+ val ONBOARDING = setOf(PAIRING, PROFILE_STEP, REMOVED)
+
+ fun account(id: AccountId) = "account/${encode(id.value)}"
+ fun accountEditor(id: AccountId?) = query("account_editor", ACCOUNT_ID to id?.value)
+ fun expenseEditor(id: ExpenseId? = null, account: AccountId? = null) =
+ query("expense_editor", EXPENSE_ID to id?.value, EXPENSE_ACCOUNT_ID to account?.value)
+ fun expense(id: ExpenseId) = "expense/${encode(id.value)}"
+ fun settle(account: AccountId? = null, editing: SettlementId? = null) =
+ query("settle", SettleArgs.ACCOUNT_ID to account?.value, SettleArgs.SETTLEMENT_ID to editing?.value)
+ fun settlement(id: SettlementId) = "settlement/${encode(id.value)}"
+ fun drillDown(args: DrillDownArgs) = query("drill_down", *args.toArguments().toList().toTypedArray())
+ fun categoryEditor(id: CategoryId?) = query("settings/category", CategoryEditorViewModel.ARG_CATEGORY_ID to id?.value)
+ fun templateEditor(id: TemplateId?) = query("settings/template", RecurringEditorViewModel.ARG_TEMPLATE_ID to id?.value)
+
+ /** Nullable string arguments, all absent by default. */
+ fun optional(vararg names: String): List = names.map { name ->
+ navArgument(name) {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ }
+ }
+
+ fun required(name: String): List = listOf(navArgument(name) { type = NavType.StringType })
+
+ val accountArgs get() = required(ACCOUNT_ID)
+ val accountEditorArgs get() = optional(ACCOUNT_ID)
+ val expenseEditorArgs get() = optional(EXPENSE_ID, EXPENSE_ACCOUNT_ID)
+ val expenseArgs get() = required(ExpenseDetailViewModel.ARG_EXPENSE_ID)
+ val settleArgs get() = optional(SettleArgs.ACCOUNT_ID, SettleArgs.SETTLEMENT_ID)
+ val settlementArgs get() = required(SettleArgs.SETTLEMENT_ID)
+ val drillDownArgs get() = optional(*DrillDownArgs.KEYS.toTypedArray())
+ val categoryEditorArgs get() = optional(CategoryEditorViewModel.ARG_CATEGORY_ID)
+ val templateEditorArgs get() = optional(RecurringEditorViewModel.ARG_TEMPLATE_ID)
+
+ private fun query(base: String, vararg params: Pair): String {
+ val present = params.filter { it.second != null }
+ return if (present.isEmpty()) base else base + "?" + present.joinToString("&") { (k, v) -> "$k=${encode(v.orEmpty())}" }
+ }
+
+ private fun encode(value: String): String = Uri.encode(value)
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt
new file mode 100644
index 0000000..55d858d
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt
@@ -0,0 +1,127 @@
+package se.hagstedt.kvitt.push
+
+import android.content.res.Resources
+import se.hagstedt.kvitt.adapter.fcm.PushEvent
+import se.hagstedt.kvitt.design.format.KvittLanguage
+import se.hagstedt.kvitt.design.format.MoneyFormatter
+import se.hagstedt.kvitt.link.OpenTarget
+import se.hagstedt.kvitt.model.User
+import se.hagstedt.kvitt.model.UserId
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+/** The local data a notification is built from (AC-13.5), read after the push's sync. */
+sealed interface PushSubject {
+ data class Expense(val description: String, val amountOre: Long, val accountName: String) : PushSubject
+
+ data class Settlement(val payer: User, val recipient: User, val amountOre: Long) : PushSubject
+}
+
+/** One notification: copy and where a tap leads. */
+data class NotificationContent(val title: String, val body: String, val target: OpenTarget)
+
+/** The push types of §14.6 (exhaustive for v1). */
+object PushTypes {
+ const val EXPENSE_CREATED = "expense_created"
+ const val EXPENSE_UPDATED = "expense_updated"
+ const val EXPENSE_DELETED = "expense_deleted"
+ const val SETTLEMENT_CREATED = "settlement_created"
+ const val SETTLEMENT_UPDATED = "settlement_updated"
+ const val SETTLEMENT_DELETED = "settlement_deleted"
+ const val RECURRING_EXPENSE_CREATED = "recurring_expense_created"
+ const val SYNC_BATCH = "sync_batch"
+
+ val ALL = setOf(
+ EXPENSE_CREATED, EXPENSE_UPDATED, EXPENSE_DELETED,
+ SETTLEMENT_CREATED, SETTLEMENT_UPDATED, SETTLEMENT_DELETED,
+ RECURRING_EXPENSE_CREATED, SYNC_BATCH,
+ )
+}
+
+/** Notification ids (§14.6 phone step 2): the hash of `entity_id`, so an update replaces the create; one id for batches. */
+object NotificationIds {
+ const val SYNC_BATCH = 1
+
+ fun of(event: PushEvent): Int = if (event.type == PushTypes.SYNC_BATCH) SYNC_BATCH else event.entityId.hashCode()
+}
+
+/**
+ * The §13.3 system-notification copy from `:core:design`'s strings in the phone's language (AC-13.5). The generic text
+ * (no amounts, no descriptions) when the sync failed, the entity or the actor is missing. Taps per AC-13.6: S-8 or S-10
+ * for creates and updates, Home for deletions, `sync_batch` and the generic text.
+ */
+class NotificationTexts(private val resources: Resources) {
+ private val language = KvittLanguage.of(resources.configuration.locales[0].language)
+
+ /** Null for a type outside §14.6 (sync only, phone step 4). */
+ fun build(event: PushEvent, synced: Boolean, actor: User?, viewer: UserId, subject: PushSubject?): NotificationContent? {
+ if (event.type !in PushTypes.ALL) return null
+ if (!synced) return generic(actor)
+ return when (event.type) {
+ PushTypes.SYNC_BATCH -> actor?.let {
+ NotificationContent(
+ resources.getQuantityString(DesignR.plurals.notif_sync_batch_title, event.count, it.displayName, event.count),
+ resources.getString(DesignR.string.notif_sync_batch_body),
+ OpenTarget.Home,
+ )
+ }
+ PushTypes.RECURRING_EXPENSE_CREATED -> (subject as? PushSubject.Expense)?.let {
+ NotificationContent(
+ resources.getString(DesignR.string.notif_recurring_created_title),
+ resources.getString(DesignR.string.notif_recurring_created_body, it.description, money(it.amountOre), it.accountName),
+ OpenTarget.Expense(event.entityId),
+ )
+ }
+ PushTypes.EXPENSE_CREATED, PushTypes.EXPENSE_UPDATED, PushTypes.EXPENSE_DELETED -> expense(event, actor, subject as? PushSubject.Expense)
+ else -> settlement(event, actor, viewer, subject as? PushSubject.Settlement)
+ } ?: generic(actor)
+ }
+
+ private fun expense(event: PushEvent, actor: User?, subject: PushSubject.Expense?): NotificationContent? {
+ if (actor == null || subject == null) return null
+ val amount = money(subject.amountOre)
+ return when (event.type) {
+ PushTypes.EXPENSE_CREATED -> NotificationContent(
+ resources.getString(DesignR.string.notif_expense_created_title, actor.displayName),
+ resources.getString(DesignR.string.notif_expense_created_body, subject.description, amount, subject.accountName),
+ OpenTarget.Expense(event.entityId),
+ )
+ PushTypes.EXPENSE_UPDATED -> NotificationContent(
+ resources.getString(DesignR.string.notif_expense_updated_title, actor.displayName),
+ resources.getString(DesignR.string.notif_expense_updated_body, subject.description, amount),
+ OpenTarget.Expense(event.entityId),
+ )
+ else -> NotificationContent(
+ resources.getString(DesignR.string.notif_expense_deleted_title, actor.displayName),
+ resources.getString(DesignR.string.notif_expense_deleted_body, subject.description, amount),
+ OpenTarget.Home,
+ )
+ }
+ }
+
+ private fun settlement(event: PushEvent, actor: User?, viewer: UserId, subject: PushSubject.Settlement?): NotificationContent? {
+ if (actor == null || subject == null) return null
+ val amount = money(subject.amountOre)
+ if (event.type == PushTypes.SETTLEMENT_DELETED) {
+ return NotificationContent(
+ resources.getString(DesignR.string.notif_settlement_deleted_title, actor.displayName),
+ resources.getString(DesignR.string.notif_settlement_deleted_body, amount),
+ OpenTarget.Home,
+ )
+ }
+ val title = if (event.type == PushTypes.SETTLEMENT_CREATED) DesignR.string.notif_settlement_created_title else DesignR.string.notif_settlement_updated_title
+ val body = when (viewer) {
+ subject.recipient.id -> resources.getString(DesignR.string.notif_settlement_body_to_you, subject.payer.displayName, amount)
+ subject.payer.id -> resources.getString(DesignR.string.notif_settlement_body_from_you, subject.recipient.displayName, amount)
+ else -> resources.getString(DesignR.string.notif_settlement_body, subject.payer.displayName, subject.recipient.displayName, amount)
+ }
+ return NotificationContent(resources.getString(title, actor.displayName), body, OpenTarget.Settlement(event.entityId))
+ }
+
+ private fun generic(actor: User?) = NotificationContent(
+ actor?.let { resources.getString(DesignR.string.notif_generic_title_actor, it.displayName) } ?: resources.getString(DesignR.string.notif_generic_title),
+ resources.getString(DesignR.string.notif_generic_body),
+ OpenTarget.Home,
+ )
+
+ private fun money(ore: Long): String = MoneyFormatter.format(ore, language)
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt
new file mode 100644
index 0000000..7d8d986
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt
@@ -0,0 +1,96 @@
+package se.hagstedt.kvitt.push
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import se.hagstedt.kvitt.adapter.fcm.PushEvent
+import se.hagstedt.kvitt.link.AppLinks
+import se.hagstedt.kvitt.model.ExpenseId
+import se.hagstedt.kvitt.model.ExpenseRepository
+import se.hagstedt.kvitt.model.ProfileRepository
+import se.hagstedt.kvitt.model.SessionRepository
+import se.hagstedt.kvitt.model.SessionState
+import se.hagstedt.kvitt.model.SettlementId
+import se.hagstedt.kvitt.model.SettlementRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+import se.hagstedt.kvitt.core.design.R as DesignR
+
+/** The notification channel of §14.6: id `activity`, name "Activity" / "Aktivitet", importance default. */
+object Notifications {
+ const val CHANNEL_ID = "activity"
+
+ /** Creates or renames the channel (the name follows the phone's language at every start). */
+ fun ensureChannel(context: Context) {
+ val channel = NotificationChannel(CHANNEL_ID, context.getString(DesignR.string.notif_channel_activity), NotificationManager.IMPORTANCE_DEFAULT)
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+}
+
+/**
+ * §14.6 phone step 2: builds the notification of a push from local data after its sync and posts it. Nothing is shown
+ * while the phone is not paired, for a type outside §14.6, or without the notification permission (AC-2.9).
+ */
+@Singleton
+class PushNotifier @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val session: SessionRepository,
+ private val profile: ProfileRepository,
+ private val expenses: ExpenseRepository,
+ private val settlements: SettlementRepository,
+) {
+ suspend fun show(event: PushEvent, synced: Boolean) {
+ if (event.type !in PushTypes.ALL) return
+ val me = (session.session.first() as? SessionState.Paired)?.me ?: return
+ val actor = profile.household().first().firstOrNull { it.id.value == event.actorUserId }
+ val subject = if (synced) subject(event) else null
+ val content = NotificationTexts(context.resources).build(event, synced, actor, me.id, subject) ?: return
+ post(NotificationIds.of(event), content)
+ }
+
+ private suspend fun subject(event: PushEvent): PushSubject? {
+ if (event.entityId.isEmpty()) return null
+ return when (event.entityType) {
+ ENTITY_EXPENSE -> expenses.detail(ExpenseId(event.entityId)).first()?.let {
+ PushSubject.Expense(it.description, it.amountOre, it.account.name)
+ }
+ ENTITY_SETTLEMENT -> settlements.detail(SettlementId(event.entityId)).first()?.let {
+ PushSubject.Settlement(it.from, it.to, it.amountOre)
+ }
+ else -> null
+ }
+ }
+
+ private fun post(id: Int, content: NotificationContent) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
+ val tap = PendingIntent.getActivity(
+ context,
+ id,
+ AppLinks.openIntent(context, content.target),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ val notification = NotificationCompat.Builder(context, Notifications.CHANNEL_ID)
+ .setSmallIcon(DesignR.drawable.ic_receipt_long)
+ .setContentTitle(content.title)
+ .setContentText(content.body)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(content.body))
+ .setCategory(NotificationCompat.CATEGORY_SOCIAL)
+ .setContentIntent(tap)
+ .setAutoCancel(true)
+ .build()
+ NotificationManagerCompat.from(context).notify(id, notification)
+ }
+
+ private companion object {
+ const val ENTITY_EXPENSE = "expense"
+ const val ENTITY_SETTLEMENT = "settlement"
+ }
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt
new file mode 100644
index 0000000..fa9f86b
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt
@@ -0,0 +1,93 @@
+package se.hagstedt.kvitt.push
+
+import android.content.Context
+import android.util.Log
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import se.hagstedt.kvitt.adapter.fcm.FcmRegistration
+import se.hagstedt.kvitt.adapter.fcm.PushEvent
+import se.hagstedt.kvitt.adapter.fcm.PushListener
+import se.hagstedt.kvitt.data.api.ApiResult
+import se.hagstedt.kvitt.data.api.KvittApi
+import se.hagstedt.kvitt.data.api.PushTokenRequest
+import se.hagstedt.kvitt.data.auth.CredentialStore
+import se.hagstedt.kvitt.data.auth.PairingStatus
+import se.hagstedt.kvitt.data.sync.SyncWork
+import se.hagstedt.kvitt.di.AppScope
+import se.hagstedt.kvitt.model.SessionState
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** `:adapter:fcm`'s listener: pushes go to [PushWorker] (or sync only), registrations to [PushTokens]. */
+@Singleton
+class PushHandler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val work: SyncWork,
+ private val tokens: PushTokens,
+) : PushListener {
+ override fun onPush(event: PushEvent?) {
+ // §14.6 phone step 4: another payload version or an unknown type syncs only.
+ if (event == null || event.type !in PushTypes.ALL) work.enqueueOneOff(expedited = true) else PushWorker.enqueue(context, event)
+ }
+
+ override fun onRegistered(installationId: String) = tokens.send(installationId)
+}
+
+/**
+ * AC-13.7: whenever the phone becomes paired (and at every start while paired) it registers with FCM; Firebase then
+ * reports the instance's id (`onRegistered`, also when it changes), which is sent as `fcm_token` with
+ * `PUT /devices/current/push-token` (D-087). AC-3.3: on `device_revoked` the instance is unregistered from FCM too.
+ * The id is never logged (§8.2).
+ */
+@Singleton
+class PushTokens @Inject constructor(
+ private val api: KvittApi,
+ private val credentials: CredentialStore,
+ @param:AppScope private val scope: CoroutineScope,
+) {
+ fun start(session: Flow) {
+ scope.launch {
+ session.map { it.key() }.distinctUntilChanged().collect { key ->
+ when (key) {
+ is SessionKey.Paired -> if (!FcmRegistration.register()) Log.w(TAG, "FCM registration failed")
+ SessionKey.Revoked -> if (!FcmRegistration.unregister()) Log.w(TAG, "FCM unregistration failed")
+ SessionKey.Other -> Unit
+ }
+ }
+ }
+ }
+
+ fun send(token: String) {
+ scope.launch { put(token) }
+ }
+
+ private suspend fun put(token: String) {
+ if (credentials.current().status != PairingStatus.PAIRED) return
+ when (val result = api.putPushToken(PushTokenRequest(token))) {
+ is ApiResult.Ok -> Unit
+ is ApiResult.Failure -> Log.w(TAG, "push token not registered: ${result.status} ${result.code}")
+ is ApiResult.NetworkError -> Log.w(TAG, "push token not registered: network")
+ ApiResult.NotPaired -> Unit
+ }
+ }
+
+ private sealed interface SessionKey {
+ data class Paired(val deviceId: String) : SessionKey
+ data object Revoked : SessionKey
+ data object Other : SessionKey
+ }
+
+ private fun SessionState.key(): SessionKey = when (this) {
+ is SessionState.Paired -> SessionKey.Paired(deviceId.value)
+ SessionState.Revoked -> SessionKey.Revoked
+ else -> SessionKey.Other
+ }
+
+ private companion object {
+ const val TAG = "KvittPush"
+ }
+}
diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt
new file mode 100644
index 0000000..addd414
--- /dev/null
+++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt
@@ -0,0 +1,67 @@
+package se.hagstedt.kvitt.push
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.ListenableWorker
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkManager
+import androidx.work.WorkerFactory
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import se.hagstedt.kvitt.adapter.fcm.PushEvent
+import se.hagstedt.kvitt.data.sync.SyncTriggers
+
+/**
+ * One push (§14.6 phone steps 1 and 2): an expedited sync waited for up to 20 seconds (`SyncTriggers.syncForPush`,
+ * AC-13.5), then the notification from local data, or the generic text when the sync did not finish in time.
+ */
+class PushWorker(
+ context: Context,
+ params: WorkerParameters,
+ private val triggers: SyncTriggers,
+ private val notifier: PushNotifier,
+) : CoroutineWorker(context, params) {
+ override suspend fun doWork(): Result {
+ val event = inputData.toEvent() ?: return Result.success()
+ val synced = triggers.syncForPush()
+ notifier.show(event, synced)
+ return Result.success()
+ }
+
+ companion object {
+ private const val KEY_TYPE = "type"
+ private const val KEY_ENTITY_TYPE = "entity_type"
+ private const val KEY_ENTITY_ID = "entity_id"
+ private const val KEY_ACTOR = "actor_user_id"
+ private const val KEY_COUNT = "count"
+
+ /** Expedited one-off work; without quota it runs as normal work (the sync still runs, the wait is the same). */
+ fun enqueue(context: Context, event: PushEvent) {
+ val request = OneTimeWorkRequestBuilder()
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .setInputData(
+ workDataOf(
+ KEY_TYPE to event.type,
+ KEY_ENTITY_TYPE to event.entityType,
+ KEY_ENTITY_ID to event.entityId,
+ KEY_ACTOR to event.actorUserId,
+ KEY_COUNT to event.count,
+ ),
+ )
+ .build()
+ WorkManager.getInstance(context).enqueue(request)
+ }
+
+ private fun androidx.work.Data.toEvent(): PushEvent? {
+ val type = getString(KEY_TYPE) ?: return null
+ return PushEvent(type, getString(KEY_ENTITY_TYPE).orEmpty(), getString(KEY_ENTITY_ID).orEmpty(), getString(KEY_ACTOR).orEmpty(), getInt(KEY_COUNT, 1))
+ }
+ }
+}
+
+/** Creates [PushWorker]s; `:app`'s WorkManager configuration delegates to it beside `:data`'s `KvittWorkerFactory`. */
+class PushWorkerFactory(private val triggers: () -> SyncTriggers, private val notifier: () -> PushNotifier) : WorkerFactory() {
+ override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker? =
+ if (workerClassName == PushWorker::class.java.name) PushWorker(appContext, workerParameters, triggers(), notifier()) else null
+}
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..86e2f95
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/release/kotlin/se/hagstedt/kvitt/release/ReleaseDataModule.kt b/android/app/src/release/kotlin/se/hagstedt/kvitt/release/ReleaseDataModule.kt
deleted file mode 100644
index 6f69aab..0000000
--- a/android/app/src/release/kotlin/se/hagstedt/kvitt/release/ReleaseDataModule.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package se.hagstedt.kvitt.release
-
-import dagger.Module
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import se.hagstedt.kvitt.data.di.DataBindingsModule
-
-/**
- * Release builds only (src/release): binds every `:core:model` repository interface to its `:data` implementation by
- * including [DataBindingsModule] (`@DisableInstallInCheck`, D-041). Debug binds the fakes instead (`DebugFakesModule`).
- */
-@Module(includes = [DataBindingsModule::class])
-@InstallIn(SingletonComponent::class)
-interface ReleaseDataModule
diff --git a/android/app/src/releasePinnedRoot/res/xml/network_security_config.xml b/android/app/src/releasePinnedRoot/res/xml/network_security_config.xml
new file mode 100644
index 0000000..764bd77
--- /dev/null
+++ b/android/app/src/releasePinnedRoot/res/xml/network_security_config.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+ icehack.asuscomm.com
+
+
+
+
+
diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt
index 56ba91a..d89bdb8 100644
--- a/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt
+++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt
@@ -1,5 +1,6 @@
package se.hagstedt.kvitt
+import android.app.Application
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
@@ -18,7 +19,7 @@ import se.hagstedt.kvitt.design.KvittTheme
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi")
+@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi", application = Application::class)
class KvittEmptyScreenScreenshotTest {
@get:Rule
val compose = createComposeRule()
diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt
new file mode 100644
index 0000000..a603b95
--- /dev/null
+++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt
@@ -0,0 +1,73 @@
+package se.hagstedt.kvitt.link
+
+import android.app.Application
+import android.content.Intent
+import android.net.Uri
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+/** AC-2.6 invite links and AC-13.6 notification taps as `MainActivity` receives them. */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36], application = Application::class)
+class AppLinksTest {
+ @Test
+ fun appLinkFillsServerAndCode() {
+ val link = AppLinks.parseUri("kvitt://invite?server=http%3A%2F%2F192.168.1.210%3A8080&code=ABCD-EFGH-JKMN")
+ assertEquals(AppLink.Invite("http://192.168.1.210:8080", "ABCDEFGHJKMN"), link)
+ }
+
+ @Test
+ fun appLinkNormalisesTheCode() {
+ // §8.3: lowercase, O -> 0, I and L -> 1.
+ assertEquals("ABCD0113EFGH", AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=abcdo1l3efgh")?.code)
+ }
+
+ @Test
+ fun landingLinkFillsTheServerFromTheLink() {
+ val link = AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN")
+ assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), link)
+ }
+
+ @Test
+ fun otherLinksAreIgnored() {
+ assertNull(AppLinks.parseUri(null))
+ assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/apk/latest"))
+ assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/SHORT"))
+ assertNull(AppLinks.parseUri("kvitt://invite?server=x"))
+ assertNull(AppLinks.parseUri("kvitt://other?code=ABCDEFGHJKMN"))
+ // A bare code is a paste on S-1, not a link.
+ assertNull(AppLinks.parseUri("ABCDEFGHJKMN"))
+ }
+
+ @Test
+ fun viewIntentIsParsed() {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://icehack.asuscomm.com:8443/i/ABCD-EFGH-JKMN"))
+ assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), AppLinks.parse(intent))
+ }
+
+ @Test
+ fun launcherIntentIsNoLink() {
+ assertNull(AppLinks.parse(Intent(Intent.ACTION_MAIN)))
+ assertNull(AppLinks.parse(null))
+ }
+
+ @Test
+ fun notificationTapsRoundTrip() {
+ val context = RuntimeEnvironment.getApplication()
+ listOf(OpenTarget.Home, OpenTarget.Expense("e-1"), OpenTarget.Settlement("s-1")).forEach { target ->
+ assertEquals(AppLink.Open(target), AppLinks.parse(AppLinks.openIntent(context, target)))
+ }
+ }
+
+ @Test
+ fun tapWithoutIdIsNoLink() {
+ assertNull(AppLinks.parseOpen("expense", null))
+ assertNull(AppLinks.parseOpen("settlement", ""))
+ assertNull(AppLinks.parseOpen("unknown", "x"))
+ }
+}
diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt
new file mode 100644
index 0000000..fb187f3
--- /dev/null
+++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt
@@ -0,0 +1,192 @@
+package se.hagstedt.kvitt.push
+
+import android.app.Application
+import android.content.res.Resources
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import se.hagstedt.kvitt.adapter.fcm.PushEvent
+import se.hagstedt.kvitt.link.OpenTarget
+import se.hagstedt.kvitt.model.Role
+import se.hagstedt.kvitt.model.User
+import se.hagstedt.kvitt.model.UserId
+
+private val JACOB = User(UserId("0192-jacob"), Role.ADMIN, "Jacob", null)
+private val ANNA = User(UserId("0192-anna"), Role.MEMBER, "Anna", null)
+private const val NBSP = ' '
+
+private fun event(type: String, entityType: String = "expense", id: String = "e-1", count: Int = 1) =
+ PushEvent(type, entityType, id, ANNA.id.value, count)
+
+private val groceries = PushSubject.Expense("ICA Maxi", 123_450, "Vardag")
+
+/** §13.3 notification copy in English (the default resources), the generic fallback and the tap targets. */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36], application = Application::class)
+class NotificationTextsTest {
+ private val texts = NotificationTexts(RuntimeEnvironment.getApplication().resources)
+
+ private fun build(event: PushEvent, subject: PushSubject?, synced: Boolean = true, actor: User? = ANNA, viewer: UserId = JACOB.id) =
+ texts.build(event, synced, actor, viewer, subject)
+
+ @Test
+ fun expenseCreated() {
+ assertEquals(
+ NotificationContent("Anna added an expense", "ICA Maxi · SEK${NBSP}1,234.50 · Vardag", OpenTarget.Expense("e-1")),
+ build(event(PushTypes.EXPENSE_CREATED), groceries),
+ )
+ }
+
+ @Test
+ fun expenseUpdated() {
+ assertEquals(
+ NotificationContent("Anna changed an expense", "ICA Maxi · SEK${NBSP}1,234.50", OpenTarget.Expense("e-1")),
+ build(event(PushTypes.EXPENSE_UPDATED), groceries),
+ )
+ }
+
+ @Test
+ fun expenseDeletedOpensHome() {
+ assertEquals(
+ NotificationContent("Anna deleted an expense", "ICA Maxi · SEK${NBSP}1,234.50", OpenTarget.Home),
+ build(event(PushTypes.EXPENSE_DELETED), groceries),
+ )
+ }
+
+ @Test
+ fun settlementCreatedToTheViewer() {
+ val subject = PushSubject.Settlement(payer = ANNA, recipient = JACOB, amountOre = 50_000)
+ assertEquals(
+ NotificationContent("Anna recorded a payment", "Anna paid you SEK${NBSP}500.00", OpenTarget.Settlement("s-1")),
+ build(event(PushTypes.SETTLEMENT_CREATED, "settlement", "s-1"), subject),
+ )
+ }
+
+ @Test
+ fun settlementUpdatedFromTheViewer() {
+ val subject = PushSubject.Settlement(payer = JACOB, recipient = ANNA, amountOre = 50_000)
+ assertEquals(
+ NotificationContent("Anna changed a payment", "You paid Anna SEK${NBSP}500.00", OpenTarget.Settlement("s-1")),
+ build(event(PushTypes.SETTLEMENT_UPDATED, "settlement", "s-1"), subject),
+ )
+ }
+
+ @Test
+ fun settlementBodyWithoutTheViewer() {
+ val subject = PushSubject.Settlement(payer = ANNA, recipient = JACOB, amountOre = 50_000)
+ assertEquals("Anna paid Jacob SEK${NBSP}500.00", build(event(PushTypes.SETTLEMENT_CREATED, "settlement", "s-1"), subject, viewer = UserId("other"))?.body)
+ }
+
+ @Test
+ fun settlementDeletedOpensHome() {
+ val subject = PushSubject.Settlement(payer = ANNA, recipient = JACOB, amountOre = 50_000)
+ assertEquals(
+ NotificationContent("Anna deleted a payment", "SEK${NBSP}500.00", OpenTarget.Home),
+ build(event(PushTypes.SETTLEMENT_DELETED, "settlement", "s-1"), subject),
+ )
+ }
+
+ @Test
+ fun recurringHasNoActor() {
+ assertEquals(
+ NotificationContent("Recurring expense added", "ICA Maxi · SEK${NBSP}1,234.50 · Vardag", OpenTarget.Expense("e-1")),
+ build(event(PushTypes.RECURRING_EXPENSE_CREATED), groceries, actor = null),
+ )
+ }
+
+ @Test
+ fun syncBatchCountsAndOpensHome() {
+ assertEquals(
+ NotificationContent("Anna made 5 changes", "Open Kvitt to see them", OpenTarget.Home),
+ build(event(PushTypes.SYNC_BATCH, "", "", count = 5), null),
+ )
+ }
+
+ @Test
+ fun failedSyncGivesTheGenericTextWithoutContent() {
+ assertEquals(
+ NotificationContent("Anna changed something in Kvitt", "Open Kvitt to see it", OpenTarget.Home),
+ build(event(PushTypes.EXPENSE_CREATED), groceries, synced = false),
+ )
+ assertEquals(
+ NotificationContent("Anna changed something in Kvitt", "Open Kvitt to see it", OpenTarget.Home),
+ build(event(PushTypes.SYNC_BATCH, "", "", count = 5), null, synced = false),
+ )
+ }
+
+ @Test
+ fun missingEntityGivesTheGenericText() {
+ assertEquals("Anna changed something in Kvitt", build(event(PushTypes.EXPENSE_UPDATED), null)?.title)
+ assertEquals("Anna changed something in Kvitt", build(event(PushTypes.SETTLEMENT_CREATED, "settlement", "s-1"), groceries)?.title)
+ }
+
+ @Test
+ fun unknownActorGivesKvitt() {
+ assertEquals(NotificationContent("Kvitt", "Open Kvitt to see it", OpenTarget.Home), build(event(PushTypes.EXPENSE_CREATED), groceries, actor = null))
+ }
+
+ @Test
+ fun unknownTypeShowsNothing() {
+ assertNull(build(event("future_type"), groceries))
+ }
+}
+
+/** The same copy in Swedish (§13.3, `values-sv` of `:core:design`). */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36], qualifiers = "sv", application = Application::class)
+class NotificationTextsSwedishTest {
+ private val resources: Resources = RuntimeEnvironment.getApplication().resources
+ private val texts = NotificationTexts(resources)
+
+ @Test
+ fun expenseCreated() {
+ assertEquals(
+ NotificationContent("Anna lade till en utgift", "ICA Maxi · 1${NBSP}234,50${NBSP}kr · Vardag", OpenTarget.Expense("e-1")),
+ texts.build(event(PushTypes.EXPENSE_CREATED), true, ANNA, JACOB.id, groceries),
+ )
+ }
+
+ @Test
+ fun settlementToTheViewer() {
+ val subject = PushSubject.Settlement(payer = ANNA, recipient = JACOB, amountOre = 50_000)
+ assertEquals("Anna betalade dig 500,00${NBSP}kr", texts.build(event(PushTypes.SETTLEMENT_CREATED, "settlement", "s-1"), true, ANNA, JACOB.id, subject)?.body)
+ }
+
+ @Test
+ fun genericText() {
+ assertEquals(
+ NotificationContent("Anna ändrade något i Kvitt", "Öppna Kvitt för att se det", OpenTarget.Home),
+ texts.build(event(PushTypes.EXPENSE_CREATED), false, ANNA, JACOB.id, null),
+ )
+ }
+
+ @Test
+ fun syncBatch() {
+ assertEquals("Öppna Kvitt för att se dem", texts.build(event(PushTypes.SYNC_BATCH, "", "", count = 4), true, ANNA, JACOB.id, null)?.body)
+ }
+}
+
+/** §14.6 phone step 2: the id is the hash of `entity_id`; `sync_batch` has one fixed id. */
+class NotificationIdsTest {
+ @Test
+ fun updateReplacesCreate() {
+ assertEquals(NotificationIds.of(event(PushTypes.EXPENSE_CREATED, id = "e-1")), NotificationIds.of(event(PushTypes.EXPENSE_UPDATED, id = "e-1")))
+ assertEquals("e-1".hashCode(), NotificationIds.of(event(PushTypes.EXPENSE_DELETED, id = "e-1")))
+ }
+
+ @Test
+ fun entitiesDiffer() {
+ assertNotEquals(NotificationIds.of(event(PushTypes.EXPENSE_CREATED, id = "e-1")), NotificationIds.of(event(PushTypes.EXPENSE_CREATED, id = "e-2")))
+ }
+
+ @Test
+ fun syncBatchIsFixed() {
+ assertEquals(NotificationIds.SYNC_BATCH, NotificationIds.of(event(PushTypes.SYNC_BATCH, "", "", count = 4)))
+ assertEquals(NotificationIds.SYNC_BATCH, NotificationIds.of(event(PushTypes.SYNC_BATCH, "", "", count = 9)))
+ }
+}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index d4496d1..c291bf0 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -28,11 +28,16 @@ vico = "3.3.1"
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
+firebaseBom = "35.0.2"
+googleServices = "4.5.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
@@ -65,6 +70,8 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" }
+firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
+firebase-messaging = { module = "com.google.firebase:firebase-messaging" }
junit = { module = "junit:junit", version.ref = "junit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
@@ -84,4 +91,5 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room3 = { id = "androidx.room3", version.ref = "room3" }
+google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }