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:
@@ -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 {
|
plugins {
|
||||||
id("kvitt.android.library")
|
id("kvitt.android.library")
|
||||||
|
id("kvitt.android.hilt")
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:model"))
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
plugins {
|
||||||
id("kvitt.android.application")
|
id("kvitt.android.application")
|
||||||
id("kvitt.android.compose")
|
id("kvitt.android.compose")
|
||||||
id("kvitt.android.hilt")
|
id("kvitt.android.hilt")
|
||||||
|
alias(libs.plugins.google.services)
|
||||||
alias(libs.plugins.roborazzi)
|
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 {
|
android {
|
||||||
namespace = "se.hagstedt.kvitt"
|
namespace = "se.hagstedt.kvitt"
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
@@ -18,10 +32,28 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
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 {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".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:settle"))
|
||||||
implementation(project(":feature:analytics"))
|
implementation(project(":feature:analytics"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
debugImplementation(project(":core:fakes"))
|
|
||||||
testImplementation(project(":core:fakes"))
|
testImplementation(project(":core:fakes"))
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
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)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.roborazzi)
|
testImplementation(libs.roborazzi)
|
||||||
testImplementation(libs.roborazzi.compose)
|
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>
|
||||||
@@ -1,21 +1,61 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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
|
<application
|
||||||
android:name=".KvittApplication"
|
android:name=".KvittApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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
|
package se.hagstedt.kvitt
|
||||||
|
|
||||||
import android.app.Application
|
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 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
|
@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
|
package se.hagstedt.kvitt
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.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
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
@Inject
|
||||||
|
lateinit var clock: Clock
|
||||||
|
|
||||||
|
private val app: AppViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
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 {
|
setContent {
|
||||||
KvittTheme {
|
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
|
package se.hagstedt.kvitt
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
@@ -18,7 +19,7 @@ import se.hagstedt.kvitt.design.KvittTheme
|
|||||||
*/
|
*/
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi")
|
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi", application = Application::class)
|
||||||
class KvittEmptyScreenScreenshotTest {
|
class KvittEmptyScreenScreenshotTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val compose = createComposeRule()
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,16 @@ vico = "3.3.1"
|
|||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
robolectric = "4.17"
|
robolectric = "4.17"
|
||||||
roborazzi = "1.76.0"
|
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]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
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-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-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
|
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 = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
|
||||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", 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" }
|
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" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||||
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
|
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" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
room3 = { id = "androidx.room3", version.ref = "room3" }
|
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" }
|
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||||
|
|||||||
Reference in New Issue
Block a user