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