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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user