From 2bd6b584f8e5533a74c39c22eb4d9e617a9154ca Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 22:32:55 +0200 Subject: [PATCH] M12f: :app invite links dropped while paired, push-token retry, private notifications, en/sv only (D-112, D-116) - AppViewModel.onLink drops an invite link while paired (AC-2.6, D-084), so it never prefills S-1 later. - PushTokens: a failed E-17 PUT (network, 429, 5xx) is kept by PushTokenRetry and sent again after each successful sync until accepted; a newer FID replaces it (AC-13.7). - Notifications are VISIBILITY_PRIVATE with a generic public version (notif_generic_title, notif_generic_body): no description, amount, account or name on a secure lock screen (security review L-7). - androidResources.localeFilters en, sv (SPEC G-8, AC-19.1). - Tests: AppViewModelTest (links while paired and unpaired, AC-2.9 permission after the first finished successful sync), PushTokenRetryTest, NotificationPrivacyTest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/app/build.gradle.kts | 5 ++ .../kotlin/se/hagstedt/kvitt/AppViewModel.kt | 6 +- .../se/hagstedt/kvitt/push/Notifications.kt | 37 +++++++--- .../se/hagstedt/kvitt/push/PushHandler.kt | 62 +++++++++++++--- .../se/hagstedt/kvitt/AppViewModelTest.kt | 70 +++++++++++++++++++ .../kvitt/push/NotificationPrivacyTest.kt | 34 +++++++++ .../hagstedt/kvitt/push/PushTokenRetryTest.kt | 36 ++++++++++ 7 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt create mode 100644 android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationPrivacyTest.kt create mode 100644 android/app/src/test/kotlin/se/hagstedt/kvitt/push/PushTokenRetryTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7293162..7f77b93 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -28,6 +28,11 @@ android { versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt() versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get() } + // SPEC G-8 and AC-19.1: the app is English (fallback) and Swedish only; library translations of other languages + // are left out of the APK. + androidResources { + localeFilters += listOf("en", "sv") + } // BuildConfig carries versionCode, versionName and DEBUG into AppBuildInfo (:data, D-041). buildFeatures { buildConfig = true diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt index 8c80073..efc68bc 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt @@ -44,9 +44,13 @@ class AppViewModel @Inject constructor( /** 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() + /** + * AC-2.6 (D-084): an invite link opened while paired is dropped, not kept, so it cannot prefill S-1 later after an + * unpairing or a revoke. + */ fun onLink(link: AppLink?) { when (link) { - is AppLink.Invite -> inviteState.value = link + is AppLink.Invite -> inviteState.value = if (session.value is SessionState.Paired) null else link is AppLink.Open -> openState.value = link.target null -> Unit } 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 index 9f6f601..b248985 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt @@ -1,6 +1,7 @@ package se.hagstedt.kvitt.push import android.Manifest +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -33,6 +34,31 @@ object Notifications { val channel = NotificationChannel(CHANNEL_ID, context.getString(DesignR.string.notif_channel_activity), NotificationManager.IMPORTANCE_DEFAULT) context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } + + /** + * The notification of [content]. It is private (D-116): on a secure lock screen Android shows only the public + * version, the generic "Kvitt" / "Open Kvitt to see it" (`notif_generic_title`, `notif_generic_body`), never a + * description, an amount, an account or a name. + */ + fun build(context: Context, content: NotificationContent, tap: PendingIntent?): Notification { + val public = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(DesignR.drawable.ic_receipt_long) + .setContentTitle(context.getString(DesignR.string.notif_generic_title)) + .setContentText(context.getString(DesignR.string.notif_generic_body)) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .build() + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(DesignR.drawable.ic_receipt_long) + .setContentTitle(content.title) + .setContentText(content.body) + .setStyle(NotificationCompat.BigTextStyle().bigText(content.body)) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(public) + .setContentIntent(tap) + .setAutoCancel(true) + .build() + } } /** @@ -77,16 +103,7 @@ class PushNotifier @Inject constructor( 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) + NotificationManagerCompat.from(context).notify(id, Notifications.build(context, content, tap)) } private companion object { 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 index 0015c13..7a09271 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt @@ -5,7 +5,11 @@ import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import se.hagstedt.kvitt.adapter.fcm.FcmRegistration @@ -19,6 +23,7 @@ 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 se.hagstedt.kvitt.model.SyncRepository import javax.inject.Inject import javax.inject.Singleton @@ -40,15 +45,19 @@ class PushHandler @Inject constructor( /** * 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). + * `PUT /devices/current/push-token` (D-087). A send that fails for a reason worth retrying is sent again after each + * successful sync cycle until it is accepted ([PushTokenRetry], audit AC-13.7). 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, + private val sync: SyncRepository, @param:AppScope private val scope: CoroutineScope, ) { + private val retry = PushTokenRetry(::put) + fun start(session: Flow) { scope.launch { session.map { it.key() }.distinctUntilChanged().collect { key -> @@ -59,19 +68,28 @@ class PushTokens @Inject constructor( } } } + scope.launch { + sync.status().map { it.summary.lastSuccessAt }.distinctUntilChanged().drop(1).collect { if (it != null) retry.retry() } + } } fun send(token: String) { - scope.launch { put(token) } + scope.launch { retry.send(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 suspend fun put(token: String): PushTokenRetry.Outcome { + if (credentials.current().status != PairingStatus.PAIRED) return PushTokenRetry.Outcome.Done + return when (val result = api.putPushToken(PushTokenRequest(token))) { + is ApiResult.Ok -> PushTokenRetry.Outcome.Done + is ApiResult.Failure -> { + Log.w(TAG, "push token not registered: ${result.status} ${result.code}") + if (result.status == HTTP_TOO_MANY_REQUESTS || result.status >= HTTP_SERVER_ERROR) PushTokenRetry.Outcome.Retry else PushTokenRetry.Outcome.Done + } + is ApiResult.NetworkError -> { + Log.w(TAG, "push token not registered: network") + PushTokenRetry.Outcome.Retry + } + ApiResult.NotPaired -> PushTokenRetry.Outcome.Done } } @@ -89,5 +107,29 @@ class PushTokens @Inject constructor( private companion object { const val TAG = "KvittPush" + const val HTTP_TOO_MANY_REQUESTS = 429 + const val HTTP_SERVER_ERROR = 500 + } +} + +/** + * The E-17 send with its retry: [put] sends one token and says whether a failure is worth retrying (network, 429, + * 5xx); such a token is kept and [retry] (after a successful sync) sends it again. A newer token replaces it. + */ +class PushTokenRetry(private val put: suspend (String) -> Outcome) { + enum class Outcome { Done, Retry } + + private val unsent = MutableStateFlow(null) + + /** The token waiting for a retry, null when none. */ + val pending: StateFlow = unsent.asStateFlow() + + suspend fun send(token: String) { + unsent.value = token + if (put(token) == Outcome.Done) unsent.compareAndSet(token, null) + } + + suspend fun retry() { + unsent.value?.let { send(it) } } } diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt new file mode 100644 index 0000000..6d4e2a6 --- /dev/null +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt @@ -0,0 +1,70 @@ +package se.hagstedt.kvitt + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import se.hagstedt.kvitt.fakes.FakeRepositories +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.FakeScenarios +import se.hagstedt.kvitt.link.AppLink +import se.hagstedt.kvitt.model.SessionState + +/** The shell's link and permission rules: AC-2.6 (D-084) and AC-2.9. A plain JVM test over the fakes. */ +@OptIn(ExperimentalCoroutinesApi::class) +class AppViewModelTest { + @Before + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts) + + private val link = AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCD2345EFGH", "Jacob") + + @Test + fun anInviteLinkWhilePaired_isDropped_andNeverPrefillsALaterPairing() { + val fakes = FakeRepositories(FakeScenarios.empty) + val vm = vm(fakes) + assertTrue(vm.session.value is SessionState.Paired) + vm.onLink(link) + assertNull(vm.invite.value) + fakes.store.edit { it.copy(session = SessionState.Revoked) } + assertEquals(SessionState.Revoked, vm.session.value) + assertNull("a link from the paired time does not come back", vm.invite.value) + } + + @Test + fun anInviteLinkWhileUnpaired_prefillsThePairingScreen() { + val vm = vm(FakeRepositories(FakeScenarios.empty.copy(session = SessionState.Unpaired))) + vm.onLink(link) + assertEquals(link, vm.invite.value) + vm.onPaired() + assertNull("consumed by the pairing", vm.invite.value) + } + + @Test + fun notificationPermission_isAskedOnlyAfterTheFirstSuccessfulSyncAfterPairing() { + val first: FakeScenario = FakeScenarios.empty.copy(lastSuccessAt = null, syncRunning = true) + val fakes = FakeRepositories(first) + val vm = vm(fakes) + assertFalse(vm.askNotifications.value) + vm.onPaired() + assertFalse("the first sync is still running", vm.askNotifications.value) + fakes.store.edit { it.copy(lastSuccessAt = "2026-10-17T12:00:00.000Z") } + assertFalse("success recorded but the cycle still runs", vm.askNotifications.value) + fakes.store.edit { it.copy(syncRunning = false) } + assertTrue(vm.askNotifications.value) + vm.notificationsAsked() + assertFalse(vm.askNotifications.value) + } +} diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationPrivacyTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationPrivacyTest.kt new file mode 100644 index 0000000..0f7a57c --- /dev/null +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationPrivacyTest.kt @@ -0,0 +1,34 @@ +package se.hagstedt.kvitt.push + +import android.app.Application +import android.app.Notification +import androidx.core.app.NotificationCompat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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.link.OpenTarget + +/** Security review L-7 (D-116): the lock screen shows only the generic public version, never the expense. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], application = Application::class) +class NotificationPrivacyTest { + private val context = RuntimeEnvironment.getApplication() + + @Test + fun notificationsArePrivate_withAGenericPublicVersion() { + val content = NotificationContent("Anna added an expense", "ICA Maxi · SEK 1,234.50 · Vardag", OpenTarget.Expense("e-1")) + val n = Notifications.build(context, content, null) + assertEquals(NotificationCompat.VISIBILITY_PRIVATE, n.visibility) + assertEquals("Anna added an expense", n.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) + val public = requireNotNull(n.publicVersion) + val title = public.extras.getCharSequence(Notification.EXTRA_TITLE).toString() + val text = public.extras.getCharSequence(Notification.EXTRA_TEXT).toString() + assertEquals("Kvitt", title) + assertEquals("Open Kvitt to see it", text) + listOf("ICA", "1,234.50", "Vardag", "Anna").forEach { assertFalse(it, (title + text).contains(it)) } + } +} diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/push/PushTokenRetryTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/PushTokenRetryTest.kt new file mode 100644 index 0000000..41e597b --- /dev/null +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/PushTokenRetryTest.kt @@ -0,0 +1,36 @@ +package se.hagstedt.kvitt.push + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import se.hagstedt.kvitt.push.PushTokenRetry.Outcome + +/** Audit AC-13.7: a failed E-17 send is retried after the next successful sync, until accepted. */ +class PushTokenRetryTest { + @Test + fun aRetryableFailure_isSentAgainOnRetry_untilAccepted() = runTest { + val sent = mutableListOf() + val answers = ArrayDeque(listOf(Outcome.Retry, Outcome.Retry, Outcome.Done)) + val retry = PushTokenRetry { sent += it; answers.removeFirst() } + retry.send("fid-1") + assertEquals("fid-1", retry.pending.value) + retry.retry() + assertEquals("fid-1", retry.pending.value) + retry.retry() + assertNull(retry.pending.value) + retry.retry() + assertEquals(listOf("fid-1", "fid-1", "fid-1"), sent) + } + + @Test + fun aDefinitiveAnswer_isNotRetried_andANewerTokenReplacesTheOldOne() = runTest { + val sent = mutableListOf() + val retry = PushTokenRetry { sent += it; if (it == "old") Outcome.Retry else Outcome.Done } + retry.send("old") + retry.send("new") + assertNull(retry.pending.value) + retry.retry() + assertEquals(listOf("old", "new"), sent) + } +}