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
+6 -1
View File
@@ -1,8 +1,13 @@
// :adapter:fcm — Firebase Cloud Messaging, the only module that names the vendor (owned by M8). M2b ships the shell.
// :adapter:fcm — Firebase Cloud Messaging, the only module that names the vendor (SPEC §6.2, owned by M8): the
// FirebaseMessagingService, the FCM token calls, and the mapping of data messages to the project's own PushEvent.
plugins {
id("kvitt.android.library")
id("kvitt.android.hilt")
}
dependencies {
implementation(project(":core:model"))
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.kotlinx.coroutines.core)
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- firebase-messaging 26: register an app instance by its Firebase Installation ID (D-087). -->
<meta-data
android:name="firebase_messaging_installation_id_enabled"
android:value="true" />
<!-- SPEC §8.8: the FCM service is reached through Firebase's own receiver; not exported (Firebase's sample). -->
<service
android:name="se.hagstedt.kvitt.adapter.fcm.KvittMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -1,10 +0,0 @@
package se.hagstedt.kvitt.adapter.fcm
/**
* Module shell. M8 adds the FirebaseMessagingService, token registration and the mapping of data messages to the
* project's own push events (SPEC §6.2, §14.6), once the Firebase licence and google-services.json are in place.
*/
internal object FcmAdapter {
/** Push payload version this adapter understands (SPEC §14.6). */
const val PAYLOAD_VERSION: Int = 1
}
@@ -0,0 +1,23 @@
package se.hagstedt.kvitt.adapter.fcm
import com.google.android.gms.tasks.Task
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* FCM registration (AC-13.7, AC-3.3). Since firebase-messaging 26 an app instance is addressed by its Firebase
* Installation ID (FID): [register] makes Firebase call `KvittMessagingService.onRegistered` with it, which `:app`
* sends as the device's `fcm_token` (E-17; the HTTP v1 `token` field the server fills accepts an FID, D-087).
*/
object FcmRegistration {
/** Registers this app instance; the FID arrives through [PushListener.onRegistered]. True on success. */
suspend fun register(): Boolean = FirebaseMessaging.getInstance().register().awaitCompletion().isSuccessful
/** Unregisters this app instance, so a revoked device stops receiving pushes on this install. True on success. */
suspend fun unregister(): Boolean = FirebaseMessaging.getInstance().unregister().awaitCompletion().isSuccessful
private suspend fun <T> Task<T>.awaitCompletion(): Task<T> = suspendCancellableCoroutine { continuation ->
addOnCompleteListener { task -> continuation.resume(task) }
}
}
@@ -0,0 +1,25 @@
package se.hagstedt.kvitt.adapter.fcm
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Receives Kvitt's data-only messages (§14.6) and registrations (AC-13.7) and hands them to `:app` at once. The work
* itself (a sync waited for up to 20 s, then the notification) runs in WorkManager, since Firebase gives
* `onMessageReceived` only a short execution window.
*/
@AndroidEntryPoint
class KvittMessagingService : FirebaseMessagingService() {
@Inject
lateinit var listener: PushListener
override fun onMessageReceived(message: RemoteMessage) {
listener.onPush(PushMapping.map(message.data))
}
override fun onRegistered(installationId: String) {
listener.onRegistered(installationId)
}
}
@@ -0,0 +1,44 @@
package se.hagstedt.kvitt.adapter.fcm
/**
* One push of payload v1 (SPEC §14.6), as the project's own record. [type] is kept as the open string the server sent
* so `:app` can treat an unknown type as "sync only" (§14.6 phone step 4). Empty [entityType] and [entityId] for
* `sync_batch`. No personal content ever reaches this record (AC-13.8).
*
* SPEC §6.2 places `PushEvent` in `:core:model`; it is not there at the M8 start, so it lives here until a contract
* change moves it (contract request in the M8 report).
*/
data class PushEvent(
val type: String,
val entityType: String,
val entityId: String,
val actorUserId: String,
val count: Int,
)
/** What `:app` does with FCM: handle a push, and register this instance's id (AC-13.7). Bound by `:app` through Hilt. */
interface PushListener {
/** A data message. [event] is null when it is not payload v1 (`v` other than `1`): sync only, no notification. */
fun onPush(event: PushEvent?)
/** Firebase registered this app instance, at first or with a new Firebase Installation ID ([FcmRegistration]). */
fun onRegistered(installationId: String)
}
/** Maps an FCM data map (all values strings, §14.6) to a [PushEvent]. */
object PushMapping {
const val PAYLOAD_VERSION = "1"
/** Null for a message of another payload version or without a `type`. */
fun map(data: Map<String, String>): PushEvent? {
if (data["v"] != PAYLOAD_VERSION) return null
val type = data["type"]?.takeIf { it.isNotEmpty() } ?: return null
return PushEvent(
type = type,
entityType = data["entity_type"].orEmpty(),
entityId = data["entity_id"].orEmpty(),
actorUserId = data["actor_user_id"].orEmpty(),
count = data["count"]?.toIntOrNull()?.coerceAtLeast(0) ?: 1,
)
}
}
@@ -0,0 +1,56 @@
package se.hagstedt.kvitt.adapter.fcm
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** SPEC §14.6: the data map of payload v1 to [PushEvent]; other versions are "sync only" (null). */
class PushMappingTest {
private fun v1(type: String, entityType: String, entityId: String, count: String = "1") = mapOf(
"v" to "1",
"type" to type,
"entity_type" to entityType,
"entity_id" to entityId,
"actor_user_id" to "0192-actor",
"count" to count,
)
@Test
fun mapsEveryTypeOfTheTable() {
val expenseTypes = listOf("expense_created", "expense_updated", "expense_deleted", "recurring_expense_created")
expenseTypes.forEach { type ->
assertEquals(PushEvent(type, "expense", "e-1", "0192-actor", 1), PushMapping.map(v1(type, "expense", "e-1")))
}
listOf("settlement_created", "settlement_updated", "settlement_deleted").forEach { type ->
assertEquals(PushEvent(type, "settlement", "s-1", "0192-actor", 1), PushMapping.map(v1(type, "settlement", "s-1")))
}
}
@Test
fun syncBatchCarriesTheCountAndEmptyEntity() {
assertEquals(PushEvent("sync_batch", "", "", "0192-actor", 7), PushMapping.map(v1("sync_batch", "", "", count = "7")))
}
@Test
fun unknownTypeIsKeptForTheAppToIgnore() {
assertEquals("future_type", PushMapping.map(v1("future_type", "expense", "e-1"))?.type)
}
@Test
fun otherPayloadVersionsAreSyncOnly() {
assertNull(PushMapping.map(v1("expense_created", "expense", "e-1") + ("v" to "2")))
assertNull(PushMapping.map(v1("expense_created", "expense", "e-1") - "v"))
assertNull(PushMapping.map(emptyMap()))
}
@Test
fun missingTypeIsSyncOnly() {
assertNull(PushMapping.map(v1("", "expense", "e-1")))
}
@Test
fun unreadableCountFallsBackToOne() {
assertEquals(1, PushMapping.map(v1("sync_batch", "", "", count = "many"))?.count)
assertEquals(0, PushMapping.map(v1("sync_batch", "", "", count = "-3"))?.count)
}
}
+44 -2
View File
@@ -1,11 +1,25 @@
// :app — MainActivity, Application, Hilt wiring; navigation, bindings, deep links and notifications arrive with M8.
// :app — MainActivity, Application, Hilt wiring (every :core:model interface bound to :data, D-041 and D-085),
// navigation and deep links, push handling and notifications, network security and release signing (SPEC §6.2, M8).
import java.util.Properties
plugins {
id("kvitt.android.application")
id("kvitt.android.compose")
id("kvitt.android.hilt")
alias(libs.plugins.google.services)
alias(libs.plugins.roborazzi)
}
// SPEC §10.6: release signing from android/local.properties (git-ignored); without the keys the release build is unsigned.
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.isFile) file.inputStream().use { load(it) }
}
val releaseStoreFile: String? = localProperties.getProperty("kvitt.release.storeFile")
// SPEC §10.5.3: the pinned Caddy root replaces the system anchors for icehack.asuscomm.com only when this is true.
val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean()
android {
namespace = "se.hagstedt.kvitt"
defaultConfig {
@@ -18,10 +32,28 @@ android {
buildFeatures {
buildConfig = true
}
signingConfigs {
if (releaseStoreFile != null) {
create("release") {
storeFile = file(releaseStoreFile)
storePassword = localProperties.getProperty("kvitt.release.storePassword")
keyAlias = localProperties.getProperty("kvitt.release.keyAlias")
keyPassword = localProperties.getProperty("kvitt.release.keyPassword")
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
}
release {
if (releaseStoreFile != null) signingConfig = signingConfigs.getByName("release")
}
}
sourceSets {
// The pinned-root network security config (and the committed kvitt_root_ca.crt in src/release/res/raw) only
// when kvitt.tls.pinnedRoot=true; otherwise src/main's system-anchors config applies.
if (pinnedRoot) getByName("release").res.directories.add("src/releasePinnedRoot/res")
}
}
@@ -42,14 +74,24 @@ dependencies {
implementation(project(":feature:settle"))
implementation(project(":feature:analytics"))
implementation(project(":feature:settings"))
debugImplementation(project(":core:fakes"))
testImplementation(project(":core:fakes"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.coil)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
@@ -1,53 +0,0 @@
// Temporary until M8 binds :data; M8 deletes this file.
package se.hagstedt.kvitt.debug
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AnalyticsRepository
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SettlementRepository
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.UpdateRepository
import javax.inject.Singleton
/**
* Debug builds only (src/debug): binds every `:core:model` repository interface of SPEC §14.2.4 to one set of fakes at
* [FakeScenarios.empty], so the features' `@HiltViewModel`s have bindings in the debug graph before M8 binds `:data`.
* Fakes never reach a release build: this source set and `debugImplementation(project(":core:fakes"))` are debug only.
*/
@Module
@InstallIn(SingletonComponent::class)
object DebugFakesModule {
@Provides @Singleton
fun fakes(): FakeRepositories = FakeRepositories(FakeScenarios.empty)
@Provides fun session(f: FakeRepositories): SessionRepository = f.session
@Provides fun home(f: FakeRepositories): HomeRepository = f.home
@Provides fun accounts(f: FakeRepositories): AccountRepository = f.accounts
@Provides fun expenses(f: FakeRepositories): ExpenseRepository = f.expenses
@Provides fun settlements(f: FakeRepositories): SettlementRepository = f.settlements
@Provides fun analytics(f: FakeRepositories): AnalyticsRepository = f.analytics
@Provides fun profile(f: FakeRepositories): ProfileRepository = f.profile
@Provides fun categories(f: FakeRepositories): CategoryRepository = f.categories
@Provides fun recurring(f: FakeRepositories): RecurringRepository = f.recurring
@Provides fun devices(f: FakeRepositories): DeviceRepository = f.devices
@Provides fun invites(f: FakeRepositories): InviteRepository = f.invites
@Provides fun sync(f: FakeRepositories): SyncRepository = f.sync
@Provides fun update(f: FakeRepositories): UpdateRepository = f.update
@Provides fun about(f: FakeRepositories): AboutRepository = f.about
@Provides fun connectivity(f: FakeRepositories): ConnectivityRepository = f.connectivity
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPEC §8.5 debug: additionally cleartext to localhost, 10.0.2.2 and private LAN addresses (AC-2.8). A network
security config cannot name address ranges, so cleartext is permitted here and the pairing screen's URL check
(SessionRepositoryImpl.normaliseServerUrl) admits http:// only for those hosts (D-086). -->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+42 -2
View File
@@ -1,21 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- AC-2.9: asked after the first full sync that follows a pairing. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- FR-16: PackageInstaller (declared by :data as well). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- SPEC §8.8: no backup, no device transfer; network security per build type (§8.5). -->
<application
android:name=".KvittApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<!-- The only exported activity (§8.8): launcher, the invite links of AC-2.6 and notification taps. -->
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kvitt" android:host="invite" />
</intent-filter>
<!-- Unverified link: the user may get a chooser (AC-2.6). -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="icehack.asuscomm.com"
android:port="8443"
android:pathPrefix="/i/" />
</intent-filter>
</activity>
<!-- WorkManager is configured by KvittApplication (KvittWorkerFactory, D-041): no default initializer. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>
@@ -0,0 +1,66 @@
package se.hagstedt.kvitt
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import androidx.work.DelegatingWorkerFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.data.di.AppBuildInfo
import se.hagstedt.kvitt.data.sync.KvittWorkerFactory
import se.hagstedt.kvitt.data.sync.SyncTriggers
import se.hagstedt.kvitt.di.AppScope
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.push.PushNotifier
import se.hagstedt.kvitt.push.PushTokens
import se.hagstedt.kvitt.push.PushWorkerFactory
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* The process-wide triggers: sync on app start and every return to the foreground (AC-15.3, `SyncTriggers`), the
* update check on cold start and every resume in release builds only (AC-16.2, AC-16.5; `UpdateRepository.check`
* limits itself to once per 6 hours while resumed), and the push-token registration (AC-13.7).
*/
@Singleton
class AppLifecycle @Inject constructor(
private val triggers: SyncTriggers,
private val update: UpdateRepository,
private val build: AppBuildInfo,
private val session: SessionRepository,
private val tokens: PushTokens,
@param:AppScope private val scope: CoroutineScope,
) {
fun start() {
triggers.onAppStart()
tokens.start(session.session)
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
private var first = true
override fun onStart(owner: LifecycleOwner) {
if (first) first = false else triggers.onForeground()
if (!build.debug) scope.launch { update.check() }
}
},
)
}
}
/** WorkManager's factory (D-041): `:data`'s sync worker and `:app`'s push worker. */
@Singleton
class AppWorkerFactory @Inject constructor(
kvitt: KvittWorkerFactory,
triggers: Provider<SyncTriggers>,
notifier: Provider<PushNotifier>,
) : DelegatingWorkerFactory() {
init {
addFactory(kvitt)
addFactory(PushWorkerFactory({ triggers.get() }, { notifier.get() }))
}
fun configuration(): Configuration = Configuration.Builder().setWorkerFactory(this).build()
}
@@ -0,0 +1,68 @@
package se.hagstedt.kvitt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.link.AppLink
import se.hagstedt.kvitt.link.OpenTarget
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncRepository
import javax.inject.Inject
/** The app shell's state: the session (which gate the graph shows), pending links and the post-pairing permission ask. */
@HiltViewModel
class AppViewModel @Inject constructor(
session: SessionRepository,
private val sync: SyncRepository,
) : ViewModel() {
/** Null until the first emission (the shell shows the empty themed screen meanwhile). */
val session: StateFlow<SessionState?> = session.session.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val inviteState = MutableStateFlow<AppLink.Invite?>(null)
/** The last invite link (AC-2.6), consumed by S-1 as its prefill. */
val invite: StateFlow<AppLink.Invite?> = inviteState.asStateFlow()
private val openState = MutableStateFlow<OpenTarget?>(null)
/** A notification tap waiting for the paired graph (AC-13.6). */
val open: StateFlow<OpenTarget?> = openState.asStateFlow()
private val askNotificationsState = MutableStateFlow(false)
/** 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()
fun onLink(link: AppLink?) {
when (link) {
is AppLink.Invite -> inviteState.value = link
is AppLink.Open -> openState.value = link.target
null -> Unit
}
}
fun openHandled() {
openState.value = null
}
/** S-1 succeeded: wait for the first successful sync (AC-2.9), then ask for the notification permission. */
fun onPaired() {
inviteState.value = null
viewModelScope.launch {
sync.status().first { it.summary.lastSuccessAt != null && !it.summary.running }
askNotificationsState.value = true
}
}
fun notificationsAsked() {
askNotificationsState.value = false
}
}
@@ -1,8 +1,39 @@
package se.hagstedt.kvitt
import android.app.Application
import androidx.work.Configuration
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import se.hagstedt.kvitt.push.Notifications
import javax.inject.Inject
/** Hilt's root. M8 adds the bindings from the `:core:model` interfaces to `:data`. */
/**
* Hilt's root. WorkManager is configured here with `:data`'s `KvittWorkerFactory` (its default initializer is removed in
* the manifest), Coil's singleton is `:data`'s authenticated `ImageLoader` (AC-4.6), and the notification channel is
* created at every start (§14.6).
*/
@HiltAndroidApp
class KvittApplication : Application()
class KvittApplication : Application(), Configuration.Provider, SingletonImageLoader.Factory {
@Inject
lateinit var workerFactory: AppWorkerFactory
@Inject
lateinit var imageLoader: Lazy<ImageLoader>
@Inject
lateinit var lifecycle: AppLifecycle
override val workManagerConfiguration: Configuration
get() = workerFactory.configuration()
override fun onCreate() {
super.onCreate()
Notifications.ensureChannel(this)
lifecycle.start()
}
override fun newImageLoader(context: PlatformContext): ImageLoader = imageLoader.get()
}
@@ -1,21 +1,45 @@
package se.hagstedt.kvitt
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.data.util.Clock
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.link.AppLinks
import se.hagstedt.kvitt.nav.KvittApp
import javax.inject.Inject
import kotlin.time.Instant
/** The single activity (§8.8): launcher, invite links (AC-2.6) and notification taps (AC-13.6). */
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var clock: Clock
private val app: AppViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (savedInstanceState == null) app.onLink(AppLinks.parse(intent))
// `:core:design` reads "now" and the zone from :data's clock (M6 contract request).
val kvittClock = KvittClock({ Instant.fromEpochMilliseconds(clock.nowMillis()) }, TimeZone.of(clock.zone().id))
setContent {
KvittTheme {
KvittEmptyScreen()
KvittApp(app, kvittClock, updateCheckAvailable = !BuildConfig.DEBUG)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
app.onLink(AppLinks.parse(intent))
}
}
@@ -0,0 +1,15 @@
package se.hagstedt.kvitt.di
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import se.hagstedt.kvitt.data.di.DataBindingsModule
/**
* Both variants: every `:core:model` repository interface bound to its `:data` implementation by including
* [DataBindingsModule] (`@DisableInstallInCheck`, D-041). The debug build talks to the development server too (D-001),
* so it binds `:data` as well (D-085); fakes are bound only in tests and the features' previews.
*/
@Module(includes = [DataBindingsModule::class])
@InstallIn(SingletonComponent::class)
interface AppDataModule
@@ -0,0 +1,33 @@
package se.hagstedt.kvitt.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import se.hagstedt.kvitt.adapter.fcm.PushListener
import se.hagstedt.kvitt.push.PushHandler
import javax.inject.Qualifier
import javax.inject.Singleton
/** The application's own coroutine scope (token registration, update checks). */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AppScope
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton @AppScope
fun scope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@Module
@InstallIn(SingletonComponent::class)
interface AppBindings {
@Binds
fun pushListener(handler: PushHandler): PushListener
}
@@ -0,0 +1,70 @@
package se.hagstedt.kvitt.link
import android.content.Context
import android.content.Intent
import se.hagstedt.kvitt.MainActivity
import se.hagstedt.kvitt.feature.onboarding.InviteLink
/** Where a notification tap leads (AC-13.6, §14.6 phone step 3). */
sealed interface OpenTarget {
data object Home : OpenTarget
data class Expense(val id: String) : OpenTarget
data class Settlement(val id: String) : OpenTarget
}
/** What an intent asks of the app. */
sealed interface AppLink {
/** An invite link (AC-2.6): S-1 with both fields filled. [serverUrl] null when the link names none. */
data class Invite(val serverUrl: String?, val code: String) : AppLink
/** A notification tap. */
data class Open(val target: OpenTarget) : AppLink
}
/**
* Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…` and
* `https://icehack.asuscomm.com:8443/i/<CODE>` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader
* S-1 uses for pasted links), and the notification taps this app builds ([openIntent]).
*/
object AppLinks {
const val ACTION_OPEN = "se.hagstedt.kvitt.action.OPEN"
private const val EXTRA_KIND = "se.hagstedt.kvitt.extra.OPEN_KIND"
private const val EXTRA_ID = "se.hagstedt.kvitt.extra.OPEN_ID"
private const val KIND_HOME = "home"
private const val KIND_EXPENSE = "expense"
private const val KIND_SETTLEMENT = "settlement"
fun parse(intent: Intent?): AppLink? {
if (intent == null) return null
return when (intent.action) {
Intent.ACTION_VIEW -> parseUri(intent.dataString)
ACTION_OPEN -> parseOpen(intent.getStringExtra(EXTRA_KIND), intent.getStringExtra(EXTRA_ID))
else -> null
}
}
/** An invite link, or null for anything else (a landing link with a bad code, another path, another scheme). */
fun parseUri(uri: String?): AppLink.Invite? {
val text = uri?.trim()?.takeIf { it.startsWith("kvitt:", ignoreCase = true) || it.startsWith("https:", ignoreCase = true) }
return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code) }
}
fun parseOpen(kind: String?, id: String?): AppLink.Open? = when (kind) {
KIND_HOME -> AppLink.Open(OpenTarget.Home)
KIND_EXPENSE -> id?.takeIf { it.isNotEmpty() }?.let { AppLink.Open(OpenTarget.Expense(it)) }
KIND_SETTLEMENT -> id?.takeIf { it.isNotEmpty() }?.let { AppLink.Open(OpenTarget.Settlement(it)) }
else -> null
}
/** The explicit intent of a notification tap. */
fun openIntent(context: Context, target: OpenTarget): Intent = Intent(context, MainActivity::class.java)
.setAction(ACTION_OPEN)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.apply {
when (target) {
OpenTarget.Home -> putExtra(EXTRA_KIND, KIND_HOME)
is OpenTarget.Expense -> putExtra(EXTRA_KIND, KIND_EXPENSE).putExtra(EXTRA_ID, target.id)
is OpenTarget.Settlement -> putExtra(EXTRA_KIND, KIND_SETTLEMENT).putExtra(EXTRA_ID, target.id)
}
}
}
@@ -0,0 +1,401 @@
package se.hagstedt.kvitt.nav
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.AppViewModel
import se.hagstedt.kvitt.KvittEmptyScreen
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AvatarPictures
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.KvittDestination
import se.hagstedt.kvitt.design.component.KvittHousehold
import se.hagstedt.kvitt.design.component.KvittSnackbar
import se.hagstedt.kvitt.design.component.LocalAvatarPictures
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.LocalKvittHousehold
import se.hagstedt.kvitt.feature.account.AccountDetailScreen
import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
import se.hagstedt.kvitt.feature.account.AccountEditorScreen
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
import se.hagstedt.kvitt.feature.analytics.DrillDownScreen
import se.hagstedt.kvitt.feature.expense.ExpenseDetailScreen
import se.hagstedt.kvitt.feature.expense.ExpenseEditorScreen
import se.hagstedt.kvitt.feature.home.HomeScreen
import se.hagstedt.kvitt.feature.home.SyncStatusScreen
import se.hagstedt.kvitt.feature.onboarding.PairingDone
import se.hagstedt.kvitt.feature.onboarding.PairingScreen
import se.hagstedt.kvitt.feature.onboarding.ProfileStepScreen
import se.hagstedt.kvitt.feature.onboarding.RemovedScreen
import se.hagstedt.kvitt.feature.onboarding.WaitingScreen
import se.hagstedt.kvitt.feature.settings.about.AboutScreen
import se.hagstedt.kvitt.feature.settings.categories.ArchiveResult
import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorScreen
import se.hagstedt.kvitt.feature.settings.categories.CategoryListScreen
import se.hagstedt.kvitt.feature.settings.devices.DevicesScreen
import se.hagstedt.kvitt.feature.settings.hub.SettingsHubScreen
import se.hagstedt.kvitt.feature.settings.invites.InvitesScreen
import se.hagstedt.kvitt.feature.settings.profile.ProfileScreen
import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorScreen
import se.hagstedt.kvitt.feature.settings.recurring.RecurringListScreen
import se.hagstedt.kvitt.feature.settle.SettleScreen
import se.hagstedt.kvitt.feature.settle.SettlementDetailScreen
import se.hagstedt.kvitt.link.OpenTarget
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.feature.account.R as AccountR
/**
* The app shell: the session gate (S-1, S-2, S-18 before the paired graph; SPEC §14.4.8, AC-3.3), the paired graph of
* §13.2 with the bottom destinations drawn by each top-level screen, the composition locals `:core:design` needs
* (household, clock, avatar pictures), result snackbars, notification taps and the permission ask of AC-2.9.
*/
@Composable
fun KvittApp(viewModel: AppViewModel, clock: KvittClock, updateCheckAvailable: Boolean) {
val session by viewModel.session.collectAsStateWithLifecycle()
val current = session ?: run {
KvittEmptyScreen()
return
}
val paired = current as? SessionState.Paired
val household = paired?.let { KvittHousehold(it.me.id, listOfNotNull(it.me.id, it.partner?.id)) } ?: KvittHousehold(null, emptyList())
CompositionLocalProvider(
LocalKvittHousehold provides household,
LocalKvittClock provides clock,
LocalAvatarPictures provides UserPictures,
) {
AppGraph(viewModel, current, updateCheckAvailable)
}
NotificationPermission(viewModel)
}
/** `:data`'s Coil singleton maps a [User] to the authenticated E-11 request (user id and `pictureVersion`, AC-4.6). */
private val UserPictures = AvatarPictures { user -> user.takeIf { it.pictureVersion != null } }
@Composable
private fun NotificationPermission(viewModel: AppViewModel) {
val ask by viewModel.askNotifications.collectAsStateWithLifecycle()
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { viewModel.notificationsAsked() }
LaunchedEffect(ask) {
if (!ask) return@LaunchedEffect
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
viewModel.notificationsAsked()
} else {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
@Composable
private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheckAvailable: Boolean) {
val nav = rememberNavController()
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val start = remember { startRoute(session) }
val entry by nav.currentBackStackEntryAsState()
val route = entry?.destination?.route
val shell = Shell(nav, snackbar, scope)
// The session gate: revocation (S-18) and a lost pairing (S-1 "Pair this phone again") replace the whole stack.
LaunchedEffect(session) {
val at = nav.currentDestination?.route
when (session) {
SessionState.Revoked -> if (at != Routes.REMOVED) shell.replaceAll(Routes.REMOVED)
SessionState.Unpaired, SessionState.Unrecognised -> if (at != Routes.PAIRING) shell.replaceAll(Routes.PAIRING)
is SessionState.Paired -> Unit
}
}
val open by viewModel.open.collectAsStateWithLifecycle()
LaunchedEffect(open, session is SessionState.Paired, route) {
val target = open ?: return@LaunchedEffect
if (session !is SessionState.Paired || route == null || route in Routes.ONBOARDING) return@LaunchedEffect
viewModel.openHandled()
when (target) {
OpenTarget.Home -> shell.top(KvittDestination.Home)
is OpenTarget.Expense -> nav.navigate(Routes.expense(ExpenseId(target.id)))
is OpenTarget.Settlement -> nav.navigate(Routes.settlement(SettlementId(target.id)))
}
}
Box(Modifier.fillMaxSize()) {
NavHost(nav, startDestination = start) {
onboarding(viewModel, shell)
topLevel(session, shell)
accountsAndExpenses(shell)
settings(shell, updateCheckAvailable)
}
// Home shows result snackbars in its own scaffold; every other screen gets them here.
if (route != Routes.HOME) {
SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().imePadding()) { data ->
KvittSnackbar(data.visuals.message, Modifier.padding(KvittTheme.spacing.s4), data.visuals.actionLabel, { data.performAction() })
}
}
}
}
private fun startRoute(session: SessionState): String = when (session) {
is SessionState.Paired -> Routes.HOME
SessionState.Revoked -> Routes.REMOVED
SessionState.Unpaired, SessionState.Unrecognised -> Routes.PAIRING
}
/** Navigation helpers shared by the graph sections. */
private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState, val scope: CoroutineScope) {
fun replaceAll(route: String) = nav.navigate(route) {
popUpTo(nav.graph.id) { inclusive = true }
launchSingleTop = true
}
/** A bottom destination (§13.2): one copy each, Home at the root, state kept per destination. */
fun top(destination: KvittDestination) {
val route = when (destination) {
KvittDestination.Home -> Routes.HOME
KvittDestination.Analytics -> Routes.ANALYTICS
KvittDestination.Settings -> Routes.SETTINGS
}
if (nav.graph.findNode(Routes.HOME) == null || nav.currentDestination?.route == route) return
nav.navigate(route) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
fun back() {
nav.popBackStack()
}
fun show(message: String?, action: String? = null, onAction: () -> Unit = {}) {
if (message == null) return
scope.launch {
val result = snackbar.showSnackbar(message, actionLabel = action, withDismissAction = false)
if (result == SnackbarResult.ActionPerformed) onAction()
}
}
}
private fun NavGraphBuilder.onboarding(viewModel: AppViewModel, shell: Shell) {
composable(Routes.PAIRING) {
val invite by viewModel.invite.collectAsStateWithLifecycle()
PairingScreen(
onPaired = { done ->
viewModel.onPaired()
// AC-2.10: a partner confirms the profile (S-2) before Home.
shell.replaceAll(if (done == PairingDone.ProfileStep) Routes.PROFILE_STEP else Routes.HOME)
},
initialServerUrl = invite?.serverUrl,
initialCode = invite?.code,
)
}
composable(Routes.PROFILE_STEP) {
ProfileStepScreen(onDone = { shell.replaceAll(Routes.HOME) })
}
composable(Routes.REMOVED) {
val activity: Activity? = LocalActivity.current
// S-18: back closes the app; only "Pair again" leads on.
BackHandler { activity?.finish() }
RemovedScreen(onPairAgain = { shell.replaceAll(Routes.PAIRING) })
}
}
private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
composable(Routes.HOME) {
val partner: User? = (session as? SessionState.Paired)?.partner
if (session is SessionState.Paired && partner == null) {
// AC-5.1: until the partner has joined, Home is "Waiting for your partner" (S-3).
WaitingScreen(onInvitePartner = { shell.nav.navigate(Routes.INVITES) }, onNavigate = shell::top)
} else {
HomeScreen(
onAddExpense = { shell.nav.navigate(Routes.expenseEditor()) },
onSettleUp = { shell.nav.navigate(Routes.settle()) },
onOpenAccount = { shell.nav.navigate(Routes.account(it)) },
onNewAccount = { shell.nav.navigate(Routes.accountEditor(null)) },
onOpenSyncStatus = { shell.nav.navigate(Routes.SYNC_STATUS) },
onNavigate = shell::top,
snackbarHostState = shell.snackbar,
)
}
}
composable(Routes.ANALYTICS) {
AnalyticsScreen(onOpenDrillDown = { shell.nav.navigate(Routes.drillDown(it)) }, onNavigate = shell::top)
}
composable(Routes.DRILL_DOWN, arguments = Routes.drillDownArgs) {
DrillDownScreen(onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, onBack = shell::back)
}
composable(Routes.SETTINGS) {
SettingsHubScreen(
onOpenProfile = { shell.nav.navigate(Routes.PROFILE) },
onOpenCategories = { shell.nav.navigate(Routes.CATEGORIES) },
onOpenRecurring = { shell.nav.navigate(Routes.RECURRING) },
onOpenDevices = { shell.nav.navigate(Routes.DEVICES) },
onOpenInvites = { shell.nav.navigate(Routes.INVITES) },
onOpenSyncStatus = { shell.nav.navigate(Routes.SYNC_STATUS) },
onOpenAbout = { shell.nav.navigate(Routes.ABOUT) },
onNavigate = shell::top,
)
}
composable(Routes.SYNC_STATUS) {
SyncStatusScreen(
onBack = shell::back,
onOpenExpense = { shell.nav.navigate(Routes.expense(it)) },
onOpenSettlement = { shell.nav.navigate(Routes.settlement(it)) },
)
}
}
private fun NavGraphBuilder.accountsAndExpenses(shell: Shell) {
composable(Routes.ACCOUNT, arguments = Routes.accountArgs) {
val resources = LocalResources.current
AccountDetailScreen(
onBack = shell::back,
onOpenExpense = { shell.nav.navigate(Routes.expense(it)) },
onOpenSettlement = { shell.nav.navigate(Routes.settlement(it)) },
onAddExpense = { shell.nav.navigate(Routes.expenseEditor(account = it)) },
onSettle = { shell.nav.navigate(Routes.settle(account = it)) },
onEdit = { shell.nav.navigate(Routes.accountEditor(it)) },
onArchived = { _, name ->
shell.nav.popBackStack(Routes.HOME, inclusive = false)
// design-spec S-5: "Vardag arkiverades" with "Ångra" on Home. The undo label (common_undo) is not in
// :core:design yet, so the snackbar has no action until it is (M8 report, contract request).
shell.show(resources.getString(AccountR.string.account_archived_snackbar, name))
},
onDeleted = { shell.nav.popBackStack(Routes.HOME, inclusive = false) },
)
}
composable(Routes.ACCOUNT_EDITOR, arguments = Routes.accountEditorArgs) { entry ->
val editing = entry.arguments?.getString(AccountDetailViewModel.ARG_ACCOUNT_ID) != null
AccountEditorScreen(
onSaved = { id ->
shell.back()
if (!editing) shell.nav.navigate(Routes.account(id))
},
onClose = shell::back,
)
}
composable(Routes.EXPENSE_EDITOR, arguments = Routes.expenseEditorArgs) {
ExpenseEditorScreen(
onClose = shell::back,
onSaved = { _, text ->
shell.back()
shell.show(text)
},
onDeleted = { text ->
// Deleting from the editor also closes the detail it was opened from.
if (!shell.nav.popBackStack(Routes.EXPENSE, inclusive = true)) shell.back()
shell.show(text)
},
)
}
composable(Routes.EXPENSE, arguments = Routes.expenseArgs) {
ExpenseDetailScreen(
onBack = shell::back,
onEdit = { shell.nav.navigate(Routes.expenseEditor(id = it)) },
onDeleted = { text ->
shell.back()
shell.show(text)
},
)
}
composable(Routes.SETTLE, arguments = Routes.settleArgs) {
val resources = LocalResources.current
SettleScreen(
onClose = shell::back,
onSaved = {
shell.back()
shell.show(resources.getString(DesignR.string.snackbar_payment_saved))
},
)
}
composable(Routes.SETTLEMENT, arguments = Routes.settlementArgs) {
val resources = LocalResources.current
SettlementDetailScreen(
onBack = shell::back,
onEdit = { shell.nav.navigate(Routes.settle(editing = it)) },
onDeleted = {
shell.back()
shell.show(resources.getString(DesignR.string.snackbar_payment_deleted))
},
)
}
}
private fun NavGraphBuilder.settings(shell: Shell, updateCheckAvailable: Boolean) {
composable(Routes.PROFILE) { ProfileScreen(onBack = shell::back) }
composable(Routes.CATEGORIES) { entry ->
// The editor's archive result (S-15) comes back through this entry's SavedStateHandle.
val name by entry.savedStateHandle.getStateFlow<String?>(ARCHIVE_NAME, null).collectAsStateWithLifecycle()
val archived by entry.savedStateHandle.getStateFlow(ARCHIVE_ARCHIVED, true).collectAsStateWithLifecycle()
CategoryListScreen(
onBack = shell::back,
onOpenCategory = { id -> shell.nav.navigate(Routes.categoryEditor(id)) },
onNewCategory = { shell.nav.navigate(Routes.categoryEditor(null)) },
archiveResult = name?.let { ArchiveResult(it, archived) },
onArchiveResultShown = { entry.savedStateHandle[ARCHIVE_NAME] = null },
)
}
composable(Routes.CATEGORY_EDITOR, arguments = Routes.categoryEditorArgs) {
CategoryEditorScreen(
onClose = shell::back,
onArchived = { result ->
shell.nav.previousBackStackEntry?.savedStateHandle?.apply {
set(ARCHIVE_NAME, result.name)
set(ARCHIVE_ARCHIVED, result.archived)
}
shell.back()
},
)
}
composable(Routes.RECURRING) {
RecurringListScreen(
onBack = shell::back,
onOpenTemplate = { shell.nav.navigate(Routes.templateEditor(it)) },
onNewTemplate = { shell.nav.navigate(Routes.templateEditor(null)) },
)
}
composable(Routes.TEMPLATE_EDITOR, arguments = Routes.templateEditorArgs) { RecurringEditorScreen(onClose = shell::back) }
composable(Routes.DEVICES) { DevicesScreen(onBack = shell::back) }
composable(Routes.INVITES) { InvitesScreen(onBack = shell::back) }
composable(Routes.ABOUT) {
// The open-source licences screen does not exist yet (no strings for it outside :app; M8 report).
AboutScreen(onBack = shell::back, onOpenLicences = {}, updateCheckAvailable = updateCheckAvailable)
}
}
private const val ARCHIVE_NAME = "kvitt.categoryArchive.name"
private const val ARCHIVE_ARCHIVED = "kvitt.categoryArchive.archived"
@@ -0,0 +1,97 @@
package se.hagstedt.kvitt.nav
import android.net.Uri
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavType
import androidx.navigation.navArgument
import se.hagstedt.kvitt.feature.account.AccountDetailViewModel
import se.hagstedt.kvitt.feature.analytics.DrillDownArgs
import se.hagstedt.kvitt.feature.expense.ExpenseDetailViewModel
import se.hagstedt.kvitt.feature.expense.ExpenseEditorViewModel
import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorViewModel
import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel
import se.hagstedt.kvitt.feature.settle.SettleArgs
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.TemplateId
/**
* The navigation graph's routes (§13.2). Argument names are the features' `SavedStateHandle` keys, so each feature
* ViewModel reads its own argument; optional arguments are query parameters (absent = new / global).
*/
object Routes {
const val PAIRING = "pairing"
const val PROFILE_STEP = "profile_step"
const val REMOVED = "removed"
const val HOME = "home"
const val ANALYTICS = "analytics"
const val SETTINGS = "settings"
private val ACCOUNT_ID = AccountDetailViewModel.ARG_ACCOUNT_ID
private val EXPENSE_ID = ExpenseEditorViewModel.ARG_EXPENSE_ID
private val EXPENSE_ACCOUNT_ID = ExpenseEditorViewModel.ARG_ACCOUNT_ID
val ACCOUNT = "account/{$ACCOUNT_ID}"
val ACCOUNT_EDITOR = "account_editor?$ACCOUNT_ID={$ACCOUNT_ID}"
val EXPENSE_EDITOR = "expense_editor?$EXPENSE_ID={$EXPENSE_ID}&$EXPENSE_ACCOUNT_ID={$EXPENSE_ACCOUNT_ID}"
val EXPENSE = "expense/{${ExpenseDetailViewModel.ARG_EXPENSE_ID}}"
val SETTLE = "settle?${SettleArgs.ACCOUNT_ID}={${SettleArgs.ACCOUNT_ID}}&${SettleArgs.SETTLEMENT_ID}={${SettleArgs.SETTLEMENT_ID}}"
val SETTLEMENT = "settlement/{${SettleArgs.SETTLEMENT_ID}}"
val DRILL_DOWN = "drill_down?" + DrillDownArgs.KEYS.joinToString("&") { "$it={$it}" }
const val SYNC_STATUS = "sync_status"
const val PROFILE = "settings/profile"
const val CATEGORIES = "settings/categories"
val CATEGORY_EDITOR = "settings/category?${CategoryEditorViewModel.ARG_CATEGORY_ID}={${CategoryEditorViewModel.ARG_CATEGORY_ID}}"
const val RECURRING = "settings/recurring"
val TEMPLATE_EDITOR = "settings/template?${RecurringEditorViewModel.ARG_TEMPLATE_ID}={${RecurringEditorViewModel.ARG_TEMPLATE_ID}}"
const val DEVICES = "settings/devices"
const val INVITES = "settings/invites"
const val ABOUT = "settings/about"
/** Routes outside the paired app: the session gate (`KvittApp`) moves between them. */
val ONBOARDING = setOf(PAIRING, PROFILE_STEP, REMOVED)
fun account(id: AccountId) = "account/${encode(id.value)}"
fun accountEditor(id: AccountId?) = query("account_editor", ACCOUNT_ID to id?.value)
fun expenseEditor(id: ExpenseId? = null, account: AccountId? = null) =
query("expense_editor", EXPENSE_ID to id?.value, EXPENSE_ACCOUNT_ID to account?.value)
fun expense(id: ExpenseId) = "expense/${encode(id.value)}"
fun settle(account: AccountId? = null, editing: SettlementId? = null) =
query("settle", SettleArgs.ACCOUNT_ID to account?.value, SettleArgs.SETTLEMENT_ID to editing?.value)
fun settlement(id: SettlementId) = "settlement/${encode(id.value)}"
fun drillDown(args: DrillDownArgs) = query("drill_down", *args.toArguments().toList().toTypedArray())
fun categoryEditor(id: CategoryId?) = query("settings/category", CategoryEditorViewModel.ARG_CATEGORY_ID to id?.value)
fun templateEditor(id: TemplateId?) = query("settings/template", RecurringEditorViewModel.ARG_TEMPLATE_ID to id?.value)
/** Nullable string arguments, all absent by default. */
fun optional(vararg names: String): List<NamedNavArgument> = names.map { name ->
navArgument(name) {
type = NavType.StringType
nullable = true
defaultValue = null
}
}
fun required(name: String): List<NamedNavArgument> = listOf(navArgument(name) { type = NavType.StringType })
val accountArgs get() = required(ACCOUNT_ID)
val accountEditorArgs get() = optional(ACCOUNT_ID)
val expenseEditorArgs get() = optional(EXPENSE_ID, EXPENSE_ACCOUNT_ID)
val expenseArgs get() = required(ExpenseDetailViewModel.ARG_EXPENSE_ID)
val settleArgs get() = optional(SettleArgs.ACCOUNT_ID, SettleArgs.SETTLEMENT_ID)
val settlementArgs get() = required(SettleArgs.SETTLEMENT_ID)
val drillDownArgs get() = optional(*DrillDownArgs.KEYS.toTypedArray())
val categoryEditorArgs get() = optional(CategoryEditorViewModel.ARG_CATEGORY_ID)
val templateEditorArgs get() = optional(RecurringEditorViewModel.ARG_TEMPLATE_ID)
private fun query(base: String, vararg params: Pair<String, String?>): String {
val present = params.filter { it.second != null }
return if (present.isEmpty()) base else base + "?" + present.joinToString("&") { (k, v) -> "$k=${encode(v.orEmpty())}" }
}
private fun encode(value: String): String = Uri.encode(value)
}
@@ -0,0 +1,127 @@
package se.hagstedt.kvitt.push
import android.content.res.Resources
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.link.OpenTarget
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.core.design.R as DesignR
/** The local data a notification is built from (AC-13.5), read after the push's sync. */
sealed interface PushSubject {
data class Expense(val description: String, val amountOre: Long, val accountName: String) : PushSubject
data class Settlement(val payer: User, val recipient: User, val amountOre: Long) : PushSubject
}
/** One notification: copy and where a tap leads. */
data class NotificationContent(val title: String, val body: String, val target: OpenTarget)
/** The push types of §14.6 (exhaustive for v1). */
object PushTypes {
const val EXPENSE_CREATED = "expense_created"
const val EXPENSE_UPDATED = "expense_updated"
const val EXPENSE_DELETED = "expense_deleted"
const val SETTLEMENT_CREATED = "settlement_created"
const val SETTLEMENT_UPDATED = "settlement_updated"
const val SETTLEMENT_DELETED = "settlement_deleted"
const val RECURRING_EXPENSE_CREATED = "recurring_expense_created"
const val SYNC_BATCH = "sync_batch"
val ALL = setOf(
EXPENSE_CREATED, EXPENSE_UPDATED, EXPENSE_DELETED,
SETTLEMENT_CREATED, SETTLEMENT_UPDATED, SETTLEMENT_DELETED,
RECURRING_EXPENSE_CREATED, SYNC_BATCH,
)
}
/** Notification ids (§14.6 phone step 2): the hash of `entity_id`, so an update replaces the create; one id for batches. */
object NotificationIds {
const val SYNC_BATCH = 1
fun of(event: PushEvent): Int = if (event.type == PushTypes.SYNC_BATCH) SYNC_BATCH else event.entityId.hashCode()
}
/**
* The §13.3 system-notification copy from `:core:design`'s strings in the phone's language (AC-13.5). The generic text
* (no amounts, no descriptions) when the sync failed, the entity or the actor is missing. Taps per AC-13.6: S-8 or S-10
* for creates and updates, Home for deletions, `sync_batch` and the generic text.
*/
class NotificationTexts(private val resources: Resources) {
private val language = KvittLanguage.of(resources.configuration.locales[0].language)
/** Null for a type outside §14.6 (sync only, phone step 4). */
fun build(event: PushEvent, synced: Boolean, actor: User?, viewer: UserId, subject: PushSubject?): NotificationContent? {
if (event.type !in PushTypes.ALL) return null
if (!synced) return generic(actor)
return when (event.type) {
PushTypes.SYNC_BATCH -> actor?.let {
NotificationContent(
resources.getQuantityString(DesignR.plurals.notif_sync_batch_title, event.count, it.displayName, event.count),
resources.getString(DesignR.string.notif_sync_batch_body),
OpenTarget.Home,
)
}
PushTypes.RECURRING_EXPENSE_CREATED -> (subject as? PushSubject.Expense)?.let {
NotificationContent(
resources.getString(DesignR.string.notif_recurring_created_title),
resources.getString(DesignR.string.notif_recurring_created_body, it.description, money(it.amountOre), it.accountName),
OpenTarget.Expense(event.entityId),
)
}
PushTypes.EXPENSE_CREATED, PushTypes.EXPENSE_UPDATED, PushTypes.EXPENSE_DELETED -> expense(event, actor, subject as? PushSubject.Expense)
else -> settlement(event, actor, viewer, subject as? PushSubject.Settlement)
} ?: generic(actor)
}
private fun expense(event: PushEvent, actor: User?, subject: PushSubject.Expense?): NotificationContent? {
if (actor == null || subject == null) return null
val amount = money(subject.amountOre)
return when (event.type) {
PushTypes.EXPENSE_CREATED -> NotificationContent(
resources.getString(DesignR.string.notif_expense_created_title, actor.displayName),
resources.getString(DesignR.string.notif_expense_created_body, subject.description, amount, subject.accountName),
OpenTarget.Expense(event.entityId),
)
PushTypes.EXPENSE_UPDATED -> NotificationContent(
resources.getString(DesignR.string.notif_expense_updated_title, actor.displayName),
resources.getString(DesignR.string.notif_expense_updated_body, subject.description, amount),
OpenTarget.Expense(event.entityId),
)
else -> NotificationContent(
resources.getString(DesignR.string.notif_expense_deleted_title, actor.displayName),
resources.getString(DesignR.string.notif_expense_deleted_body, subject.description, amount),
OpenTarget.Home,
)
}
}
private fun settlement(event: PushEvent, actor: User?, viewer: UserId, subject: PushSubject.Settlement?): NotificationContent? {
if (actor == null || subject == null) return null
val amount = money(subject.amountOre)
if (event.type == PushTypes.SETTLEMENT_DELETED) {
return NotificationContent(
resources.getString(DesignR.string.notif_settlement_deleted_title, actor.displayName),
resources.getString(DesignR.string.notif_settlement_deleted_body, amount),
OpenTarget.Home,
)
}
val title = if (event.type == PushTypes.SETTLEMENT_CREATED) DesignR.string.notif_settlement_created_title else DesignR.string.notif_settlement_updated_title
val body = when (viewer) {
subject.recipient.id -> resources.getString(DesignR.string.notif_settlement_body_to_you, subject.payer.displayName, amount)
subject.payer.id -> resources.getString(DesignR.string.notif_settlement_body_from_you, subject.recipient.displayName, amount)
else -> resources.getString(DesignR.string.notif_settlement_body, subject.payer.displayName, subject.recipient.displayName, amount)
}
return NotificationContent(resources.getString(title, actor.displayName), body, OpenTarget.Settlement(event.entityId))
}
private fun generic(actor: User?) = NotificationContent(
actor?.let { resources.getString(DesignR.string.notif_generic_title_actor, it.displayName) } ?: resources.getString(DesignR.string.notif_generic_title),
resources.getString(DesignR.string.notif_generic_body),
OpenTarget.Home,
)
private fun money(ore: Long): String = MoneyFormatter.format(ore, language)
}
@@ -0,0 +1,96 @@
package se.hagstedt.kvitt.push
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.link.AppLinks
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SettlementRepository
import javax.inject.Inject
import javax.inject.Singleton
import se.hagstedt.kvitt.core.design.R as DesignR
/** The notification channel of §14.6: id `activity`, name "Activity" / "Aktivitet", importance default. */
object Notifications {
const val CHANNEL_ID = "activity"
/** Creates or renames the channel (the name follows the phone's language at every start). */
fun ensureChannel(context: Context) {
val channel = NotificationChannel(CHANNEL_ID, context.getString(DesignR.string.notif_channel_activity), NotificationManager.IMPORTANCE_DEFAULT)
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
/**
* §14.6 phone step 2: builds the notification of a push from local data after its sync and posts it. Nothing is shown
* while the phone is not paired, for a type outside §14.6, or without the notification permission (AC-2.9).
*/
@Singleton
class PushNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val session: SessionRepository,
private val profile: ProfileRepository,
private val expenses: ExpenseRepository,
private val settlements: SettlementRepository,
) {
suspend fun show(event: PushEvent, synced: Boolean) {
if (event.type !in PushTypes.ALL) return
val me = (session.session.first() as? SessionState.Paired)?.me ?: return
val actor = profile.household().first().firstOrNull { it.id.value == event.actorUserId }
val subject = if (synced) subject(event) else null
val content = NotificationTexts(context.resources).build(event, synced, actor, me.id, subject) ?: return
post(NotificationIds.of(event), content)
}
private suspend fun subject(event: PushEvent): PushSubject? {
if (event.entityId.isEmpty()) return null
return when (event.entityType) {
ENTITY_EXPENSE -> expenses.detail(ExpenseId(event.entityId)).first()?.let {
PushSubject.Expense(it.description, it.amountOre, it.account.name)
}
ENTITY_SETTLEMENT -> settlements.detail(SettlementId(event.entityId)).first()?.let {
PushSubject.Settlement(it.from, it.to, it.amountOre)
}
else -> null
}
}
private fun post(id: Int, content: NotificationContent) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
val tap = PendingIntent.getActivity(
context,
id,
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)
}
private companion object {
const val ENTITY_EXPENSE = "expense"
const val ENTITY_SETTLEMENT = "settlement"
}
}
@@ -0,0 +1,93 @@
package se.hagstedt.kvitt.push
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.adapter.fcm.FcmRegistration
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.adapter.fcm.PushListener
import se.hagstedt.kvitt.data.api.ApiResult
import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.api.PushTokenRequest
import se.hagstedt.kvitt.data.auth.CredentialStore
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 javax.inject.Inject
import javax.inject.Singleton
/** `:adapter:fcm`'s listener: pushes go to [PushWorker] (or sync only), registrations to [PushTokens]. */
@Singleton
class PushHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val work: SyncWork,
private val tokens: PushTokens,
) : PushListener {
override fun onPush(event: PushEvent?) {
// §14.6 phone step 4: another payload version or an unknown type syncs only.
if (event == null || event.type !in PushTypes.ALL) work.enqueueOneOff(expedited = true) else PushWorker.enqueue(context, event)
}
override fun onRegistered(installationId: String) = tokens.send(installationId)
}
/**
* 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).
*/
@Singleton
class PushTokens @Inject constructor(
private val api: KvittApi,
private val credentials: CredentialStore,
@param:AppScope private val scope: CoroutineScope,
) {
fun start(session: Flow<SessionState>) {
scope.launch {
session.map { it.key() }.distinctUntilChanged().collect { key ->
when (key) {
is SessionKey.Paired -> if (!FcmRegistration.register()) Log.w(TAG, "FCM registration failed")
SessionKey.Revoked -> if (!FcmRegistration.unregister()) Log.w(TAG, "FCM unregistration failed")
SessionKey.Other -> Unit
}
}
}
}
fun send(token: String) {
scope.launch { put(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 sealed interface SessionKey {
data class Paired(val deviceId: String) : SessionKey
data object Revoked : SessionKey
data object Other : SessionKey
}
private fun SessionState.key(): SessionKey = when (this) {
is SessionState.Paired -> SessionKey.Paired(deviceId.value)
SessionState.Revoked -> SessionKey.Revoked
else -> SessionKey.Other
}
private companion object {
const val TAG = "KvittPush"
}
}
@@ -0,0 +1,67 @@
package se.hagstedt.kvitt.push
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.data.sync.SyncTriggers
/**
* One push (§14.6 phone steps 1 and 2): an expedited sync waited for up to 20 seconds (`SyncTriggers.syncForPush`,
* AC-13.5), then the notification from local data, or the generic text when the sync did not finish in time.
*/
class PushWorker(
context: Context,
params: WorkerParameters,
private val triggers: SyncTriggers,
private val notifier: PushNotifier,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val event = inputData.toEvent() ?: return Result.success()
val synced = triggers.syncForPush()
notifier.show(event, synced)
return Result.success()
}
companion object {
private const val KEY_TYPE = "type"
private const val KEY_ENTITY_TYPE = "entity_type"
private const val KEY_ENTITY_ID = "entity_id"
private const val KEY_ACTOR = "actor_user_id"
private const val KEY_COUNT = "count"
/** Expedited one-off work; without quota it runs as normal work (the sync still runs, the wait is the same). */
fun enqueue(context: Context, event: PushEvent) {
val request = OneTimeWorkRequestBuilder<PushWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setInputData(
workDataOf(
KEY_TYPE to event.type,
KEY_ENTITY_TYPE to event.entityType,
KEY_ENTITY_ID to event.entityId,
KEY_ACTOR to event.actorUserId,
KEY_COUNT to event.count,
),
)
.build()
WorkManager.getInstance(context).enqueue(request)
}
private fun androidx.work.Data.toEvent(): PushEvent? {
val type = getString(KEY_TYPE) ?: return null
return PushEvent(type, getString(KEY_ENTITY_TYPE).orEmpty(), getString(KEY_ENTITY_ID).orEmpty(), getString(KEY_ACTOR).orEmpty(), getInt(KEY_COUNT, 1))
}
}
}
/** Creates [PushWorker]s; `:app`'s WorkManager configuration delegates to it beside `:data`'s `KvittWorkerFactory`. */
class PushWorkerFactory(private val triggers: () -> SyncTriggers, private val notifier: () -> PushNotifier) : WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker? =
if (workerClassName == PushWorker::class.java.name) PushWorker(appContext, workerParameters, triggers(), notifier()) else null
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPEC §8.5 release: cleartext off, system trust anchors only. kvitt.tls.pinnedRoot=true swaps in
src/releasePinnedRoot/res (§10.5.3); debug builds override this file in src/debug/res. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.release
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import se.hagstedt.kvitt.data.di.DataBindingsModule
/**
* Release builds only (src/release): binds every `:core:model` repository interface to its `:data` implementation by
* including [DataBindingsModule] (`@DisableInstallInCheck`, D-041). Debug binds the fakes instead (`DebugFakesModule`).
*/
@Module(includes = [DataBindingsModule::class])
@InstallIn(SingletonComponent::class)
interface ReleaseDataModule
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPEC §10.5.3 fallback, used only with kvitt.tls.pinnedRoot=true: the Caddy internal root (committed as
src/release/res/raw/kvitt_root_ca.crt) replaces the system anchors for icehack.asuscomm.com only. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="false">icehack.asuscomm.com</domain>
<trust-anchors>
<certificates src="@raw/kvitt_root_ca" />
</trust-anchors>
</domain-config>
</network-security-config>
@@ -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)))
}
}
+8
View File
@@ -28,11 +28,16 @@ vico = "3.3.1"
junit = "4.13.2"
robolectric = "4.17"
roborazzi = "1.76.0"
# M8 (2026-10-18): Firebase BoM 35.0.2 (firebase-messaging 26.0.0) and the Google services plugin 4.5.0, the latest
# stable per firebase.google.com/support/release-notes/android
firebaseBom = "35.0.2"
googleServices = "4.5.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
@@ -65,6 +70,8 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil"
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging" }
junit = { module = "junit:junit", version.ref = "junit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
@@ -84,4 +91,5 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room3 = { id = "androidx.room3", version.ref = "room3" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }