M8: app integration — navigation, :data bindings in both variants, FCM adapter, push notifications, hardening

:app binds every :core:model interface to :data in debug and release (AppDataModule, D-085); the
debug fakes module is gone. NavHost over all feature screens with the session gate (S-1, S-2, S-18),
bottom destinations from each screen's own bar, result snackbars, deep links (kvitt://invite and the
/i/<CODE> landing link) and notification taps. :adapter:fcm maps payload v1 to PushEvent and registers
by Firebase Installation ID (firebase-messaging 26, D-087). Push work runs in WorkManager: expedited
sync waited up to 20 s, then the notification from local data or the generic text. WorkManager uses
KvittWorkerFactory, Coil uses :data's ImageLoader, SyncTriggers on start and foreground, update check
in release only. Network security configs (debug cleartext, D-086; pinned-root variant), release
signing from local.properties, notification permission after the first sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 11:30:57 +02:00
parent 3dda414ec2
commit cb832a7cac
31 changed files with 1762 additions and 86 deletions
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- firebase-messaging 26: register an app instance by its Firebase Installation ID (D-087). -->
<meta-data
android:name="firebase_messaging_installation_id_enabled"
android:value="true" />
<!-- SPEC §8.8: the FCM service is reached through Firebase's own receiver; not exported (Firebase's sample). -->
<service
android:name="se.hagstedt.kvitt.adapter.fcm.KvittMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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
}
@@ -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 <T> Task<T>.awaitCompletion(): Task<T> = suspendCancellableCoroutine { continuation ->
addOnCompleteListener { task -> continuation.resume(task) }
}
}
@@ -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)
}
}
@@ -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<String, String>): 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,
)
}
}
@@ -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)
}
}