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
@@ -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()
@@ -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"))
}
}
@@ -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)))
}
}