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