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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:32:55 +02:00
parent 5b2ed37f65
commit 2bd6b584f8
7 changed files with 229 additions and 21 deletions
+5
View File
@@ -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
@@ -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<Boolean> = 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
}
@@ -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 {
@@ -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<SessionState>) {
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<String?>(null)
/** The token waiting for a retry, null when none. */
val pending: StateFlow<String?> = 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) }
}
}
@@ -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)
}
}
@@ -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)) }
}
}
@@ -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<String>()
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<String>()
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)
}
}