diff --git a/android/adapter/fcm/build.gradle.kts b/android/adapter/fcm/build.gradle.kts index 95c3651..ef36c41 100644 --- a/android/adapter/fcm/build.gradle.kts +++ b/android/adapter/fcm/build.gradle.kts @@ -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) } diff --git a/android/adapter/fcm/src/main/AndroidManifest.xml b/android/adapter/fcm/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6ff649a --- /dev/null +++ b/android/adapter/fcm/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt deleted file mode 100644 index 5572eca..0000000 --- a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmAdapter.kt +++ /dev/null @@ -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 -} diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmRegistration.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmRegistration.kt new file mode 100644 index 0000000..37f1eca --- /dev/null +++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/FcmRegistration.kt @@ -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 Task.awaitCompletion(): Task = suspendCancellableCoroutine { continuation -> + addOnCompleteListener { task -> continuation.resume(task) } + } +} diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/KvittMessagingService.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/KvittMessagingService.kt new file mode 100644 index 0000000..62a13ed --- /dev/null +++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/KvittMessagingService.kt @@ -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) + } +} diff --git a/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt new file mode 100644 index 0000000..9324d90 --- /dev/null +++ b/android/adapter/fcm/src/main/kotlin/se/hagstedt/kvitt/adapter/fcm/PushEvent.kt @@ -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): 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, + ) + } +} diff --git a/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt b/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt new file mode 100644 index 0000000..040cc3d --- /dev/null +++ b/android/adapter/fcm/src/test/kotlin/se/hagstedt/kvitt/adapter/fcm/PushMappingTest.kt @@ -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) + } +} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2fd70a9..fff7ac8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/debug/kotlin/se/hagstedt/kvitt/debug/DebugFakesModule.kt b/android/app/src/debug/kotlin/se/hagstedt/kvitt/debug/DebugFakesModule.kt deleted file mode 100644 index 727b224..0000000 --- a/android/app/src/debug/kotlin/se/hagstedt/kvitt/debug/DebugFakesModule.kt +++ /dev/null @@ -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 -} diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..c55cd22 --- /dev/null +++ b/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 19a64a1..2c076e2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,21 +1,61 @@ - + + + + + + + + + android:exported="true" + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize"> + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppLifecycle.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppLifecycle.kt new file mode 100644 index 0000000..5b50ef0 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppLifecycle.kt @@ -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, + notifier: Provider, +) : DelegatingWorkerFactory() { + init { + addFactory(kvitt) + addFactory(PushWorkerFactory({ triggers.get() }, { notifier.get() })) + } + + fun configuration(): Configuration = Configuration.Builder().setWorkerFactory(this).build() +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt new file mode 100644 index 0000000..bc58691 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt @@ -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 = session.session.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val inviteState = MutableStateFlow(null) + + /** The last invite link (AC-2.6), consumed by S-1 as its prefill. */ + val invite: StateFlow = inviteState.asStateFlow() + + private val openState = MutableStateFlow(null) + + /** A notification tap waiting for the paired graph (AC-13.6). */ + val open: StateFlow = 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 = 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 + } +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt index df82e56..292c1c3 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/KvittApplication.kt @@ -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 + + @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() +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt index 6a7d7c4..c10cd24 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/MainActivity.kt @@ -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)) + } } diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppDataModule.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppDataModule.kt new file mode 100644 index 0000000..ff2d9d7 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppDataModule.kt @@ -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 diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt new file mode 100644 index 0000000..623f795 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt new file mode 100644 index 0000000..6a7e130 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt @@ -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/` (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) + } + } +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt new file mode 100644 index 0000000..2e45560 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt @@ -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(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" diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt new file mode 100644 index 0000000..eadb362 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/nav/Routes.kt @@ -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 = names.map { name -> + navArgument(name) { + type = NavType.StringType + nullable = true + defaultValue = null + } + } + + fun required(name: String): List = 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 { + 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) +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt new file mode 100644 index 0000000..55d858d --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/NotificationTexts.kt @@ -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) +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt new file mode 100644 index 0000000..7d8d986 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt @@ -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" + } +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt new file mode 100644 index 0000000..fa9f86b --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushHandler.kt @@ -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) { + 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" + } +} diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt new file mode 100644 index 0000000..addd414 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/push/PushWork.kt @@ -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() + .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 +} diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..86e2f95 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/android/app/src/release/kotlin/se/hagstedt/kvitt/release/ReleaseDataModule.kt b/android/app/src/release/kotlin/se/hagstedt/kvitt/release/ReleaseDataModule.kt deleted file mode 100644 index 6f69aab..0000000 --- a/android/app/src/release/kotlin/se/hagstedt/kvitt/release/ReleaseDataModule.kt +++ /dev/null @@ -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 diff --git a/android/app/src/releasePinnedRoot/res/xml/network_security_config.xml b/android/app/src/releasePinnedRoot/res/xml/network_security_config.xml new file mode 100644 index 0000000..764bd77 --- /dev/null +++ b/android/app/src/releasePinnedRoot/res/xml/network_security_config.xml @@ -0,0 +1,16 @@ + + + + + + + + + + icehack.asuscomm.com + + + + + diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt index 56ba91a..d89bdb8 100644 --- a/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/KvittEmptyScreenScreenshotTest.kt @@ -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() diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt new file mode 100644 index 0000000..a603b95 --- /dev/null +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt @@ -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")) + } +} diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt new file mode 100644 index 0000000..fb187f3 --- /dev/null +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/push/NotificationTextsTest.kt @@ -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))) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index d4496d1..c291bf0 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" }