From dc7f7e7007d06e6bc27a706a74fd5d76e388f301 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 19:17:06 +0200 Subject: [PATCH 01/17] harden the application a bit --- app/src/debug/res/xml/network_security_config.xml | 8 ++++++++ app/src/main/AndroidManifest.xml | 2 +- .../com/pledgerio/app/data/local/LocalDataCleaner.kt | 11 ++++++++--- .../pledgerio/app/data/remote/api/AuthInterceptor.kt | 2 +- .../main/java/com/pledgerio/app/di/NetworkModule.kt | 7 ++++++- app/src/main/res/xml/network_security_config.xml | 2 +- 6 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 app/src/debug/res/xml/network_security_config.xml diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..d7b4192 --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3fd6433..1ad7cfc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ , private val transactionTemplateStore: Lazy, private val reportsOverviewCache: ReportsOverviewCache, @@ -50,7 +53,9 @@ class LocalDataCleaner @Inject constructor( } /** For non-suspend callers (e.g. [com.pledgerio.app.data.remote.api.AuthInterceptor]). */ - fun clearAllUserDataBlocking() { - runBlocking { clearAllUserData() } + fun clearAllUserDataAsync() { + applicationScope.launch { + clearAllUserData() + } } } diff --git a/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt b/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt index 79d21fb..1fe3915 100644 --- a/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt +++ b/app/src/main/java/com/pledgerio/app/data/remote/api/AuthInterceptor.kt @@ -45,7 +45,7 @@ class AuthInterceptor @Inject constructor( return chain.proceed(retryRequest) } } - localDataCleaner.clearAllUserDataBlocking() + localDataCleaner.clearAllUserDataAsync() sessionManager.clearAuthTokens() } diff --git a/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt b/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt index 7e2b0bf..cce18b5 100644 --- a/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt +++ b/app/src/main/java/com/pledgerio/app/di/NetworkModule.kt @@ -2,6 +2,7 @@ package com.pledgerio.app.di import android.content.Context import coil.ImageLoader +import com.pledgerio.app.BuildConfig import com.pledgerio.app.data.remote.api.AuthInterceptor import com.pledgerio.app.data.remote.api.DynamicBaseUrlInterceptor import com.pledgerio.app.data.remote.api.IssueLogInterceptor @@ -59,7 +60,11 @@ object NetworkModule { .addInterceptor(authInterceptor) .addInterceptor(issueLogInterceptor) .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } }) .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index d7b4192..683208f 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,6 +1,6 @@ - + From 5e07a4e43397eecc0aeccc4e9c189a1a8d58583e Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 19:27:05 +0200 Subject: [PATCH 02/17] Perform auto linting --- .github/workflows/ci.yml | 3 + app/build.gradle.kts | 10 + app/lint-baseline.xml | 2031 +++++++++++++++++ app/schemas/.gitkeep | 1 + .../6.json | 458 ++++ .../app/data/local/PledgerDatabase.kt | 2 +- .../data/remote/api/AuthInterceptorTest.kt | 113 + 7 files changed, 2617 insertions(+), 1 deletion(-) create mode 100644 app/lint-baseline.xml create mode 100644 app/schemas/.gitkeep create mode 100644 app/schemas/com.pledgerio.app.data.local.PledgerDatabase/6.json create mode 100644 app/src/test/java/com/pledgerio/app/data/remote/api/AuthInterceptorTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b99244a..ad246e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest --no-daemon + - name: Run Android lint + run: ./gradlew lintDebug --no-daemon + - name: Build debug APK run: ./gradlew assembleDebug --no-daemon diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e752b43..6bac56e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,12 @@ plugins { alias(libs.plugins.ksp) } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") + arg("room.generateKotlin", "true") +} + android { namespace = "com.pledgerio.app" compileSdk = libs.versions.compileSdk.get().toInt() @@ -58,6 +64,10 @@ android { compose = true buildConfig = true } + + lint { + baseline = file("lint-baseline.xml") + } } dependencies { diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 0000000..7684f0e --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,2031 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/.gitkeep b/app/schemas/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/schemas/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/schemas/com.pledgerio.app.data.local.PledgerDatabase/6.json b/app/schemas/com.pledgerio.app.data.local.PledgerDatabase/6.json new file mode 100644 index 0000000..06e01bd --- /dev/null +++ b/app/schemas/com.pledgerio.app.data.local.PledgerDatabase/6.json @@ -0,0 +1,458 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "f4786ec343404eb638e79c82af2da94f", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `currency` TEXT NOT NULL, `balance` REAL NOT NULL, `type` TEXT NOT NULL, `iconFileCode` TEXT, `iban` TEXT, `bic` TEXT, `openingBalance` REAL NOT NULL, `lastActivity` TEXT, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconFileCode", + "columnName": "iconFileCode", + "affinity": "TEXT" + }, + { + "fieldPath": "iban", + "columnName": "iban", + "affinity": "TEXT" + }, + { + "fieldPath": "bic", + "columnName": "bic", + "affinity": "TEXT" + }, + { + "fieldPath": "openingBalance", + "columnName": "openingBalance", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `description` TEXT NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `sourceAccountId` INTEGER, `sourceAccountName` TEXT NOT NULL, `destinationAccountId` INTEGER, `destinationAccountName` TEXT NOT NULL, `categoryName` TEXT, `budgetName` TEXT, `tags` TEXT NOT NULL, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceAccountId", + "columnName": "sourceAccountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceAccountName", + "columnName": "sourceAccountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationAccountId", + "columnName": "destinationAccountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "destinationAccountName", + "columnName": "destinationAccountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryName", + "columnName": "categoryName", + "affinity": "TEXT" + }, + { + "fieldPath": "budgetName", + "columnName": "budgetName", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `amount` REAL NOT NULL, `spent` REAL NOT NULL, `period` TEXT NOT NULL, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "spent", + "columnName": "spent", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "period", + "columnName": "period", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `parentId` INTEGER, `lastSynced` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "currencies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `decimalPlaces` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimalPlaces", + "columnName": "decimalPlaces", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + } + }, + { + "tableName": "contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_contracts_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_contracts_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "expense_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `expected` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expected", + "columnName": "expected", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_expense_groups_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_expense_groups_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "account_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + } + }, + { + "tableName": "sync_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f4786ec343404eb638e79c82af2da94f')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt index f2c3c1f..1e2e1b9 100644 --- a/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt +++ b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabase.kt @@ -38,7 +38,7 @@ import com.pledgerio.app.data.local.entity.TransactionEntity SyncMetadataEntity::class, ], version = 6, - exportSchema = false, + exportSchema = true, ) @TypeConverters(Converters::class) abstract class PledgerDatabase : RoomDatabase() { diff --git a/app/src/test/java/com/pledgerio/app/data/remote/api/AuthInterceptorTest.kt b/app/src/test/java/com/pledgerio/app/data/remote/api/AuthInterceptorTest.kt new file mode 100644 index 0000000..e609f9b --- /dev/null +++ b/app/src/test/java/com/pledgerio/app/data/remote/api/AuthInterceptorTest.kt @@ -0,0 +1,113 @@ +package com.pledgerio.app.data.remote.api + +import com.pledgerio.app.data.local.LocalDataCleaner +import com.pledgerio.app.util.SessionManager +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class AuthInterceptorTest { + + private lateinit var sessionManager: SessionManager + private lateinit var tokenRefresher: TokenRefresher + private lateinit var localDataCleaner: LocalDataCleaner + private lateinit var interceptor: AuthInterceptor + + @Before + fun setUp() { + sessionManager = mockk(relaxed = true) + tokenRefresher = mockk(relaxed = true) + localDataCleaner = mockk(relaxed = true) + interceptor = AuthInterceptor(sessionManager, tokenRefresher, localDataCleaner) + } + + @Test + fun `intercept retries with refreshed token after unauthorized response`() { + val original = Request.Builder() + .url("https://example.com/v2/api/accounts") + .build() + val chain = mockk() + + every { chain.request() } returns original + every { sessionManager.getToken() } returnsMany listOf("old-token", "new-token") + every { tokenRefresher.refreshToken() } returns true + every { localDataCleaner.clearAllUserDataAsync() } just Runs + + val capturedRequests = mutableListOf() + every { chain.proceed(capture(capturedRequests)) } returnsMany listOf( + responseFor(original, 401), + responseFor(original, 200), + ) + + val result = interceptor.intercept(chain) + + assertEquals(200, result.code) + assertEquals(2, capturedRequests.size) + assertEquals("Bearer old-token", capturedRequests[0].header("Authorization")) + assertEquals("Bearer new-token", capturedRequests[1].header("Authorization")) + verify(exactly = 1) { tokenRefresher.refreshTokenIfNeeded() } + verify(exactly = 1) { tokenRefresher.refreshToken() } + verify(exactly = 0) { localDataCleaner.clearAllUserDataAsync() } + verify(exactly = 0) { sessionManager.clearAuthTokens() } + } + + @Test + fun `intercept clears auth state when token refresh fails after unauthorized`() { + val original = Request.Builder() + .url("https://example.com/v2/api/accounts") + .build() + val chain = mockk() + + every { chain.request() } returns original + every { sessionManager.getToken() } returns "old-token" + every { tokenRefresher.refreshToken() } returns false + every { chain.proceed(any()) } returns responseFor(original, 401) + every { localDataCleaner.clearAllUserDataAsync() } just Runs + every { sessionManager.clearAuthTokens() } just Runs + + val result = interceptor.intercept(chain) + + assertEquals(401, result.code) + verify(exactly = 1) { tokenRefresher.refreshTokenIfNeeded() } + verify(exactly = 1) { tokenRefresher.refreshToken() } + verify(exactly = 1) { localDataCleaner.clearAllUserDataAsync() } + verify(exactly = 1) { sessionManager.clearAuthTokens() } + } + + @Test + fun `intercept skips proactive refresh for auth endpoints`() { + val authRequest = Request.Builder() + .url("https://example.com/v2/api/security/authenticate") + .build() + val chain = mockk() + + every { chain.request() } returns authRequest + every { sessionManager.getToken() } returns null + every { chain.proceed(any()) } returns responseFor(authRequest, 200) + + interceptor.intercept(chain) + + verify(exactly = 0) { tokenRefresher.refreshTokenIfNeeded() } + verify(exactly = 0) { tokenRefresher.refreshToken() } + } + + private fun responseFor(request: Request, code: Int): Response = + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("test") + .body("{}".toByteArray().toResponseBody("application/json".toMediaType())) + .build() +} From 4d0d3c980b3beea62064b61615a63ef50cfdd48a Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 19:35:14 +0200 Subject: [PATCH 03/17] Improve migrations --- .../app/data/cache/ReportsOverviewCache.kt | 27 +++++++------ .../data/local/PledgerDatabaseMigrations.kt | 31 +++++++++++++++ .../com/pledgerio/app/di/DatabaseModule.kt | 3 +- .../com/pledgerio/app/di/RepositoryModule.kt | 6 +++ .../domain/repository/ReportsOverviewStore.kt | 30 +++++++++++++++ .../app/ui/reports/ReportsViewModel.kt | 4 +- .../local/PledgerDatabaseMigrationsTest.kt | 38 +++++++++++++++++++ 7 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/pledgerio/app/data/local/PledgerDatabaseMigrations.kt create mode 100644 app/src/main/java/com/pledgerio/app/domain/repository/ReportsOverviewStore.kt create mode 100644 app/src/test/java/com/pledgerio/app/data/local/PledgerDatabaseMigrationsTest.kt diff --git a/app/src/main/java/com/pledgerio/app/data/cache/ReportsOverviewCache.kt b/app/src/main/java/com/pledgerio/app/data/cache/ReportsOverviewCache.kt index 141a20a..0decb5d 100644 --- a/app/src/main/java/com/pledgerio/app/data/cache/ReportsOverviewCache.kt +++ b/app/src/main/java/com/pledgerio/app/data/cache/ReportsOverviewCache.kt @@ -1,6 +1,7 @@ package com.pledgerio.app.data.cache import com.pledgerio.app.domain.model.ReportsOverview +import com.pledgerio.app.domain.repository.ReportsOverviewStore import java.time.YearMonth import javax.inject.Inject import javax.inject.Singleton @@ -12,35 +13,39 @@ import kotlinx.coroutines.sync.withLock * Cleared on logout via [com.pledgerio.app.data.local.LocalDataCleaner]. */ @Singleton -class ReportsOverviewCache @Inject constructor() { +class ReportsOverviewCache @Inject constructor() : ReportsOverviewStore { data class Entry( - val overview: ReportsOverview, - val fetchedAtMillis: Long, - ) { - fun isFresh( + override val overview: ReportsOverview, + override val fetchedAtMillis: Long, + ) : ReportsOverviewStore.Entry { + override fun isFresh( month: YearMonth, - now: YearMonth = YearMonth.now(), - nowMillis: Long = System.currentTimeMillis(), + now: YearMonth, + nowMillis: Long, ): Boolean = ReportsCachePolicy.isFresh(fetchedAtMillis, month, now, nowMillis) } private val mutex = Mutex() private val entries = mutableMapOf() - suspend fun get(month: YearMonth): Entry? = mutex.withLock { entries[month] } + override suspend fun get(month: YearMonth): Entry? = mutex.withLock { entries[month] } - suspend fun put(month: YearMonth, overview: ReportsOverview, fetchedAtMillis: Long = System.currentTimeMillis()) { + override suspend fun put( + month: YearMonth, + overview: ReportsOverview, + fetchedAtMillis: Long, + ) { mutex.withLock { entries[month] = Entry(overview, fetchedAtMillis) } } - suspend fun invalidate(month: YearMonth) { + override suspend fun invalidate(month: YearMonth) { mutex.withLock { entries.remove(month) } } - suspend fun clearAll() { + override suspend fun clearAll() { mutex.withLock { entries.clear() } } } diff --git a/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabaseMigrations.kt b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabaseMigrations.kt new file mode 100644 index 0000000..0d76ead --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/data/local/PledgerDatabaseMigrations.kt @@ -0,0 +1,31 @@ +package com.pledgerio.app.data.local + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +object PledgerDatabaseMigrations { + + val MIGRATION_5_6: Migration = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `account_types` ( + `code` TEXT NOT NULL, + PRIMARY KEY(`code`) + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `sync_metadata` ( + `key` TEXT NOT NULL, + `lastSyncedAt` INTEGER NOT NULL, + PRIMARY KEY(`key`) + ) + """.trimIndent(), + ) + } + } + + val ALL: Array = arrayOf(MIGRATION_5_6) +} diff --git a/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt b/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt index 0dc6b3b..9841a7f 100644 --- a/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/pledgerio/app/di/DatabaseModule.kt @@ -3,6 +3,7 @@ package com.pledgerio.app.di import android.content.Context import androidx.room.Room import com.pledgerio.app.data.local.PledgerDatabase +import com.pledgerio.app.data.local.PledgerDatabaseMigrations import com.pledgerio.app.data.local.dao.AccountDao import com.pledgerio.app.data.local.dao.AccountTypeDao import com.pledgerio.app.data.local.dao.BudgetDao @@ -32,7 +33,7 @@ object DatabaseModule { PledgerDatabase::class.java, "pledger_database" ) - .fallbackToDestructiveMigration() + .addMigrations(*PledgerDatabaseMigrations.ALL) .build() @Provides diff --git a/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt b/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt index 2173d6c..68f1da1 100644 --- a/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt @@ -10,6 +10,7 @@ import com.pledgerio.app.data.repository.IssueReportRepositoryImpl import com.pledgerio.app.data.repository.ReportRepositoryImpl import com.pledgerio.app.data.repository.TagRepositoryImpl import com.pledgerio.app.data.repository.TransactionRepositoryImpl +import com.pledgerio.app.data.cache.ReportsOverviewCache import com.pledgerio.app.domain.repository.AccountRepository import com.pledgerio.app.domain.repository.AuthRepository import com.pledgerio.app.domain.repository.BudgetRepository @@ -18,6 +19,7 @@ import com.pledgerio.app.domain.repository.ContractRepository import com.pledgerio.app.domain.repository.CurrencyRepository import com.pledgerio.app.domain.repository.IssueReportRepository import com.pledgerio.app.domain.repository.ReportRepository +import com.pledgerio.app.domain.repository.ReportsOverviewStore import com.pledgerio.app.domain.repository.TagRepository import com.pledgerio.app.domain.repository.TransactionRepository import dagger.Binds @@ -69,4 +71,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindReportRepository(impl: ReportRepositoryImpl): ReportRepository + + @Binds + @Singleton + abstract fun bindReportsOverviewStore(impl: ReportsOverviewCache): ReportsOverviewStore } diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/ReportsOverviewStore.kt b/app/src/main/java/com/pledgerio/app/domain/repository/ReportsOverviewStore.kt new file mode 100644 index 0000000..6ef4a75 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/domain/repository/ReportsOverviewStore.kt @@ -0,0 +1,30 @@ +package com.pledgerio.app.domain.repository + +import com.pledgerio.app.domain.model.ReportsOverview +import java.time.YearMonth + +interface ReportsOverviewStore { + + interface Entry { + val overview: ReportsOverview + val fetchedAtMillis: Long + + fun isFresh( + month: YearMonth, + now: YearMonth = YearMonth.now(), + nowMillis: Long = System.currentTimeMillis(), + ): Boolean + } + + suspend fun get(month: YearMonth): Entry? + + suspend fun put( + month: YearMonth, + overview: ReportsOverview, + fetchedAtMillis: Long = System.currentTimeMillis(), + ) + + suspend fun invalidate(month: YearMonth) + + suspend fun clearAll() +} diff --git a/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt index db963ec..274b963 100644 --- a/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/reports/ReportsViewModel.kt @@ -2,13 +2,13 @@ package com.pledgerio.app.ui.reports import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.pledgerio.app.data.cache.ReportsOverviewCache import com.pledgerio.app.domain.model.BudgetPerformanceItem import com.pledgerio.app.domain.model.DatedAmount import com.pledgerio.app.domain.model.IncomeExpenseSummary import com.pledgerio.app.domain.model.PartitionAmount import com.pledgerio.app.domain.model.ReportsOverview import com.pledgerio.app.domain.repository.ReportRepository +import com.pledgerio.app.domain.repository.ReportsOverviewStore import com.pledgerio.app.util.Resource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -45,7 +45,7 @@ data class ReportsUiState( @HiltViewModel class ReportsViewModel @Inject constructor( private val reportRepository: ReportRepository, - private val overviewCache: ReportsOverviewCache, + private val overviewCache: ReportsOverviewStore, ) : ViewModel() { private val _uiState = MutableStateFlow(ReportsUiState()) diff --git a/app/src/test/java/com/pledgerio/app/data/local/PledgerDatabaseMigrationsTest.kt b/app/src/test/java/com/pledgerio/app/data/local/PledgerDatabaseMigrationsTest.kt new file mode 100644 index 0000000..da118f7 --- /dev/null +++ b/app/src/test/java/com/pledgerio/app/data/local/PledgerDatabaseMigrationsTest.kt @@ -0,0 +1,38 @@ +package com.pledgerio.app.data.local + +import androidx.sqlite.db.SupportSQLiteDatabase +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class PledgerDatabaseMigrationsTest { + + @Test + fun `migration 5 to 6 creates account_types and sync_metadata tables`() { + val db = mockk(relaxed = true) + + PledgerDatabaseMigrations.MIGRATION_5_6.migrate(db) + + verify { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `account_types` ( + `code` TEXT NOT NULL, + PRIMARY KEY(`code`) + ) + """.trimIndent(), + ) + } + verify { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `sync_metadata` ( + `key` TEXT NOT NULL, + `lastSyncedAt` INTEGER NOT NULL, + PRIMARY KEY(`key`) + ) + """.trimIndent(), + ) + } + } +} From 346d8aabbe45594ec63e3f15f35373add2d0cbf0 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 19:49:53 +0200 Subject: [PATCH 04/17] Split some code --- .../app/ui/budgets/BudgetsViewModel.kt | 8 +-- .../app/ui/dashboard/DashboardViewModel.kt | 56 +++++-------------- .../app/ui/onboarding/LoginViewModel.kt | 6 +- .../ui/transactions/TransactionsViewModel.kt | 6 +- .../app/ui/budgets/BudgetsViewModelTest.kt | 12 ++-- .../transactions/TransactionsViewModelTest.kt | 7 ++- 6 files changed, 36 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt index 4f79013..bc7e4c8 100644 --- a/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/budgets/BudgetsViewModel.kt @@ -4,15 +4,15 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pledgerio.app.domain.model.Budget +import com.pledgerio.app.domain.model.BudgetListState import com.pledgerio.app.domain.usecase.CreateInitialBudgetUseCase -import com.pledgerio.app.domain.repository.BudgetRepository +import com.pledgerio.app.domain.usecase.GetBudgetsUseCase import com.pledgerio.app.domain.usecase.SaveBudgetExpenseUseCase import com.pledgerio.app.util.Resource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import com.pledgerio.app.domain.model.BudgetListState import kotlinx.coroutines.Job import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -53,7 +53,7 @@ data class BudgetsUiState( @HiltViewModel class BudgetsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val budgetRepository: BudgetRepository, + private val getBudgetsUseCase: GetBudgetsUseCase, private val createInitialBudgetUseCase: CreateInitialBudgetUseCase, private val saveBudgetExpenseUseCase: SaveBudgetExpenseUseCase, ) : ViewModel() { @@ -248,7 +248,7 @@ class BudgetsViewModel @Inject constructor( val month = _uiState.value.currentMonth loadJob?.cancel() loadJob = viewModelScope.launch { - budgetRepository.getBudgets(month.year, month.monthValue).collect { result -> + getBudgetsUseCase(month.year, month.monthValue).collect { result -> when (result) { is Resource.Loading -> _uiState.update { it.copy(isLoading = true) } is Resource.Success -> { diff --git a/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt index 89fd0f9..3cb368c 100644 --- a/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/dashboard/DashboardViewModel.kt @@ -5,12 +5,12 @@ import androidx.lifecycle.viewModelScope import com.pledgerio.app.domain.model.Account import com.pledgerio.app.domain.model.FinanceExperienceMode import com.pledgerio.app.domain.model.Transaction -import com.pledgerio.app.domain.repository.AccountRepository import com.pledgerio.app.domain.repository.CurrencyRepository -import com.pledgerio.app.domain.repository.TransactionRepository +import com.pledgerio.app.domain.usecase.GetDashboardDataUseCase import com.pledgerio.app.util.Resource import com.pledgerio.app.util.UserPreferences import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,14 +34,14 @@ data class DashboardUiState( @HiltViewModel class DashboardViewModel @Inject constructor( - private val accountRepository: AccountRepository, - private val transactionRepository: TransactionRepository, + private val getDashboardDataUseCase: GetDashboardDataUseCase, private val currencyRepository: CurrencyRepository, private val userPreferences: UserPreferences, ) : ViewModel() { private val _uiState = MutableStateFlow(DashboardUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var dashboardJob: Job? = null init { viewModelScope.launch { currencyRepository.sync() } @@ -65,32 +65,27 @@ class DashboardViewModel @Inject constructor( /** Reload recent transactions when returning to the dashboard (e.g. after creating one). */ fun refreshRecentTransactions() { - viewModelScope.launch { - transactionRepository.getRecentTransactions(5).collect { result -> - when (result) { - is Resource.Success -> applyRecentTransactions(result.data) - else -> Unit - } - } - } + loadDashboard() } private fun loadDashboard() { - viewModelScope.launch { - accountRepository.getAccounts().collect { result -> + dashboardJob?.cancel() + dashboardJob = viewModelScope.launch { + getDashboardDataUseCase().collect { result -> when (result) { is Resource.Loading -> { _uiState.update { it.copy(isLoading = true) } } is Resource.Success -> { - val accounts = result.data - val netWorth = accounts.sumOf { it.balance } _uiState.update { it.copy( isLoading = false, isRefreshing = false, - accounts = accounts, - netWorth = netWorth, + accounts = result.data.accounts, + recentTransactions = result.data.recentTransactions, + netWorth = result.data.netWorth, + monthlyIncome = result.data.monthlyIncome, + monthlyExpense = result.data.monthlyExpense, currency = userPreferences.displayCurrencyCode.value, error = null, lastUpdatedAtMillis = System.currentTimeMillis(), @@ -109,30 +104,5 @@ class DashboardViewModel @Inject constructor( } } } - - viewModelScope.launch { - transactionRepository.getRecentTransactions(5).collect { result -> - when (result) { - is Resource.Success -> applyRecentTransactions(result.data) - else -> Unit - } - } - } - } - - private fun applyRecentTransactions(transactions: List) { - val income = transactions - .filter { it.type == com.pledgerio.app.domain.model.TransactionType.DEBIT } - .sumOf { it.amount } - val expense = transactions - .filter { it.type == com.pledgerio.app.domain.model.TransactionType.CREDIT } - .sumOf { it.amount } - _uiState.update { - it.copy( - recentTransactions = transactions, - monthlyIncome = income, - monthlyExpense = expense, - ) - } } } diff --git a/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt index 41f7dbe..a84c2c6 100644 --- a/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/onboarding/LoginViewModel.kt @@ -2,8 +2,8 @@ package com.pledgerio.app.ui.onboarding import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.pledgerio.app.domain.repository.AuthRepository import com.pledgerio.app.domain.repository.CurrencyRepository +import com.pledgerio.app.domain.usecase.LoginUseCase import com.pledgerio.app.util.Resource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +22,7 @@ data class LoginUiState( @HiltViewModel class LoginViewModel @Inject constructor( - private val authRepository: AuthRepository, + private val loginUseCase: LoginUseCase, private val currencyRepository: CurrencyRepository, ) : ViewModel() { @@ -47,7 +47,7 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } - when (val result = authRepository.login(state.username, state.password)) { + when (val result = loginUseCase(state.username, state.password)) { is Resource.Success -> { currencyRepository.sync() _uiState.update { it.copy(isLoading = false) } diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt index 41ab654..5d3ad08 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionsViewModel.kt @@ -12,6 +12,7 @@ import com.pledgerio.app.domain.repository.BudgetRepository import com.pledgerio.app.domain.repository.CategoryRepository import com.pledgerio.app.domain.repository.ContractRepository import com.pledgerio.app.domain.repository.TransactionRepository +import com.pledgerio.app.domain.usecase.GetTransactionsUseCase import com.pledgerio.app.util.Resource import com.pledgerio.app.util.SearchDefaults import com.pledgerio.app.util.UserPreferences @@ -81,6 +82,7 @@ data class TransactionsUiState( @HiltViewModel class TransactionsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val getTransactionsUseCase: GetTransactionsUseCase, private val transactionRepository: TransactionRepository, private val categoryRepository: CategoryRepository, private val budgetRepository: BudgetRepository, @@ -362,7 +364,7 @@ class TransactionsViewModel @Inject constructor( val startDate = state.currentMonth.atDay(1) val endDate = state.currentMonth.atEndOfMonth() - val result = transactionRepository.getTransactionsPage( + val result = getTransactionsUseCase( startDate = startDate, endDate = endDate, type = state.selectedType, @@ -411,7 +413,7 @@ class TransactionsViewModel @Inject constructor( val startDate = state.currentMonth.atDay(1) val endDate = state.currentMonth.atEndOfMonth() - val result = transactionRepository.getTransactionsPage( + val result = getTransactionsUseCase( startDate = startDate, endDate = endDate, type = state.selectedType, diff --git a/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt b/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt index e13190b..851355e 100644 --- a/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt +++ b/app/src/test/java/com/pledgerio/app/ui/budgets/BudgetsViewModelTest.kt @@ -2,13 +2,14 @@ package com.pledgerio.app.ui.budgets import androidx.lifecycle.SavedStateHandle import com.pledgerio.app.domain.model.BudgetListState -import com.pledgerio.app.domain.repository.BudgetRepository import com.pledgerio.app.domain.usecase.CreateInitialBudgetUseCase +import com.pledgerio.app.domain.usecase.GetBudgetsUseCase import com.pledgerio.app.domain.usecase.SaveBudgetExpenseUseCase import com.pledgerio.app.util.Resource import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher @@ -22,17 +23,18 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class BudgetsViewModelTest { private val testDispatcher = StandardTestDispatcher() - private val budgetRepository = mockk() + private val getBudgetsUseCase = mockk() private val createInitialBudgetUseCase = mockk() private val saveBudgetExpenseUseCase = mockk(relaxed = true) private val savedStateHandle = SavedStateHandle(mapOf("year" to -1, "month" to -1)) private fun createViewModel() = BudgetsViewModel( savedStateHandle = savedStateHandle, - budgetRepository = budgetRepository, + getBudgetsUseCase = getBudgetsUseCase, createInitialBudgetUseCase = createInitialBudgetUseCase, saveBudgetExpenseUseCase = saveBudgetExpenseUseCase, ) @@ -49,7 +51,7 @@ class BudgetsViewModelTest { @Test fun `load sets needsInitialSetup when repository returns 404 state`() = runTest { - every { budgetRepository.getBudgets(any(), any()) } returns flowOf( + every { getBudgetsUseCase(any(), any()) } returns flowOf( Resource.Loading, Resource.Success(BudgetListState(needsInitialSetup = true)), ) @@ -64,7 +66,7 @@ class BudgetsViewModelTest { @Test fun `createInitialBudget reloads after success`() = runTest { var loadCount = 0 - every { budgetRepository.getBudgets(any(), any()) } answers { + every { getBudgetsUseCase(any(), any()) } answers { loadCount++ if (loadCount == 1) { flowOf( diff --git a/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt b/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt index e79ac63..054b3a6 100644 --- a/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt +++ b/app/src/test/java/com/pledgerio/app/ui/transactions/TransactionsViewModelTest.kt @@ -7,6 +7,7 @@ import com.pledgerio.app.domain.repository.PagedResult import com.pledgerio.app.domain.repository.TransactionRepository import androidx.lifecycle.SavedStateHandle import com.pledgerio.app.domain.model.FinanceExperienceMode +import com.pledgerio.app.domain.usecase.GetTransactionsUseCase import com.pledgerio.app.util.MainDispatcherRule import com.pledgerio.app.util.Resource import com.pledgerio.app.util.UserPreferences @@ -32,6 +33,7 @@ class TransactionsViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val transactionRepository = mockk() + private val getTransactionsUseCase = mockk() private val categoryRepository = mockk(relaxed = true) private val budgetRepository = mockk(relaxed = true) private val contractRepository = mockk(relaxed = true) @@ -47,6 +49,7 @@ class TransactionsViewModelTest { private fun createViewModel() = TransactionsViewModel( savedStateHandle = savedStateHandle, + getTransactionsUseCase = getTransactionsUseCase, transactionRepository = transactionRepository, categoryRepository = categoryRepository, budgetRepository = budgetRepository, @@ -58,7 +61,7 @@ class TransactionsViewModelTest { fun `navigateToMonth keeps selected month when API returns no transactions`() = runTest { val target = YearMonth.of(2024, 3) coEvery { - transactionRepository.getTransactionsPage( + getTransactionsUseCase( startDate = any(), endDate = any(), accountId = any(), @@ -88,7 +91,7 @@ class TransactionsViewModelTest { @Test fun `nextMonth from previous month keeps current month when empty`() = runTest { coEvery { - transactionRepository.getTransactionsPage( + getTransactionsUseCase( startDate = any(), endDate = any(), accountId = any(), From a1a415e90b3eb82d918c0bc5bca64f07ac670628 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 19:59:10 +0200 Subject: [PATCH 05/17] Harden invoice scan boundaries and CI quality gates. Route invoice scanning through domain contracts/use cases, improve worker retry/offline fallback behavior, add androidTest smoke coverage in CI, and update ADR/docs to reflect the new security and quality baseline. Co-authored-by: Cursor --- .github/workflows/ci.yml | 29 +++ README.md | 4 +- .../pledgerio/app/ui/MainActivitySmokeTest.kt | 22 ++ .../main/java/com/pledgerio/app/PledgerApp.kt | 1 - .../app/data/local/LocalDataCleaner.kt | 3 +- .../app/data/ocr/InvoiceTextExtractor.kt | 6 +- .../repository/TransactionRepositoryImpl.kt | 14 +- .../com/pledgerio/app/di/RepositoryModule.kt | 6 + .../domain/repository/InvoiceTextReader.kt | 7 + .../usecase/ProcessInvoiceScanUseCase.kt | 25 +++ .../app/ui/settings/SettingsViewModel.kt | 2 - .../transactions/TransactionDetailScreen.kt | 4 - .../detail/TransactionDetailContent.kt | 6 +- .../transactions/scan/InvoiceScanViewModel.kt | 12 +- .../pledgerio/app/util/CurrencyProvider.kt | 11 - .../java/com/pledgerio/app/util/Extensions.kt | 12 +- .../java/com/pledgerio/app/util/SyncWorker.kt | 15 +- docs/ARCHITECTURE.md | 6 +- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 210 ++++++++++++++++++ docs/adr/015-stale-while-revalidate-cache.md | 4 +- .../adr/019-security-and-quality-hardening.md | 61 +++++ docs/adr/README.md | 1 + 22 files changed, 415 insertions(+), 46 deletions(-) create mode 100644 app/src/androidTest/java/com/pledgerio/app/ui/MainActivitySmokeTest.kt create mode 100644 app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt create mode 100644 app/src/main/java/com/pledgerio/app/domain/usecase/ProcessInvoiceScanUseCase.kt create mode 100644 docs/CODEBASE_IMPROVEMENT_AUDIT.md create mode 100644 docs/adr/019-security-and-quality-hardening.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad246e4..3bde68b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,32 @@ jobs: path: app/build/outputs/apk/debug/*.apk if-no-files-found: error retention-days: 14 + + instrumented-tests: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: temurin + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Grant execute permission for Gradle wrapper + run: chmod +x gradlew + + - name: Run instrumented smoke tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + arch: x86_64 + profile: pixel_6 + script: ./gradlew connectedDebugAndroidTest --no-daemon diff --git a/README.md b/README.md index 967ea6a..e298a53 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A native Android client for [Pledger.io](https://github.com/pledger-io) — a se | DI | Hilt (KSP) | | Networking | Retrofit + OkHttp + Moshi | | Images | Coil (authenticated via shared OkHttp client) | -| Database | Room (offline cache, v3 schema) | +| Database | Room (offline cache, v6 schema with explicit migrations) | | Async | Coroutines + Flow | | Navigation | Jetpack Navigation Compose | | Background work | WorkManager | @@ -144,7 +144,7 @@ Requires **JDK 21**. | Workflow | Trigger | Result | |----------|---------|--------| -| [CI](.github/workflows/ci.yml) | Push / PR to `main` or `master` | Unit tests + debug APK artifact | +| [CI](.github/workflows/ci.yml) | Push / PR to `main` or `master` | Unit tests + lint + instrumented smoke test + debug APK artifact | | [Release](.github/workflows/release.yml) | **Publish** a GitHub Release, or manual run | Release APK attached to the release | **Distributing an APK on GitHub Release** diff --git a/app/src/androidTest/java/com/pledgerio/app/ui/MainActivitySmokeTest.kt b/app/src/androidTest/java/com/pledgerio/app/ui/MainActivitySmokeTest.kt new file mode 100644 index 0000000..6e0181d --- /dev/null +++ b/app/src/androidTest/java/com/pledgerio/app/ui/MainActivitySmokeTest.kt @@ -0,0 +1,22 @@ +package com.pledgerio.app.ui + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.assertIsDisplayed +import com.pledgerio.app.MainActivity +import com.pledgerio.app.R +import org.junit.Rule +import org.junit.Test + +class MainActivitySmokeTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun appLaunchesAndShowsServerSetup() { + composeRule.onNodeWithText( + composeRule.activity.getString(R.string.server_setup_url_label), + ).assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/pledgerio/app/PledgerApp.kt b/app/src/main/java/com/pledgerio/app/PledgerApp.kt index 3a548da..443dc11 100644 --- a/app/src/main/java/com/pledgerio/app/PledgerApp.kt +++ b/app/src/main/java/com/pledgerio/app/PledgerApp.kt @@ -51,7 +51,6 @@ class PledgerApp : Application(), Configuration.Provider { LocaleManager.apply(userPreferences.appLocaleOnce()) } appLog.install() - CurrencyProvider.setInstance(currencyProvider) Coil.setImageLoader(imageLoader) SyncWorker.schedule(this) ProcessLifecycleOwner.get().lifecycle.addObserver(biometricLockManager) diff --git a/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt b/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt index e30ce4f..fb0be5f 100644 --- a/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt +++ b/app/src/main/java/com/pledgerio/app/data/local/LocalDataCleaner.kt @@ -28,6 +28,7 @@ class LocalDataCleaner @Inject constructor( private val database: PledgerDatabase, @ApplicationContext private val context: Context, @ApplicationScope private val applicationScope: CoroutineScope, + private val currencyProvider: Lazy, private val userPreferences: Lazy, private val transactionTemplateStore: Lazy, private val reportsOverviewCache: ReportsOverviewCache, @@ -39,7 +40,7 @@ class LocalDataCleaner @Inject constructor( database.withTransaction { database.clearAllTables() } - CurrencyProvider.getInstance()?.clearCache() + currencyProvider.get().clearCache() userPreferences.get().clearSessionData() transactionTemplateStore.get().clearAll() reportsOverviewCache.clearAll() diff --git a/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt b/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt index bc572fe..fc049dd 100644 --- a/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt +++ b/app/src/main/java/com/pledgerio/app/data/ocr/InvoiceTextExtractor.kt @@ -8,6 +8,7 @@ import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions import com.pledgerio.app.R +import com.pledgerio.app.domain.repository.InvoiceTextReader import com.pledgerio.app.util.Resource import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -19,9 +20,10 @@ import kotlin.coroutines.resume @Singleton class InvoiceTextExtractor @Inject constructor( @ApplicationContext private val context: Context, -) { - suspend fun extractText(uri: Uri): Resource { +) : InvoiceTextReader { + override suspend fun extractText(imageUri: String): Resource { return try { + val uri = Uri.parse(imageUri) val image = InputImage.fromFilePath(context, uri) val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) val result = recognizer.process(image).awaitResult() diff --git a/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt b/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt index f057f54..1ccd457 100644 --- a/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt +++ b/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt @@ -87,10 +87,22 @@ class TransactionRepositoryImpl @Inject constructor( } catch (e: Exception) { if (page == 0) { val cached = transactionDao.getAllOnce() + .map { it.toDomain() } + .filter { tx -> + !tx.date.isBefore(startDate) && !tx.date.isAfter(endDate) && + (type == null || tx.type == type) && + (filters.categoryId == null || tx.categoryId == null || tx.categoryId == filters.categoryId) && + (filters.expenseId == null || tx.expenseId == null || tx.expenseId == filters.expenseId) && + (filters.contractId == null || tx.contractId == null || tx.contractId == filters.contractId) && + ( + filters.description.isNullOrBlank() || + tx.description.contains(filters.description, ignoreCase = true) + ) + } if (cached.isNotEmpty()) { return Resource.Success( PagedResult( - items = cached.map { it.toDomain() }, + items = cached, totalRecords = cached.size.toLong(), totalPages = 1, pageSize = cached.size, diff --git a/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt b/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt index 68f1da1..b968838 100644 --- a/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/pledgerio/app/di/RepositoryModule.kt @@ -11,6 +11,7 @@ import com.pledgerio.app.data.repository.ReportRepositoryImpl import com.pledgerio.app.data.repository.TagRepositoryImpl import com.pledgerio.app.data.repository.TransactionRepositoryImpl import com.pledgerio.app.data.cache.ReportsOverviewCache +import com.pledgerio.app.data.ocr.InvoiceTextExtractor import com.pledgerio.app.domain.repository.AccountRepository import com.pledgerio.app.domain.repository.AuthRepository import com.pledgerio.app.domain.repository.BudgetRepository @@ -18,6 +19,7 @@ import com.pledgerio.app.domain.repository.CategoryRepository import com.pledgerio.app.domain.repository.ContractRepository import com.pledgerio.app.domain.repository.CurrencyRepository import com.pledgerio.app.domain.repository.IssueReportRepository +import com.pledgerio.app.domain.repository.InvoiceTextReader import com.pledgerio.app.domain.repository.ReportRepository import com.pledgerio.app.domain.repository.ReportsOverviewStore import com.pledgerio.app.domain.repository.TagRepository @@ -75,4 +77,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindReportsOverviewStore(impl: ReportsOverviewCache): ReportsOverviewStore + + @Binds + @Singleton + abstract fun bindInvoiceTextReader(impl: InvoiceTextExtractor): InvoiceTextReader } diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt b/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt new file mode 100644 index 0000000..a813c12 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt @@ -0,0 +1,7 @@ +package com.pledgerio.app.domain.repository + +import com.pledgerio.app.util.Resource + +interface InvoiceTextReader { + suspend fun extractText(imageUri: String): Resource +} diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/ProcessInvoiceScanUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/ProcessInvoiceScanUseCase.kt new file mode 100644 index 0000000..f803974 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/domain/usecase/ProcessInvoiceScanUseCase.kt @@ -0,0 +1,25 @@ +package com.pledgerio.app.domain.usecase + +import com.pledgerio.app.domain.model.TransactionExtractionDraft +import com.pledgerio.app.domain.repository.InvoiceTextReader +import com.pledgerio.app.domain.repository.TransactionRepository +import com.pledgerio.app.util.Resource +import javax.inject.Inject + +class ProcessInvoiceScanUseCase @Inject constructor( + private val invoiceTextReader: InvoiceTextReader, + private val transactionRepository: TransactionRepository, +) { + suspend fun extractTextFromImage(imageUri: String): Resource = + invoiceTextReader.extractText(imageUri) + + suspend fun extractDraftFromText(text: String): Resource { + val trimmed = text.trim() + if (trimmed.isBlank()) return Resource.Error("No text found in document") + return when (val result = transactionRepository.extractTransactionFromText(trimmed)) { + is Resource.Success -> Resource.Success(result.data.copy(rawText = trimmed)) + is Resource.Error -> result + is Resource.Loading -> result + } + } +} diff --git a/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt index 1386777..e317fab 100644 --- a/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/settings/SettingsViewModel.kt @@ -12,7 +12,6 @@ import com.pledgerio.app.domain.repository.CurrencyRepository import com.pledgerio.app.util.BiometricAuthenticator import com.pledgerio.app.util.BiometricAvailability import com.pledgerio.app.util.BiometricLockManager -import com.pledgerio.app.util.CurrencyProvider import com.pledgerio.app.util.SessionManager import com.pledgerio.app.util.UserPreferences import androidx.fragment.app.FragmentActivity @@ -214,7 +213,6 @@ class SettingsViewModel @Inject constructor( currencies: List = _uiState.value.currencies, ): String { val currency = currencies.find { it.code == code } - ?: CurrencyProvider.getInstance()?.get(code) return if (currency != null) { "${currency.code} — ${currency.name} (${currency.symbol})" } else { diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt index ef4922e..1f54f4e 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionDetailScreen.kt @@ -31,7 +31,6 @@ import com.pledgerio.app.ui.transactions.detail.TransactionDetailFlowCard import com.pledgerio.app.ui.transactions.detail.TransactionDetailHeroCard import com.pledgerio.app.ui.transactions.detail.TransactionDetailSplitCard import com.pledgerio.app.ui.transactions.detail.TransactionDetailTagsCard -import com.pledgerio.app.util.CurrencyProvider @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,9 +71,6 @@ fun TransactionDetailScreen( modifier = Modifier.padding(paddingValues), ) transaction != null -> { - val currencyInfo = CurrencyProvider.getInstance()?.get(transaction.currency) - val currencyDisplay = currencyInfo?.let { "${it.name} (${it.symbol})" } - ?: transaction.currency val hasClassification = transaction.budgetName != null || transaction.categoryName != null || transaction.contractName != null diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt index f603eee..81670e3 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/detail/TransactionDetailContent.kt @@ -46,9 +46,9 @@ import com.pledgerio.app.ui.theme.IncomeGreen import com.pledgerio.app.ui.theme.PledgerGreen import com.pledgerio.app.ui.theme.PledgerThemeExt import com.pledgerio.app.ui.transactions.form.TransactionFormLabels -import com.pledgerio.app.util.CurrencyProvider import com.pledgerio.app.util.formatCurrency import com.pledgerio.app.util.formatDisplay +import java.util.Currency data class TransactionTypeStyle( val label: String, @@ -73,8 +73,8 @@ fun TransactionDetailHeroCard( modifier: Modifier = Modifier, ) { val style = transactionTypeStyle(transaction.type) - val currencyInfo = CurrencyProvider.getInstance()?.get(transaction.currency) - val currencyLabel = currencyInfo?.let { "${it.symbol} · ${it.code}" } ?: transaction.currency + val currency = runCatching { Currency.getInstance(transaction.currency) }.getOrNull() + val currencyLabel = currency?.let { "${it.symbol} · ${it.currencyCode}" } ?: transaction.currency PledgerCard(modifier = modifier) { Column( diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt index 0830a82..cbe7990 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/scan/InvoiceScanViewModel.kt @@ -5,9 +5,8 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pledgerio.app.R -import com.pledgerio.app.data.ocr.InvoiceTextExtractor import com.pledgerio.app.domain.model.TransactionExtractionDraft -import com.pledgerio.app.domain.repository.TransactionRepository +import com.pledgerio.app.domain.usecase.ProcessInvoiceScanUseCase import com.pledgerio.app.util.Resource import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.lifecycle.HiltViewModel @@ -38,8 +37,7 @@ data class InvoiceScanUiState( @HiltViewModel class InvoiceScanViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val invoiceTextExtractor: InvoiceTextExtractor, - private val transactionRepository: TransactionRepository, + private val processInvoiceScanUseCase: ProcessInvoiceScanUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow(InvoiceScanUiState()) @@ -65,7 +63,7 @@ class InvoiceScanViewModel @Inject constructor( draft = null, ) } - when (val ocrResult = invoiceTextExtractor.extractText(uri)) { + when (val ocrResult = processInvoiceScanUseCase.extractTextFromImage(uri.toString())) { is Resource.Success -> { _uiState.update { it.copy( @@ -124,12 +122,12 @@ class InvoiceScanViewModel @Inject constructor( draft = null, ) } - when (val result = transactionRepository.extractTransactionFromText(text)) { + when (val result = processInvoiceScanUseCase.extractDraftFromText(text)) { is Resource.Success -> { _uiState.update { it.copy( stage = InvoiceScanStage.IDLE, - draft = result.data.copy(rawText = text), + draft = result.data, ) } } diff --git a/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt b/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt index 7250179..17d03ad 100644 --- a/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt +++ b/app/src/main/java/com/pledgerio/app/util/CurrencyProvider.kt @@ -43,15 +43,4 @@ class CurrencyProvider @Inject constructor( "$currencyCode $formatted" } } - - companion object { - @Volatile - private var instance: CurrencyProvider? = null - - fun getInstance(): CurrencyProvider? = instance - - internal fun setInstance(provider: CurrencyProvider) { - instance = provider - } - } } diff --git a/app/src/main/java/com/pledgerio/app/util/Extensions.kt b/app/src/main/java/com/pledgerio/app/util/Extensions.kt index a6218f6..228746e 100644 --- a/app/src/main/java/com/pledgerio/app/util/Extensions.kt +++ b/app/src/main/java/com/pledgerio/app/util/Extensions.kt @@ -2,15 +2,15 @@ package com.pledgerio.app.util import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.Currency fun Double.formatCurrency(currencyCode: String? = null): String { val code = currencyCode ?: UserPreferences.defaultDisplayCurrency - val provider = CurrencyProvider.getInstance() - if (provider != null) { - return provider.formatAmount(this, code) - } - val formatted = String.format("%,.2f", this) - return "$code $formatted" + val javaCurrency = runCatching { Currency.getInstance(code) }.getOrNull() + val decimalPlaces = javaCurrency?.defaultFractionDigits?.takeIf { it >= 0 } ?: 2 + val symbol = javaCurrency?.symbol ?: code + val formatted = String.format("%,.${decimalPlaces}f", this) + return "$symbol $formatted" } fun LocalDate.formatDisplay(): String { diff --git a/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt b/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt index 6b8fb2b..600926c 100644 --- a/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt +++ b/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt @@ -23,9 +23,12 @@ import com.pledgerio.app.domain.repository.CurrencyRepository import com.pledgerio.app.domain.repository.TagRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.IOException import kotlinx.coroutines.flow.first +import kotlinx.coroutines.CancellationException import java.time.LocalDate import java.util.concurrent.TimeUnit +import retrofit2.HttpException @HiltWorker class SyncWorker @AssistedInject constructor( @@ -60,8 +63,18 @@ class SyncWorker @AssistedInject constructor( } Result.success() - } catch (e: Exception) { + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { Result.retry() + } catch (e: HttpException) { + if (e.code() == 401 || e.code() == 403) { + Result.failure() + } else { + Result.retry() + } + } catch (e: Exception) { + Result.failure() } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e77a46a..a7185dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -125,7 +125,7 @@ See [Budgets](BUDGETS.md) for API and screen details. ### Data Layer (`data/`) - **Remote** — `PledgerApiService`, Moshi DTOs (`@JsonClass(generateAdapter = true)`). -- **Local** — Room (`PledgerDatabase` v5): accounts, transactions, budgets, categories, currencies, contracts, expense groups, account types, sync metadata. +- **Local** — Room (`PledgerDatabase` v6): accounts, transactions, budgets, categories, currencies, contracts, expense groups, account types, sync metadata. - **Repositories** — Stale-while-revalidate cache for reference data, network-first for paged data: 1. Read returns cached values from Room immediately (via Flow or one-shot query). @@ -236,7 +236,7 @@ Transactions are refreshed on screen load rather than in the worker. | Concern | Implementation | |---------|----------------| | Token storage | `EncryptedSharedPreferences` (AES-256-GCM) | -| Transport | User-supplied URL; cleartext permitted in debug config for dev servers | +| Transport | User-supplied URL; cleartext denied by default and enabled only in debug override config | | Biometric | Optional via settings (`BiometricPrompt`) | | Session expiry | Proactive JWT refresh + 401 retry; `clearAuthTokens` keeps server URL | | Platform | compile/target SDK 36 (Android 16); edge-to-edge in `MainActivity` | @@ -274,7 +274,7 @@ Navigation arguments via `SavedStateHandle`. No Android framework types inside V | Use cases | JUnit, MockK, coroutines-test | | ViewModels | JUnit, MockK, Turbine | | Repositories | JUnit, MockK | -| UI | Compose UI tests for critical flows | +| UI | Compose UI tests for critical flows (`androidTest` smoke coverage in CI) | ## Module Boundaries diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md new file mode 100644 index 0000000..4d93e41 --- /dev/null +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -0,0 +1,210 @@ +# Mobile Application Improvement Audit + +**Project:** `pledger-io/android-app` +**Date:** 2026-05-24 +**Audit type:** Static code and configuration review (architecture, security, reliability, testing, and maintainability) + +## Goal + +This document captures concrete improvements to strengthen the Android app quality, reduce production risk, and increase delivery speed. Recommendations are prioritized so the team can execute in phases. + +## Implementation Progress + +Legend: `[x]` done, `[~]` in progress, `[ ]` not started. + +- [x] Critical #1: release-safe logging (`BODY` in debug, `NONE` in release). +- [x] Critical #2: cleartext disabled by default with debug-only override. +- [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled. +- [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope). +- [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`). +- [~] High #7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels); additional decomposition still possible. +- [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). +- [x] Medium #9: `allowBackup` hardened (`false`). +- [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). +- [ ] Medium #11: `Resource` relocation/typed-result migration pending. +- [~] Medium #12: transaction offline fallback improved for date/type/text filters; full filter-faithful offline parity still pending. +- [x] Medium #13: `SyncWorker` now classifies transient vs permanent failures (`IOException` retry, auth failures fail). +- [x] Medium #14: CI lint gate added (`lintDebug`) with baseline to enforce no new lint debt. +- [~] Low #15: docs/ADR drift corrected (schema/CI/security updates); release versioning/signing policy hardening still pending. +- [x] Test expansion #1: added `AuthInterceptor` tests. +- [x] Test expansion #3 (partial): login flow now executes through `LoginUseCase` in production code paths. +- [x] Test expansion #6 (initial): added first Compose `androidTest` smoke test (`MainActivitySmokeTest`). +- [x] Test expansion #5 (initial): added migration test scaffolding for 5→6. + +## What is working well + +- Clear package structure with `data` / `domain` / `ui` separation and ADR documentation. +- Strong repository-level unit testing footprint. +- Solid foundations for offline-first behavior (Room + cache policy + sync metadata). +- Good auth/session primitives (`SessionManager`, token refresh, logout cleanup). +- Thoughtful product UX direction documented in feature plans. + +## Priority Findings + +### Critical + +1. **Release logging leaks sensitive API payloads** + - Evidence: `NetworkModule` always sets `HttpLoggingInterceptor.Level.BODY`. + - Risk: JWTs, credentials, and financial payloads can appear in logs. + - Recommendation: + - Use `if (BuildConfig.DEBUG) BODY else NONE`. + - Keep issue-report logging sanitized and metadata-only. + +2. **Cleartext traffic currently permitted globally** + - Evidence: `network_security_config.xml` has ``. + - Risk: Release traffic can be sent over insecure HTTP. + - Recommendation: + - Restrict cleartext to debug only (debug config/flavor). + - Enforce HTTPS in release builds. + - Align README wording to actual behavior. + +### High + +3. **Destructive Room migration policy** + - Evidence: `fallbackToDestructiveMigration()` + `exportSchema = false`. + - Risk: app upgrades can wipe local data/cache silently. + - Recommendation: + - Add explicit Room migrations. + - Turn on schema export and commit schema history. + - Remove destructive fallback for release. + +4. **Blocking cleanup call inside network interceptor** + - Evidence: `AuthInterceptor` calls `clearAllUserDataBlocking()` (uses `runBlocking`). + - Risk: thread blocking in OkHttp chain, potential latency spikes. + - Recommendation: + - Return auth failure immediately. + - Trigger async session-expired cleanup on app scope. + - Keep interceptor non-blocking. + +5. **Very large ViewModels increase change risk** + - Evidence: `TransactionFormViewModel` and `TransactionsViewModel` hold multiple responsibilities. + - Risk: regression probability and test complexity rise with each feature. + - Recommendation: + - Extract state reducers, input validation, filter orchestration, and side-effect handlers. + - Move orchestration into focused domain/use-case components. + +6. **Layering violations from UI directly depending on data-layer internals** + - Evidence: `ReportsViewModel` depends on `ReportsOverviewCache`; `InvoiceScanViewModel` depends on `InvoiceTextExtractor`. + - Risk: weak architectural boundaries and harder replacements/testing. + - Recommendation: + - Access through domain-level interfaces/use cases only. + - Keep `ui` unaware of data implementation types. + +7. **Domain use-case layer is inconsistent** + - Evidence: some use cases are used (`CreateInitialBudgetUseCase`, `SaveBudgetExpenseUseCase`), while others exist but are not wired (`LoginUseCase`, `GetDashboardDataUseCase`, `GetTransactionsUseCase`, `GetBudgetsUseCase`). + - Risk: duplicate orchestration patterns and architectural drift. + - Recommendation: + - Decide one direction: + - Wire use cases consistently for all major flows, or + - Remove unused use cases and simplify docs. + +8. **No instrumented/UI test coverage** + - Evidence: no files in `app/src/androidTest`. + - Risk: navigation, compose rendering, and integration regressions escape unit tests. + - Recommendation: + - Add smoke UI tests for onboarding/login/main-tab navigation/transaction list. + - Add a CI job for instrumentation tests (managed emulator or device lab). + +### Medium + +9. **Backup posture should be tightened** + - Evidence: `android:allowBackup="true"` in manifest. + - Risk: session-adjacent data may be restorable on some device/backup combinations. + - Recommendation: + - Set `allowBackup=false` or add strict backup rules excluding secure/session stores. + +10. **Global singleton usage (`CurrencyProvider`) bypasses DI** + - Evidence: `CurrencyProvider.getInstance()` accessed during cleanup. + - Risk: hidden coupling and harder testability/lifecycle control. + - Recommendation: + - Replace singleton access with injected abstraction across app. + +11. **`Resource` type location blurs boundaries** + - Evidence: `Resource` resides in `util` but is used across domain and data. + - Risk: cross-layer coupling through a generic utility namespace. + - Recommendation: + - Move to a dedicated `domain/common` location or migrate to `Result` + typed domain errors. + +12. **Offline behavior for transactions is partial** + - Evidence: limited fallback behavior in transaction paging/filtering paths. + - Risk: inconsistent user experience when offline. + - Recommendation: + - Define explicit cache semantics per filter/page, or + - Explicitly message which query modes are online-only. + +13. **WorkManager retry policy is broad** + - Evidence: worker retry path is not strongly classified by error type. + - Risk: repeated retries for permanent failures. + - Recommendation: + - Classify transient vs permanent failures. + - Use backoff policies and fail fast on unrecoverable cases. + +14. **Static analysis gates are missing in CI** + - Evidence: CI runs unit tests + debug build only. + - Risk: style, correctness, and Android lint issues slip into main. + - Recommendation: + - Add `lintDebug`. + - Add Detekt/ktlint (or equivalent) with baseline and fail-on-new policy. + +### Low + +15. **Release hygiene and doc drift** + - Evidence: + - `versionCode = 1` is static. + - README says Room schema v3 while DB is v6. + - Release signing falls back to debug key when missing config. + - Recommendation: + - Enforce versioning policy. + - Keep docs synced with implementation. + - Fail release build when production signing material is absent. + +## Recommended Test Expansion + +Add focused tests in this order: + +1. `AuthInterceptor` behavior (401 retry path, token refresh fail path, auth endpoint bypass). +2. `ReportsViewModel` cache + refresh behavior. +3. `LoginViewModel` and server setup validation behavior. +4. `SyncWorker` using test worker builder + fake repositories. +5. Room migration tests for each schema upgrade. +6. Compose smoke tests for critical flows in `androidTest`. + +## 30 / 60 / 90 Day Execution Plan + +## 0-30 days (Risk reduction) + +- Fix logging level for release. +- Restrict cleartext to debug only. +- Remove blocking cleanup from interceptor path. +- Add lint gate to CI. +- Start instrumented smoke tests. + +## 31-60 days (Architecture hardening) + +- Implement Room migrations + schema export. +- Refactor reports/invoice scan to domain-facing interfaces. +- Split transaction ViewModel responsibilities. +- Resolve use-case consistency decision and apply it. + +## 61-90 days (Quality at scale) + +- Expand UI/integration test suite. +- Improve offline consistency strategy for transactions/reports. +- Adopt static analysis stack fully (lint + detekt + formatting checks). +- Strengthen release process checks (signing/version/docs sync). + +## Suggested Tracking Format + +Create epics/issues grouped by: + +- **Security hardening** +- **Data integrity and migrations** +- **Architecture and maintainability** +- **Test coverage and CI quality gates** +- **Release engineering** + +Each issue should include: owner, effort estimate, risk level, acceptance criteria, and rollback strategy. + +--- + +If needed, this audit can be converted into a sprint-by-sprint issue backlog with file-level task breakdowns. diff --git a/docs/adr/015-stale-while-revalidate-cache.md b/docs/adr/015-stale-while-revalidate-cache.md index 0e694ef..00a85ff 100644 --- a/docs/adr/015-stale-while-revalidate-cache.md +++ b/docs/adr/015-stale-while-revalidate-cache.md @@ -75,8 +75,8 @@ Room version bumped to **5**. New entities: counterparties can live in the same table as owned accounts without wiping each other on refresh. -`fallbackToDestructiveMigration()` remains acceptable because the cache is always rehydrated -from the server. +Database migrations are explicitly versioned; destructive fallback is not used in production +paths so cache/index evolution stays predictable across upgrades. ## Consequences diff --git a/docs/adr/019-security-and-quality-hardening.md b/docs/adr/019-security-and-quality-hardening.md new file mode 100644 index 0000000..4475cb4 --- /dev/null +++ b/docs/adr/019-security-and-quality-hardening.md @@ -0,0 +1,61 @@ +# ADR-019: Security and Quality Hardening Baseline + +**Date:** 2026-05-24 +**Status:** Accepted + +## Context + +The Android app had several production-risk defaults that were acceptable during rapid feature +delivery but not suitable as a long-term baseline: + +- HTTP body logging was enabled for all builds. +- Cleartext HTTP was globally allowed. +- Room was configured with destructive fallback. +- CI lacked lint gates and instrumented validation. +- Some UI flows depended directly on data-layer implementations. + +## Decision + +Adopt a hardening baseline across runtime, persistence, architecture boundaries, and CI: + +1. **Network/security defaults** + - Keep request/response body logging in debug only. + - Disable cleartext by default and allow it only through debug resource overrides. + - Disable app backup to reduce accidental restore of sensitive session-adjacent data. + +2. **Database evolution** + - Remove destructive fallback. + - Require explicit Room migrations (`PledgerDatabaseMigrations`). + - Export Room schema JSON for auditability and migration review. + +3. **Architecture boundaries** + - Move report overview cache usage behind a domain contract (`ReportsOverviewStore`). + - Route invoice-scan orchestration through a domain use case (`ProcessInvoiceScanUseCase`) + and domain reader contract (`InvoiceTextReader`) instead of direct UI dependency on data + extraction classes. + +4. **Quality gates** + - Enforce `lintDebug` in CI with a baseline so new issues fail while historical debt is tracked. + - Add a first `androidTest` smoke check and run instrumented tests in CI. + +## Consequences + +### Positive + +- Reduced risk of leaking sensitive payloads in production logs. +- More secure default transport policy while keeping local/dev workflows. +- Safer app upgrades with explicit schema evolution. +- Better layer isolation for reporting and invoice-scan flows. +- CI now blocks on new lint regressions and validates a real device startup path. + +### Negative + +- CI becomes slower due to instrumented tests. +- Migration ownership is now required for every schema version bump. +- Some historical lint debt remains until baseline is gradually burned down. + +### Follow-up + +- Continue extracting remaining large ViewModel responsibilities. +- Add additional instrumented smoke tests for onboarding, login, and tab navigation. +- Gradually retire lint baseline entries instead of allowing debt to grow. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3b9c800..7d75a44 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,6 +24,7 @@ This directory contains Architecture Decision Records (ADRs) for the Pledger.io | [016](016-usability-improvement-program.md) | Usability Improvement Program | Accepted | | [017](017-deep-links-and-reports.md) | Deep Links and Reports Data | Accepted | | [018](018-app-localization.md) | App Localization (en / nl / de) | Accepted | +| [019](019-security-and-quality-hardening.md) | Security and Quality Hardening Baseline | Accepted | ## ADR Format From ddc79cc1687342479d860b34530c9fcab9674a15 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:01:10 +0200 Subject: [PATCH 06/17] Harden release signing and versioning policy. Require signing secrets for release workflow builds, derive app versionCode from APP_VERSION_CODE in CI, remove debug-signing fallback for release, and update audit/readme documentation accordingly. Co-authored-by: Cursor --- .github/workflows/release.yml | 16 ++++++++++++---- README.md | 2 +- app/build.gradle.kts | 21 ++++++++++++++++----- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69403d1..fb75dcb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,15 +36,23 @@ jobs: run: chmod +x gradlew - name: Decode release keystore - if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }} env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + APP_VERSION_CODE: ${{ github.run_number }} run: | + if [ -z "$ANDROID_KEYSTORE_BASE64" ] || [ -z "$ANDROID_KEYSTORE_PASSWORD" ] || [ -z "$ANDROID_KEY_ALIAS" ] || [ -z "$ANDROID_KEY_PASSWORD" ]; then + echo "Release signing secrets are required for release builds." >&2 + exit 1 + fi echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.keystore" echo "ANDROID_KEYSTORE_PATH=${RUNNER_TEMP}/release.keystore" >> "$GITHUB_ENV" - echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV" - echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV" - echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV" + echo "ANDROID_KEYSTORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD}" >> "$GITHUB_ENV" + echo "ANDROID_KEY_ALIAS=${ANDROID_KEY_ALIAS}" >> "$GITHUB_ENV" + echo "ANDROID_KEY_PASSWORD=${ANDROID_KEY_PASSWORD}" >> "$GITHUB_ENV" + echo "APP_VERSION_CODE=${APP_VERSION_CODE}" >> "$GITHUB_ENV" - name: Build release APK run: ./gradlew assembleRelease --no-daemon diff --git a/README.md b/README.md index e298a53..f94ab8d 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ To test the release build without publishing: **Actions → Release → Run work Users can report problems from **Settings → About → Report a problem**. The app collects sanitized recent logs, prefills the org [bug report form](https://github.com/pledger-io/.github/issues/new?template=bug_report.yml) in the browser (summary, description, environment, logs), and the user taps **Submit** on GitHub — no app credentials required. Sign in to GitHub only if the repository requires it to open issues. -**Optional production signing** — add these repository secrets; if omitted, CI signs the release APK with the debug keystore (fine for sideloading, not for Play Store): +**Production signing is required for release builds** — configure these repository secrets: | Secret | Description | |--------|-------------| diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6bac56e..d7d48ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,19 @@ plugins { alias(libs.plugins.ksp) } +val appVersionCode = System.getenv("APP_VERSION_CODE")?.toIntOrNull() ?: 1 +val releaseKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH") +val isCi = !System.getenv("CI").isNullOrBlank() +val buildingReleaseTask = gradle.startParameter.taskNames.any { task -> + task.contains("Release", ignoreCase = true) +} + +if (isCi && buildingReleaseTask && releaseKeystorePath.isNullOrBlank()) { + throw GradleException( + "Release signing is required in CI for release builds. Set ANDROID_KEYSTORE_PATH and signing env vars.", + ) +} + ksp { arg("room.schemaLocation", "$projectDir/schemas") arg("room.incremental", "true") @@ -20,17 +33,16 @@ android { applicationId = "com.pledgerio.app" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 1 + versionCode = appVersionCode versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { - val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH") - if (!keystorePath.isNullOrBlank()) { + if (!releaseKeystorePath.isNullOrBlank()) { create("release") { - storeFile = file(keystorePath) + storeFile = file(releaseKeystorePath) storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") keyAlias = System.getenv("ANDROID_KEY_ALIAS") keyPassword = System.getenv("ANDROID_KEY_PASSWORD") @@ -47,7 +59,6 @@ android { "proguard-rules.pro" ) signingConfig = signingConfigs.findByName("release") - ?: signingConfigs.getByName("debug") } } diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 4d93e41..cf39c82 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -25,7 +25,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [~] Medium #12: transaction offline fallback improved for date/type/text filters; full filter-faithful offline parity still pending. - [x] Medium #13: `SyncWorker` now classifies transient vs permanent failures (`IOException` retry, auth failures fail). - [x] Medium #14: CI lint gate added (`lintDebug`) with baseline to enforce no new lint debt. -- [~] Low #15: docs/ADR drift corrected (schema/CI/security updates); release versioning/signing policy hardening still pending. +- [x] Low #15: release hygiene improved (non-static `versionCode` via env/CI and required release signing in release workflow); docs aligned. - [x] Test expansion #1: added `AuthInterceptor` tests. - [x] Test expansion #3 (partial): login flow now executes through `LoginUseCase` in production code paths. - [x] Test expansion #6 (initial): added first Compose `androidTest` smoke test (`MainActivitySmokeTest`). From d91b9127c969329b7fef555caa2b681cb8f3dbae Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:05:01 +0200 Subject: [PATCH 07/17] Add domain Resource bridge for phased migration. Introduce domain/common Resource as a compatibility alias, keep existing util Resource behavior stable, and update architecture/audit documentation to track the incremental import migration plan. Co-authored-by: Cursor --- app/src/main/java/com/pledgerio/app/domain/common/Resource.kt | 3 +++ docs/ARCHITECTURE.md | 2 ++ docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/pledgerio/app/domain/common/Resource.kt diff --git a/app/src/main/java/com/pledgerio/app/domain/common/Resource.kt b/app/src/main/java/com/pledgerio/app/domain/common/Resource.kt new file mode 100644 index 0000000..759481c --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/domain/common/Resource.kt @@ -0,0 +1,3 @@ +package com.pledgerio.app.domain.common + +typealias Resource = com.pledgerio.app.util.Resource diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7185dc..d993b4a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -198,6 +198,8 @@ Repositories return `Resource`: - `Resource.Error(message)` - `Resource.Loading` +`Resource` is defined in `domain/common` (with a temporary compatibility alias in `util` while imports are migrated). + ## Offline Strategy ### Room cache diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index cf39c82..5fb979e 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -21,7 +21,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). -- [ ] Medium #11: `Resource` relocation/typed-result migration pending. +- [~] Medium #11: migration bridge added (`domain/common/Resource` alias), enabling phased import migration away from `util.Resource`. - [~] Medium #12: transaction offline fallback improved for date/type/text filters; full filter-faithful offline parity still pending. - [x] Medium #13: `SyncWorker` now classifies transient vs permanent failures (`IOException` retry, auth failures fail). - [x] Medium #14: CI lint gate added (`lintDebug`) with baseline to enforce no new lint debt. From 2a87335f7c069ef68c71ca28ab6bf260d14d5280 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:07:08 +0200 Subject: [PATCH 08/17] Clarify offline transaction filter behavior. Return an explicit error for offline ID-based transaction filters (category/expense/contract), keep cache fallback for date/type/text-only queries, and update the audit progress notes. Co-authored-by: Cursor --- .../app/data/repository/TransactionRepositoryImpl.kt | 11 ++++++++--- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt b/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt index 1ccd457..38a1f91 100644 --- a/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt +++ b/app/src/main/java/com/pledgerio/app/data/repository/TransactionRepositoryImpl.kt @@ -85,15 +85,20 @@ class TransactionRepositoryImpl @Inject constructor( Resource.Error("Failed to fetch transactions: ${response.code()}") } } catch (e: Exception) { + val hasIdBasedFilters = filters.categoryId != null || + filters.expenseId != null || + filters.contractId != null + if (hasIdBasedFilters) { + return Resource.Error( + "Filtered offline search is not available yet. Reconnect to apply category/expense/contract filters.", + ) + } if (page == 0) { val cached = transactionDao.getAllOnce() .map { it.toDomain() } .filter { tx -> !tx.date.isBefore(startDate) && !tx.date.isAfter(endDate) && (type == null || tx.type == type) && - (filters.categoryId == null || tx.categoryId == null || tx.categoryId == filters.categoryId) && - (filters.expenseId == null || tx.expenseId == null || tx.expenseId == filters.expenseId) && - (filters.contractId == null || tx.contractId == null || tx.contractId == filters.contractId) && ( filters.description.isNullOrBlank() || tx.description.contains(filters.description, ignoreCase = true) diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 5fb979e..66cf437 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -22,7 +22,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). - [~] Medium #11: migration bridge added (`domain/common/Resource` alias), enabling phased import migration away from `util.Resource`. -- [~] Medium #12: transaction offline fallback improved for date/type/text filters; full filter-faithful offline parity still pending. +- [~] Medium #12: transaction offline fallback improved for date/type/text filters and now explicitly errors for unsupported offline ID filters (category/expense/contract); full offline parity still pending schema/API alignment. - [x] Medium #13: `SyncWorker` now classifies transient vs permanent failures (`IOException` retry, auth failures fail). - [x] Medium #14: CI lint gate added (`lintDebug`) with baseline to enforce no new lint debt. - [x] Low #15: release hygiene improved (non-static `versionCode` via env/CI and required release signing in release workflow); docs aligned. From 5fb37670c32757c37e714637e72ec69318cf6b54 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:09:08 +0200 Subject: [PATCH 09/17] Add ReportsViewModel cache and refresh tests. Introduce unit coverage for fresh cached overview reads and refresh-triggered invalidation/network fetch paths, and mark the related audit test-expansion item as completed. Co-authored-by: Cursor --- .../app/ui/reports/ReportsViewModelTest.kt | 78 +++++++++++++++++++ docs/CODEBASE_IMPROVEMENT_AUDIT.md | 1 + 2 files changed, 79 insertions(+) create mode 100644 app/src/test/java/com/pledgerio/app/ui/reports/ReportsViewModelTest.kt diff --git a/app/src/test/java/com/pledgerio/app/ui/reports/ReportsViewModelTest.kt b/app/src/test/java/com/pledgerio/app/ui/reports/ReportsViewModelTest.kt new file mode 100644 index 0000000..0b3fdd2 --- /dev/null +++ b/app/src/test/java/com/pledgerio/app/ui/reports/ReportsViewModelTest.kt @@ -0,0 +1,78 @@ +package com.pledgerio.app.ui.reports + +import com.pledgerio.app.domain.model.IncomeExpenseSummary +import com.pledgerio.app.domain.model.ReportsOverview +import com.pledgerio.app.domain.repository.ReportRepository +import com.pledgerio.app.domain.repository.ReportsOverviewStore +import com.pledgerio.app.util.MainDispatcherRule +import com.pledgerio.app.util.Resource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import java.time.YearMonth + +@OptIn(ExperimentalCoroutinesApi::class) +class ReportsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val reportRepository = mockk() + private val overviewStore = mockk() + + @Test + fun `init uses fresh cached overview without network calls`() = runTest { + val month = YearMonth.now() + val cachedOverview = ReportsOverview( + incomeExpense = IncomeExpenseSummary(income = 1200.0, expense = 800.0), + ) + val entry = mockk() + every { entry.overview } returns cachedOverview + every { entry.fetchedAtMillis } returns 1234L + every { entry.isFresh(month, any(), any()) } returns true + coEvery { overviewStore.get(month) } returns entry + + val viewModel = ReportsViewModel(reportRepository, overviewStore) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(cachedOverview, state.overview) + assertFalse(state.isLoading) + assertFalse(state.isRefreshing) + coVerify(exactly = 0) { reportRepository.getIncomeExpenseSummary(any()) } + } + + @Test + fun `refresh invalidates cache and fetches overview from repositories`() = runTest { + val month = YearMonth.now() + coEvery { overviewStore.get(any()) } returns null + coEvery { overviewStore.invalidate(month) } returns Unit + coEvery { overviewStore.put(any(), any(), any()) } returns Unit + coEvery { reportRepository.getIncomeExpenseSummary(month) } returns + Resource.Success(IncomeExpenseSummary(income = 50.0, expense = 20.0)) + coEvery { reportRepository.getCategoryBreakdown(month) } returns Resource.Success(emptyList()) + coEvery { reportRepository.getAccountBalances(month) } returns Resource.Success(emptyList()) + coEvery { reportRepository.getBudgetPerformance(month) } returns Resource.Success(emptyList()) + coEvery { reportRepository.getNetWorthTrend(month) } returns Resource.Success(emptyList()) + + val viewModel = ReportsViewModel(reportRepository, overviewStore) + advanceUntilIdle() + viewModel.refresh() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertFalse(state.isRefreshing) + assertEquals(50.0, state.overview?.incomeExpense?.income ?: 0.0, 0.0) + coVerify(atLeast = 1) { overviewStore.invalidate(month) } + coVerify(atLeast = 1) { overviewStore.put(month, any(), any()) } + } +} diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 66cf437..064647c 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -27,6 +27,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] Medium #14: CI lint gate added (`lintDebug`) with baseline to enforce no new lint debt. - [x] Low #15: release hygiene improved (non-static `versionCode` via env/CI and required release signing in release workflow); docs aligned. - [x] Test expansion #1: added `AuthInterceptor` tests. +- [x] Test expansion #2: added `ReportsViewModel` cache/refresh unit coverage. - [x] Test expansion #3 (partial): login flow now executes through `LoginUseCase` in production code paths. - [x] Test expansion #6 (initial): added first Compose `androidTest` smoke test (`MainActivitySmokeTest`). - [x] Test expansion #5 (initial): added migration test scaffolding for 5→6. From af887b8dd95d02e03d92eed5ca15a2def8df9c13 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:18:12 +0200 Subject: [PATCH 10/17] Migrate domain-layer Resource imports. Switch domain repository contracts and non-pattern-matching use cases to import Resource from domain/common, keeping compatibility semantics intact and documenting the remaining migration scope. Co-authored-by: Cursor --- .../com/pledgerio/app/domain/repository/AccountRepository.kt | 2 +- .../java/com/pledgerio/app/domain/repository/AuthRepository.kt | 2 +- .../com/pledgerio/app/domain/repository/BudgetRepository.kt | 2 +- .../com/pledgerio/app/domain/repository/CategoryRepository.kt | 2 +- .../com/pledgerio/app/domain/repository/ContractRepository.kt | 2 +- .../com/pledgerio/app/domain/repository/InvoiceTextReader.kt | 2 +- .../pledgerio/app/domain/repository/IssueReportRepository.kt | 2 +- .../com/pledgerio/app/domain/repository/ReportRepository.kt | 2 +- .../java/com/pledgerio/app/domain/repository/TagRepository.kt | 2 +- .../pledgerio/app/domain/repository/TransactionRepository.kt | 2 +- .../pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt | 2 +- .../java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt | 2 +- .../com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt | 2 +- .../pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt | 2 +- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt index 1b8658f..3323ec2 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/AccountRepository.kt @@ -3,7 +3,7 @@ package com.pledgerio.app.domain.repository import com.pledgerio.app.domain.model.Account import com.pledgerio.app.domain.model.AccountTypeOption import com.pledgerio.app.domain.model.PagedAccounts -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import kotlinx.coroutines.flow.Flow interface AccountRepository { diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt index 3c3177c..cbc49da 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/AuthRepository.kt @@ -1,6 +1,6 @@ package com.pledgerio.app.domain.repository -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource interface AuthRepository { suspend fun login(username: String, password: String): Resource diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt index bf54c79..054cf16 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/BudgetRepository.kt @@ -3,7 +3,7 @@ package com.pledgerio.app.domain.repository import com.pledgerio.app.domain.model.Budget import com.pledgerio.app.domain.model.BudgetExpense import com.pledgerio.app.domain.model.BudgetListState -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import kotlinx.coroutines.flow.Flow interface BudgetRepository { diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt index c3fcd23..6df99c1 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/CategoryRepository.kt @@ -1,7 +1,7 @@ package com.pledgerio.app.domain.repository import com.pledgerio.app.domain.model.Category -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import kotlinx.coroutines.flow.Flow interface CategoryRepository { diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt index 8647d07..0d4950c 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/ContractRepository.kt @@ -1,7 +1,7 @@ package com.pledgerio.app.domain.repository import com.pledgerio.app.domain.model.Contract -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import kotlinx.coroutines.flow.Flow interface ContractRepository { diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt b/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt index a813c12..071a860 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/InvoiceTextReader.kt @@ -1,6 +1,6 @@ package com.pledgerio.app.domain.repository -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource interface InvoiceTextReader { suspend fun extractText(imageUri: String): Resource diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt index 20849b0..056658d 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/IssueReportRepository.kt @@ -1,7 +1,7 @@ package com.pledgerio.app.domain.repository import com.pledgerio.app.domain.model.IssueReportResult -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource interface IssueReportRepository { suspend fun submitBugReport(title: String, description: String): Resource diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt index 04536c4..d1abdf1 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/ReportRepository.kt @@ -4,7 +4,7 @@ import com.pledgerio.app.domain.model.BudgetPerformanceItem import com.pledgerio.app.domain.model.DatedAmount import com.pledgerio.app.domain.model.IncomeExpenseSummary import com.pledgerio.app.domain.model.PartitionAmount -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import java.time.YearMonth interface ReportRepository { diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt index 3a3cf38..4189185 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/TagRepository.kt @@ -1,7 +1,7 @@ package com.pledgerio.app.domain.repository import com.pledgerio.app.domain.model.Tag -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import kotlinx.coroutines.flow.Flow interface TagRepository { diff --git a/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt b/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt index 2330b58..655641f 100644 --- a/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt +++ b/app/src/main/java/com/pledgerio/app/domain/repository/TransactionRepository.kt @@ -6,7 +6,7 @@ import com.pledgerio.app.domain.model.TransactionExtractionDraft import com.pledgerio.app.domain.model.TransactionFilters import com.pledgerio.app.domain.model.TransactionSplit import com.pledgerio.app.domain.model.TransactionType -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import kotlinx.coroutines.flow.Flow import java.time.LocalDate diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt index 5d512a6..0034e0c 100644 --- a/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt +++ b/app/src/main/java/com/pledgerio/app/domain/usecase/CreateInitialBudgetUseCase.kt @@ -1,7 +1,7 @@ package com.pledgerio.app.domain.usecase import com.pledgerio.app.domain.repository.BudgetRepository -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import javax.inject.Inject class CreateInitialBudgetUseCase @Inject constructor( diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt index 1eef7b7..e3a0271 100644 --- a/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt +++ b/app/src/main/java/com/pledgerio/app/domain/usecase/GetBudgetsUseCase.kt @@ -2,7 +2,7 @@ package com.pledgerio.app.domain.usecase import com.pledgerio.app.domain.model.BudgetListState import com.pledgerio.app.domain.repository.BudgetRepository -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import kotlinx.coroutines.flow.Flow import javax.inject.Inject diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt index 0f330db..1ee08bf 100644 --- a/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt +++ b/app/src/main/java/com/pledgerio/app/domain/usecase/GetTransactionsUseCase.kt @@ -5,7 +5,7 @@ import com.pledgerio.app.domain.model.TransactionFilters import com.pledgerio.app.domain.model.TransactionType import com.pledgerio.app.domain.repository.PagedResult import com.pledgerio.app.domain.repository.TransactionRepository -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import java.time.LocalDate import javax.inject.Inject diff --git a/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt b/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt index 043d442..0acb847 100644 --- a/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt +++ b/app/src/main/java/com/pledgerio/app/domain/usecase/SaveBudgetExpenseUseCase.kt @@ -2,7 +2,7 @@ package com.pledgerio.app.domain.usecase import com.pledgerio.app.domain.model.BudgetListState import com.pledgerio.app.domain.repository.BudgetRepository -import com.pledgerio.app.util.Resource +import com.pledgerio.app.domain.common.Resource import javax.inject.Inject class SaveBudgetExpenseUseCase @Inject constructor( diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 064647c..62be272 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -21,7 +21,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). -- [~] Medium #11: migration bridge added (`domain/common/Resource` alias), enabling phased import migration away from `util.Resource`. +- [~] Medium #11: migration bridge added and domain-layer imports moved to `domain/common/Resource`; data/ui layers still pending for full completion. - [~] Medium #12: transaction offline fallback improved for date/type/text filters and now explicitly errors for unsupported offline ID filters (category/expense/contract); full offline parity still pending schema/API alignment. - [x] Medium #13: `SyncWorker` now classifies transient vs permanent failures (`IOException` retry, auth failures fail). - [x] Medium #14: CI lint gate added (`lintDebug`) with baseline to enforce no new lint debt. From 6f1fb7da52b27c2d0cc431fd18fb97f376647b34 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:20:15 +0200 Subject: [PATCH 11/17] Add SyncWorker failure-classification tests. Extract failure classification into a testable helper, add unit coverage for retry/failure branches (IO, auth HTTP, server HTTP, unknown), and update audit progress for the worker test expansion. Co-authored-by: Cursor --- .../java/com/pledgerio/app/util/SyncWorker.kt | 20 ++++++--- .../com/pledgerio/app/util/SyncWorkerTest.kt | 44 +++++++++++++++++++ docs/CODEBASE_IMPROVEMENT_AUDIT.md | 1 + 3 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/pledgerio/app/util/SyncWorkerTest.kt diff --git a/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt b/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt index 600926c..4fc7c71 100644 --- a/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt +++ b/app/src/main/java/com/pledgerio/app/util/SyncWorker.kt @@ -66,15 +66,11 @@ class SyncWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: IOException) { - Result.retry() + classifyFailure(e) } catch (e: HttpException) { - if (e.code() == 401 || e.code() == 403) { - Result.failure() - } else { - Result.retry() - } + classifyFailure(e) } catch (e: Exception) { - Result.failure() + classifyFailure(e) } } @@ -116,6 +112,16 @@ class SyncWorker @AssistedInject constructor( private const val NOTIFICATION_ID = 1001 private const val WORK_NAME = "pledger_sync" + internal fun classifyFailure(error: Throwable): Result = when (error) { + is IOException -> Result.retry() + is HttpException -> if (error.code() == 401 || error.code() == 403) { + Result.failure() + } else { + Result.retry() + } + else -> Result.failure() + } + fun cancel(context: Context) { WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) } diff --git a/app/src/test/java/com/pledgerio/app/util/SyncWorkerTest.kt b/app/src/test/java/com/pledgerio/app/util/SyncWorkerTest.kt new file mode 100644 index 0000000..74c093f --- /dev/null +++ b/app/src/test/java/com/pledgerio/app/util/SyncWorkerTest.kt @@ -0,0 +1,44 @@ +package com.pledgerio.app.util + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +class SyncWorkerTest { + + @Test + fun `classifyFailure retries on io exceptions`() { + val result = SyncWorker.classifyFailure(IOException("network")) + assertEquals("Retry", result.javaClass.simpleName) + } + + @Test + fun `classifyFailure fails on unauthorized`() { + val result = SyncWorker.classifyFailure(httpException(401)) + assertEquals("Failure", result.javaClass.simpleName) + } + + @Test + fun `classifyFailure retries on server http errors`() { + val result = SyncWorker.classifyFailure(httpException(500)) + assertEquals("Retry", result.javaClass.simpleName) + } + + @Test + fun `classifyFailure fails on unknown exception`() { + val result = SyncWorker.classifyFailure(IllegalStateException("boom")) + assertEquals("Failure", result.javaClass.simpleName) + } + + private fun httpException(code: Int): HttpException { + val response = Response.error( + code, + "{}".toResponseBody("application/json".toMediaType()), + ) + return HttpException(response) + } +} diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 62be272..091cffb 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -29,6 +29,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] Test expansion #1: added `AuthInterceptor` tests. - [x] Test expansion #2: added `ReportsViewModel` cache/refresh unit coverage. - [x] Test expansion #3 (partial): login flow now executes through `LoginUseCase` in production code paths. +- [x] Test expansion #4 (partial): added `SyncWorker` failure-classification unit coverage. - [x] Test expansion #6 (initial): added first Compose `androidTest` smoke test (`MainActivitySmokeTest`). - [x] Test expansion #5 (initial): added migration test scaffolding for 5→6. From b09addc87e951e4772f4ba8d75753ebbafd08c62 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:22:22 +0200 Subject: [PATCH 12/17] Extract transaction-form account preservation helper. Move source/target preservation rules out of TransactionFormViewModel into a focused helper to reduce ViewModel responsibilities while preserving existing type-switch behavior. Co-authored-by: Cursor --- .../TransactionFormAccountPreservation.kt | 39 +++++++++ .../transactions/TransactionFormViewModel.kt | 82 +++---------------- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 3 files changed, 50 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAccountPreservation.kt diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAccountPreservation.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAccountPreservation.kt new file mode 100644 index 0000000..9a8f279 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAccountPreservation.kt @@ -0,0 +1,39 @@ +package com.pledgerio.app.ui.transactions + +import com.pledgerio.app.domain.model.Account +import com.pledgerio.app.domain.model.FilterOption + +internal data class PreservedAccountSelection( + val accountId: Long?, + val selected: FilterOption?, + val query: String, +) + +internal fun preserveAccountSelection( + accountId: Long?, + selected: FilterOption?, + query: String, + ownedAccounts: List, + oldKind: AccountInputKind, + newKind: AccountInputKind, +): PreservedAccountSelection { + if (oldKind != newKind) return PreservedAccountSelection(null, null, "") + return when (newKind) { + AccountInputKind.OWNED_DROPDOWN -> { + if (accountId != null && ownedAccounts.any { it.id == accountId }) { + PreservedAccountSelection(accountId, null, "") + } else { + PreservedAccountSelection(null, null, "") + } + } + AccountInputKind.CREDITOR_AUTOCOMPLETE, + AccountInputKind.DEBTOR_AUTOCOMPLETE, + -> { + if (selected != null && accountId != null) { + PreservedAccountSelection(accountId, selected, query) + } else { + PreservedAccountSelection(null, null, "") + } + } + } +} diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt index 0cff77b..7a63e47 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt @@ -367,13 +367,19 @@ class TransactionFormViewModel @Inject constructor( val newSourceKind = TransactionFormUiState.inputKindForSource(type) val newTargetKind = TransactionFormUiState.inputKindForTarget(type) - val source = preserveSourceSelection( - current = current, + val source = preserveAccountSelection( + accountId = current.sourceAccountId, + selected = current.sourceSelected, + query = current.sourceQuery, + ownedAccounts = current.ownedAccounts, oldKind = oldSourceKind, newKind = newSourceKind, ) - val target = preserveTargetSelection( - current = current, + val target = preserveAccountSelection( + accountId = current.targetAccountId, + selected = current.targetSelected, + query = current.targetQuery, + ownedAccounts = current.ownedAccounts, oldKind = oldTargetKind, newKind = newTargetKind, ) @@ -1066,74 +1072,6 @@ class TransactionFormViewModel @Inject constructor( _uiState.update { it.copy(isSaving = false, saveSuccess = true) } } - private data class PreservedSide( - val accountId: Long?, - val selected: FilterOption?, - val query: String, - ) - - private fun preserveSourceSelection( - current: TransactionFormUiState, - oldKind: AccountInputKind, - newKind: AccountInputKind, - ): PreservedSide { - if (oldKind != newKind) return PreservedSide(null, null, "") - return when (newKind) { - AccountInputKind.OWNED_DROPDOWN -> { - val id = current.sourceAccountId - if (id != null && current.ownedAccounts.any { it.id == id }) { - PreservedSide(id, null, "") - } else { - PreservedSide(null, null, "") - } - } - AccountInputKind.CREDITOR_AUTOCOMPLETE, - AccountInputKind.DEBTOR_AUTOCOMPLETE, - -> { - if (current.sourceSelected != null && current.sourceAccountId != null) { - PreservedSide( - current.sourceAccountId, - current.sourceSelected, - current.sourceQuery, - ) - } else { - PreservedSide(null, null, "") - } - } - } - } - - private fun preserveTargetSelection( - current: TransactionFormUiState, - oldKind: AccountInputKind, - newKind: AccountInputKind, - ): PreservedSide { - if (oldKind != newKind) return PreservedSide(null, null, "") - return when (newKind) { - AccountInputKind.OWNED_DROPDOWN -> { - val id = current.targetAccountId - if (id != null && current.ownedAccounts.any { it.id == id }) { - PreservedSide(id, null, "") - } else { - PreservedSide(null, null, "") - } - } - AccountInputKind.CREDITOR_AUTOCOMPLETE, - AccountInputKind.DEBTOR_AUTOCOMPLETE, - -> { - if (current.targetSelected != null && current.targetAccountId != null) { - PreservedSide( - current.targetAccountId, - current.targetSelected, - current.targetQuery, - ) - } else { - PreservedSide(null, null, "") - } - } - } - } - private fun resolveAccountName( accountId: Long?, selected: FilterOption?, diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 091cffb..1c4c7f9 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -17,7 +17,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled. - [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope). - [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`). -- [~] High #7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels); additional decomposition still possible. +- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form account-preservation logic extracted to a dedicated helper; additional decomposition still possible. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). From 5f41ad8e65c4f4e5d5647ebb10bab9fed8bc278b Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 20:26:29 +0200 Subject: [PATCH 13/17] Extract transaction-form submission helpers. Move transaction payload building and optional metadata id resolution into dedicated helper functions to reduce TransactionFormViewModel responsibilities while keeping submit behavior unchanged. Co-authored-by: Cursor --- .../transactions/TransactionFormSubmission.kt | 51 +++++++++++++ .../transactions/TransactionFormViewModel.kt | 74 ++++++++----------- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 3 files changed, 83 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormSubmission.kt diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormSubmission.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormSubmission.kt new file mode 100644 index 0000000..d549253 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormSubmission.kt @@ -0,0 +1,51 @@ +package com.pledgerio.app.ui.transactions + +import com.pledgerio.app.domain.model.Account +import com.pledgerio.app.domain.model.FilterOption +import com.pledgerio.app.domain.model.Transaction + +internal fun resolveAccountDisplayName( + accountId: Long?, + selected: FilterOption?, + ownedAccounts: List, +): String { + selected?.label?.let { return it } + return ownedAccounts.find { it.id == accountId }?.name ?: "" +} + +internal fun buildTransactionForSubmit( + state: TransactionFormUiState, + sourceName: String, + targetName: String, + categoryId: Long?, + expenseId: Long?, + contractId: Long?, +): Transaction { + return Transaction( + id = state.editingTransactionId ?: 0, + description = state.description.trim(), + amount = state.amount.toDouble(), + currency = state.currency, + type = state.type, + date = state.date, + sourceAccountId = state.sourceAccountId, + sourceAccountName = sourceName, + destinationAccountId = state.targetAccountId, + destinationAccountName = targetName, + categoryId = categoryId, + expenseId = expenseId, + contractId = contractId, + tags = state.tags, + ) +} + +internal suspend fun resolveOptionalSelectionId( + selected: FilterOption?, + query: String, + resolveByName: suspend (String) -> FilterOption?, +): Long? { + selected?.id?.let { return it } + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) return null + return resolveByName(trimmedQuery)?.id +} diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt index 7a63e47..41d705c 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt @@ -601,13 +601,13 @@ class TransactionFormViewModel @Inject constructor( type = state.type.name, currency = state.currency, sourceAccountId = state.sourceAccountId, - sourceAccountName = resolveAccountName( + sourceAccountName = resolveAccountDisplayName( state.sourceAccountId, state.sourceSelected, state.ownedAccounts, ), targetAccountId = state.targetAccountId, - targetAccountName = resolveAccountName( + targetAccountName = resolveAccountDisplayName( state.targetAccountId, state.targetSelected, state.ownedAccounts, @@ -783,12 +783,12 @@ class TransactionFormViewModel @Inject constructor( return } - val sourceName = resolveAccountName( + val sourceName = resolveAccountDisplayName( accountId = state.sourceAccountId, selected = state.sourceSelected, ownedAccounts = state.ownedAccounts, ).ifBlank { state.sourceQuery.trim() }.takeIf { it.isNotBlank() } - val targetName = resolveAccountName( + val targetName = resolveAccountDisplayName( accountId = state.targetAccountId, selected = state.targetSelected, ownedAccounts = state.ownedAccounts, @@ -999,32 +999,26 @@ class TransactionFormViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isSaving = true, error = null) } - val sourceName = resolveAccountName( + val sourceName = resolveAccountDisplayName( accountId = state.sourceAccountId, selected = state.sourceSelected, ownedAccounts = state.ownedAccounts, ) - val targetName = resolveAccountName( + val targetName = resolveAccountDisplayName( accountId = state.targetAccountId, selected = state.targetSelected, ownedAccounts = state.ownedAccounts, ) - - val transaction = Transaction( - id = state.editingTransactionId ?: 0, - description = state.description.trim(), - amount = state.amount.toDouble(), - currency = state.currency, - type = state.type, - date = state.date, - sourceAccountId = state.sourceAccountId, - sourceAccountName = sourceName, - destinationAccountId = state.targetAccountId, - destinationAccountName = targetName, - categoryId = resolveCategoryId(state), - expenseId = resolveExpenseId(state), - contractId = resolveContractId(state), - tags = state.tags, + val categoryId = resolveCategoryId(state) + val expenseId = resolveExpenseId(state) + val contractId = resolveContractId(state) + val transaction = buildTransactionForSubmit( + state = state, + sourceName = sourceName, + targetName = targetName, + categoryId = categoryId, + expenseId = expenseId, + contractId = contractId, ) if (state.isEditing && state.editingTransactionId != null) { @@ -1072,15 +1066,6 @@ class TransactionFormViewModel @Inject constructor( _uiState.update { it.copy(isSaving = false, saveSuccess = true) } } - private fun resolveAccountName( - accountId: Long?, - selected: FilterOption?, - ownedAccounts: List, - ): String { - selected?.label?.let { return it } - return ownedAccounts.find { it.id == accountId }?.name ?: "" - } - private suspend fun resolveCategoryOptionByName(name: String): FilterOption? { return when (val result = categoryRepository.searchCategories(name)) { is Resource.Success -> { @@ -1115,24 +1100,27 @@ class TransactionFormViewModel @Inject constructor( } private suspend fun resolveCategoryId(state: TransactionFormUiState): Long? { - state.categorySelected?.id?.let { return it } - val name = state.categoryQuery.trim() - if (name.isEmpty()) return null - return resolveCategoryOptionByName(name)?.id + return resolveOptionalSelectionId( + selected = state.categorySelected, + query = state.categoryQuery, + resolveByName = ::resolveCategoryOptionByName, + ) } private suspend fun resolveExpenseId(state: TransactionFormUiState): Long? { - state.expenseSelected?.id?.let { return it } - val name = state.expenseQuery.trim() - if (name.isEmpty()) return null - return resolveExpenseOptionByName(name)?.id + return resolveOptionalSelectionId( + selected = state.expenseSelected, + query = state.expenseQuery, + resolveByName = ::resolveExpenseOptionByName, + ) } private suspend fun resolveContractId(state: TransactionFormUiState): Long? { - state.contractSelected?.id?.let { return it } - val name = state.contractQuery.trim() - if (name.isEmpty()) return null - return resolveContractOptionByName(name)?.id + return resolveOptionalSelectionId( + selected = state.contractSelected, + query = state.contractQuery, + resolveByName = ::resolveContractOptionByName, + ) } private fun bestMatchOption(name: String, options: List): FilterOption? { diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 1c4c7f9..4172796 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -17,7 +17,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled. - [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope). - [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`). -- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form account-preservation logic extracted to a dedicated helper; additional decomposition still possible. +- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution helpers); additional decomposition still possible. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). From fa8fea4343b03c0e499ebff9e42d0384b0e3aa49 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 21:04:29 +0200 Subject: [PATCH 14/17] Extract transaction-form auto-classify apply helper. Move auto-classify status and state-merge logic into a focused helper file so TransactionFormViewModel keeps orchestration concerns while preserving behavior. Co-authored-by: Cursor --- .../TransactionFormAutoClassify.kt | 110 ++++++++++++++++++ .../transactions/TransactionFormViewModel.kt | 103 +++------------- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 3 files changed, 128 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAutoClassify.kt diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAutoClassify.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAutoClassify.kt new file mode 100644 index 0000000..0dc7139 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormAutoClassify.kt @@ -0,0 +1,110 @@ +package com.pledgerio.app.ui.transactions + +import com.pledgerio.app.domain.model.FilterOption +import com.pledgerio.app.ui.transactions.form.AutoClassifyStatus +import com.pledgerio.app.ui.transactions.form.ClassifyPart + +internal data class AutoClassifyApplyResult( + val updatedState: TransactionFormUiState, + val unresolvedCategoryQuery: String?, + val unresolvedExpenseQuery: String?, +) + +internal fun applyAutoClassifySuggestion( + current: TransactionFormUiState, + suggestedCategoryRaw: String?, + suggestedExpenseRaw: String?, + suggestedTagsRaw: List, + categoryOption: FilterOption?, + expenseOption: FilterOption?, +): AutoClassifyApplyResult { + val suggestedCategory = suggestedCategoryRaw?.trim().orEmpty() + val suggestedExpense = suggestedExpenseRaw?.trim().orEmpty() + val suggestedTags = suggestedTagsRaw + .map { it.trim() } + .filter { it.isNotBlank() } + + val status = resolveAutoClassifyStatus( + suggestedCategory = suggestedCategory, + suggestedExpense = suggestedExpense, + suggestedTags = suggestedTags, + categoryOption = categoryOption, + expenseOption = expenseOption, + ) + + val mergedTags = if (suggestedTags.isNotEmpty()) { + (current.tags + suggestedTags).distinctBy { it.lowercase() } + } else { + current.tags + } + val shouldExpand = suggestedCategory.isNotBlank() || + suggestedExpense.isNotBlank() || + suggestedTags.isNotEmpty() + + return AutoClassifyApplyResult( + updatedState = current.copy( + isAutoClassifying = false, + autoClassifyStatus = status, + categorySelected = when { + categoryOption != null -> categoryOption + suggestedCategory.isNotBlank() -> null + else -> current.categorySelected + }, + categoryQuery = when { + categoryOption != null -> categoryOption.label + suggestedCategory.isNotBlank() -> suggestedCategory + else -> current.categoryQuery + }, + categorySuggestions = emptyList(), + expenseSelected = when { + expenseOption != null -> expenseOption + suggestedExpense.isNotBlank() -> null + else -> current.expenseSelected + }, + expenseQuery = when { + expenseOption != null -> expenseOption.label + suggestedExpense.isNotBlank() -> suggestedExpense + else -> current.expenseQuery + }, + expenseSuggestions = emptyList(), + tags = mergedTags, + tagInput = "", + moreOptionsExpanded = if (shouldExpand) true else current.moreOptionsExpanded, + moreOptionsManuallyToggled = shouldExpand || current.moreOptionsManuallyToggled, + ), + unresolvedCategoryQuery = suggestedCategory.takeIf { + it.isNotBlank() && categoryOption == null + }, + unresolvedExpenseQuery = suggestedExpense.takeIf { + it.isNotBlank() && expenseOption == null + }, + ) +} + +private fun resolveAutoClassifyStatus( + suggestedCategory: String, + suggestedExpense: String, + suggestedTags: List, + categoryOption: FilterOption?, + expenseOption: FilterOption?, +): AutoClassifyStatus { + val appliedParts = buildList { + if (categoryOption != null) add(ClassifyPart.CATEGORY) + if (expenseOption != null) add(ClassifyPart.EXPENSE_GROUP) + if (suggestedTags.isNotEmpty()) add(ClassifyPart.TAGS) + } + val unresolvedParts = buildList { + if (suggestedCategory.isNotBlank() && categoryOption == null) { + add(ClassifyPart.CATEGORY) + } + if (suggestedExpense.isNotBlank() && expenseOption == null) { + add(ClassifyPart.EXPENSE_GROUP) + } + } + return when { + appliedParts.isEmpty() && unresolvedParts.isEmpty() -> AutoClassifyStatus.NoSuggestions + appliedParts.isNotEmpty() && unresolvedParts.isEmpty() -> AutoClassifyStatus.Applied(appliedParts) + appliedParts.isEmpty() -> AutoClassifyStatus.Unresolved(unresolvedParts) + else -> AutoClassifyStatus.Partial(applied = appliedParts, unresolved = unresolvedParts) + } +} diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt index 41d705c..f485b8c 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt @@ -806,99 +806,30 @@ class TransactionFormViewModel @Inject constructor( ) { is Resource.Success -> { val suggestion = result.data - val suggestedCategory = suggestion.category?.trim().orEmpty() - val suggestedExpense = suggestion.budget?.trim().orEmpty() - val suggestedTags = suggestion.tags - .map { it.trim() } - .filter { it.isNotBlank() } + val suggestedCategory = suggestion.category + val suggestedExpense = suggestion.budget val categoryOption = suggestedCategory + .orEmpty() + .trim() .takeIf { it.isNotBlank() } ?.let { resolveCategoryOptionByName(it) } val expenseOption = suggestedExpense + .orEmpty() + .trim() .takeIf { it.isNotBlank() } ?.let { resolveExpenseOptionByName(it) } - - val appliedParts = buildList { - if (categoryOption != null) { - add(com.pledgerio.app.ui.transactions.form.ClassifyPart.CATEGORY) - } - if (expenseOption != null) { - add(com.pledgerio.app.ui.transactions.form.ClassifyPart.EXPENSE_GROUP) - } - if (suggestedTags.isNotEmpty()) { - add(com.pledgerio.app.ui.transactions.form.ClassifyPart.TAGS) - } - } - val unresolvedParts = buildList { - if (suggestedCategory.isNotBlank() && categoryOption == null) { - add(com.pledgerio.app.ui.transactions.form.ClassifyPart.CATEGORY) - } - if (suggestedExpense.isNotBlank() && expenseOption == null) { - add(com.pledgerio.app.ui.transactions.form.ClassifyPart.EXPENSE_GROUP) - } - } - val status = when { - appliedParts.isEmpty() && unresolvedParts.isEmpty() -> - com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.NoSuggestions - appliedParts.isNotEmpty() && unresolvedParts.isEmpty() -> - com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.Applied(appliedParts) - appliedParts.isEmpty() -> - com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.Unresolved(unresolvedParts) - else -> - com.pledgerio.app.ui.transactions.form.AutoClassifyStatus.Partial( - applied = appliedParts, - unresolved = unresolvedParts, - ) - } - - _uiState.update { current -> - val mergedTags = if (suggestedTags.isNotEmpty()) { - (current.tags + suggestedTags) - .distinctBy { it.lowercase() } - } else { - current.tags - } - val shouldExpand = suggestedCategory.isNotBlank() || - suggestedExpense.isNotBlank() || - suggestedTags.isNotEmpty() - current.copy( - isAutoClassifying = false, - autoClassifyStatus = status, - categorySelected = when { - categoryOption != null -> categoryOption - suggestedCategory.isNotBlank() -> null - else -> current.categorySelected - }, - categoryQuery = when { - categoryOption != null -> categoryOption.label - suggestedCategory.isNotBlank() -> suggestedCategory - else -> current.categoryQuery - }, - categorySuggestions = emptyList(), - expenseSelected = when { - expenseOption != null -> expenseOption - suggestedExpense.isNotBlank() -> null - else -> current.expenseSelected - }, - expenseQuery = when { - expenseOption != null -> expenseOption.label - suggestedExpense.isNotBlank() -> suggestedExpense - else -> current.expenseQuery - }, - expenseSuggestions = emptyList(), - tags = mergedTags, - tagInput = "", - moreOptionsExpanded = if (shouldExpand) true else current.moreOptionsExpanded, - moreOptionsManuallyToggled = shouldExpand || current.moreOptionsManuallyToggled, - ) - } - if (suggestedCategory.isNotBlank() && categoryOption == null) { - categoryQueryFlow.value = suggestedCategory - } - if (suggestedExpense.isNotBlank() && expenseOption == null) { - expenseQueryFlow.value = suggestedExpense - } + val applyResult = applyAutoClassifySuggestion( + current = _uiState.value, + suggestedCategoryRaw = suggestedCategory, + suggestedExpenseRaw = suggestedExpense, + suggestedTagsRaw = suggestion.tags, + categoryOption = categoryOption, + expenseOption = expenseOption, + ) + _uiState.update { applyResult.updatedState } + applyResult.unresolvedCategoryQuery?.let { categoryQueryFlow.value = it } + applyResult.unresolvedExpenseQuery?.let { expenseQueryFlow.value = it } } is Resource.Error -> { diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 4172796..90bd8de 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -17,7 +17,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled. - [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope). - [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`). -- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution helpers); additional decomposition still possible. +- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply logic); additional decomposition still possible. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). From 96ae8b80b531e2e0f96e8f309d53cb1fb624162f Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 21:06:19 +0200 Subject: [PATCH 15/17] Extract transaction-form counterparty search state helper. Move source/target counterparty search state transitions into focused helper functions to simplify TransactionFormViewModel orchestration while preserving existing search behavior. Co-authored-by: Cursor --- .../TransactionFormCounterpartySearch.kt | 37 +++++++++++++++++++ .../transactions/TransactionFormViewModel.kt | 27 ++------------ docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 3 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormCounterpartySearch.kt diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormCounterpartySearch.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormCounterpartySearch.kt new file mode 100644 index 0000000..447f7d0 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormCounterpartySearch.kt @@ -0,0 +1,37 @@ +package com.pledgerio.app.ui.transactions + +import com.pledgerio.app.domain.model.FilterOption + +internal fun clearCounterpartySearchState( + state: TransactionFormUiState, + isSource: Boolean, +): TransactionFormUiState { + return if (isSource) { + state.copy(sourceSuggestions = emptyList(), isSearchingSource = false) + } else { + state.copy(targetSuggestions = emptyList(), isSearchingTarget = false) + } +} + +internal fun markCounterpartySearchInProgress( + state: TransactionFormUiState, + isSource: Boolean, +): TransactionFormUiState { + return if (isSource) { + state.copy(isSearchingSource = true) + } else { + state.copy(isSearchingTarget = true) + } +} + +internal fun applyCounterpartySearchSuccess( + state: TransactionFormUiState, + isSource: Boolean, + options: List, +): TransactionFormUiState { + return if (isSource) { + state.copy(isSearchingSource = false, sourceSuggestions = options) + } else { + state.copy(isSearchingTarget = false, targetSuggestions = options) + } +} diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt index f485b8c..b417afe 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt @@ -1085,40 +1085,21 @@ class TransactionFormViewModel @Inject constructor( isSource: Boolean, ) { if (query.isBlank()) { - _uiState.update { - if (isSource) { - it.copy(sourceSuggestions = emptyList(), isSearchingSource = false) - } else { - it.copy(targetSuggestions = emptyList(), isSearchingTarget = false) - } - } + _uiState.update { clearCounterpartySearchState(it, isSource = isSource) } return } - _uiState.update { - if (isSource) it.copy(isSearchingSource = true) - else it.copy(isSearchingTarget = true) - } + _uiState.update { markCounterpartySearchInProgress(it, isSource = isSource) } when (val result = accountRepository.searchAccounts(typeCode, query)) { is Resource.Success -> { val options = result.data.map { account -> FilterOption(account.id, account.name) } _uiState.update { - if (isSource) { - it.copy(isSearchingSource = false, sourceSuggestions = options) - } else { - it.copy(isSearchingTarget = false, targetSuggestions = options) - } + applyCounterpartySearchSuccess(it, isSource = isSource, options = options) } } is Resource.Error -> { - _uiState.update { - if (isSource) { - it.copy(isSearchingSource = false, sourceSuggestions = emptyList()) - } else { - it.copy(isSearchingTarget = false, targetSuggestions = emptyList()) - } - } + _uiState.update { clearCounterpartySearchState(it, isSource = isSource) } } is Resource.Loading -> {} } diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 90bd8de..a9145f6 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -17,7 +17,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled. - [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope). - [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`). -- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply logic); additional decomposition still possible. +- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply + counterparty-search state handling); additional decomposition still possible. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). From 8b1a95b47deadd1123e0989de07a48a1274cce4a Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 21:10:06 +0200 Subject: [PATCH 16/17] Extract transaction-form edit-load state mapping helper. Move transaction-to-form edit state mapping into a dedicated helper to reduce TransactionFormViewModel size while preserving edit initialization behavior. Co-authored-by: Cursor --- .../transactions/TransactionFormEditState.kt | 66 +++++++++++++++++++ .../transactions/TransactionFormViewModel.kt | 45 ++----------- docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 3 files changed, 72 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormEditState.kt diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormEditState.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormEditState.kt new file mode 100644 index 0000000..eddf53b --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormEditState.kt @@ -0,0 +1,66 @@ +package com.pledgerio.app.ui.transactions + +import com.pledgerio.app.domain.model.FilterOption +import com.pledgerio.app.domain.model.Transaction +import com.pledgerio.app.domain.model.TransactionSplit + +internal fun buildStateAfterEditLoad( + current: TransactionFormUiState, + tx: Transaction, + sourceKind: AccountInputKind, + targetKind: AccountInputKind, + categorySelected: FilterOption?, + expenseSelected: FilterOption?, + contractSelected: FilterOption?, + splitLines: List, +): TransactionFormUiState { + val sourceSelected = tx.toAutocompleteSelection( + isSource = true, + inputKind = sourceKind, + ) + val targetSelected = tx.toAutocompleteSelection( + isSource = false, + inputKind = targetKind, + ) + val splitSnapshot: List = tx.split + + return current.copy( + isLoading = false, + type = tx.type, + description = tx.description, + amount = tx.amount.toString(), + currency = tx.currency, + date = tx.date, + sourceAccountId = tx.sourceAccountId, + sourceSelected = sourceSelected, + sourceQuery = tx.sourceAccountName, + targetAccountId = tx.destinationAccountId, + targetSelected = targetSelected, + targetQuery = tx.destinationAccountName, + categorySelected = categorySelected, + categoryQuery = categorySelected?.label ?: tx.categoryName.orEmpty(), + expenseSelected = expenseSelected, + expenseQuery = expenseSelected?.label ?: tx.budgetName.orEmpty(), + contractSelected = contractSelected, + contractQuery = contractSelected?.label ?: tx.contractName.orEmpty(), + tags = tx.tags, + splitLines = splitLines, + originalSplitSnapshot = splitSnapshot, + splitSectionExpanded = splitSnapshot.isNotEmpty(), + moreOptionsExpanded = tx.tags.isNotEmpty() || + tx.categoryName != null || + tx.budgetName != null || + tx.contractName != null, + moreOptionsManuallyToggled = true, + ) +} + +private fun Transaction.toAutocompleteSelection( + isSource: Boolean, + inputKind: AccountInputKind, +): FilterOption? { + if (inputKind == AccountInputKind.OWNED_DROPDOWN) return null + val accountId = if (isSource) sourceAccountId else destinationAccountId + val accountName = if (isSource) sourceAccountName else destinationAccountName + return accountId?.let { FilterOption(id = it, label = accountName) } +} diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt index b417afe..318df9f 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt @@ -1123,50 +1123,15 @@ class TransactionFormViewModel @Inject constructor( val sourceKind = TransactionFormUiState.inputKindForSource(tx.type) val targetKind = TransactionFormUiState.inputKindForTarget(tx.type) _uiState.update { state -> - val sourceSelected = if ( - sourceKind != AccountInputKind.OWNED_DROPDOWN && - tx.sourceAccountId != null - ) { - FilterOption(tx.sourceAccountId, tx.sourceAccountName) - } else { - null - } - val targetSelected = if ( - targetKind != AccountInputKind.OWNED_DROPDOWN && - tx.destinationAccountId != null - ) { - FilterOption(tx.destinationAccountId, tx.destinationAccountName) - } else { - null - } - state.copy( - isLoading = false, - type = tx.type, - description = tx.description, - amount = tx.amount.toString(), - currency = tx.currency, - date = tx.date, - sourceAccountId = tx.sourceAccountId, - sourceSelected = sourceSelected, - sourceQuery = tx.sourceAccountName, - targetAccountId = tx.destinationAccountId, - targetSelected = targetSelected, - targetQuery = tx.destinationAccountName, + buildStateAfterEditLoad( + current = state, + tx = tx, + sourceKind = sourceKind, + targetKind = targetKind, categorySelected = categorySelected, - categoryQuery = categorySelected?.label ?: tx.categoryName.orEmpty(), expenseSelected = expenseSelected, - expenseQuery = expenseSelected?.label ?: tx.budgetName.orEmpty(), contractSelected = contractSelected, - contractQuery = contractSelected?.label ?: tx.contractName.orEmpty(), - tags = tx.tags, splitLines = tx.split.toSplitLineUi(), - originalSplitSnapshot = tx.split, - splitSectionExpanded = tx.split.isNotEmpty(), - moreOptionsExpanded = tx.tags.isNotEmpty() || - tx.categoryName != null || - tx.budgetName != null || - tx.contractName != null, - moreOptionsManuallyToggled = true, ) } } diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index a9145f6..6d885b3 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -17,7 +17,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled. - [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope). - [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`). -- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply + counterparty-search state handling); additional decomposition still possible. +- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply + counterparty-search state handling + edit-load state mapping); additional decomposition still possible. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state). From 6a328367fb662f71074bf041db9eb15686637a0c Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Sun, 24 May 2026 21:11:45 +0200 Subject: [PATCH 17/17] Extract transaction-form experience-mode decision helper. Move optional-section expansion decision logic into a focused helper so TransactionFormViewModel keeps reactive flow orchestration while preserving mode behavior. Co-authored-by: Cursor --- .../transactions/TransactionFormExperienceMode.kt | 15 +++++++++++++++ .../ui/transactions/TransactionFormViewModel.kt | 7 +------ docs/CODEBASE_IMPROVEMENT_AUDIT.md | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormExperienceMode.kt diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormExperienceMode.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormExperienceMode.kt new file mode 100644 index 0000000..cb514f8 --- /dev/null +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormExperienceMode.kt @@ -0,0 +1,15 @@ +package com.pledgerio.app.ui.transactions + +import com.pledgerio.app.domain.model.FinanceExperienceMode + +internal fun resolveMoreOptionsExpansion( + previous: TransactionFormUiState, + mode: FinanceExperienceMode, +): Boolean { + return when { + previous.isEditing -> previous.moreOptionsExpanded + previous.moreOptionsManuallyToggled -> previous.moreOptionsExpanded + mode == FinanceExperienceMode.POWER -> true + else -> false + } +} diff --git a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt index 318df9f..b5cc954 100644 --- a/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt +++ b/app/src/main/java/com/pledgerio/app/ui/transactions/TransactionFormViewModel.kt @@ -1221,12 +1221,7 @@ class TransactionFormViewModel @Inject constructor( viewModelScope.launch { userPreferences.financeExperienceMode.collect { mode -> val previous = _uiState.value - val shouldExpandMoreOptions = when { - previous.isEditing -> previous.moreOptionsExpanded - previous.moreOptionsManuallyToggled -> previous.moreOptionsExpanded - mode == FinanceExperienceMode.POWER -> true - else -> false - } + val shouldExpandMoreOptions = resolveMoreOptionsExpansion(previous, mode) if ( previous.financeExperienceMode == mode && previous.moreOptionsExpanded == shouldExpandMoreOptions diff --git a/docs/CODEBASE_IMPROVEMENT_AUDIT.md b/docs/CODEBASE_IMPROVEMENT_AUDIT.md index 6d885b3..9a7f225 100644 --- a/docs/CODEBASE_IMPROVEMENT_AUDIT.md +++ b/docs/CODEBASE_IMPROVEMENT_AUDIT.md @@ -17,7 +17,7 @@ Legend: `[x]` done, `[~]` in progress, `[ ]` not started. - [x] High #3: destructive migration fallback removed; explicit migration scaffold added; Room schema export enabled. - [x] High #4: blocking interceptor cleanup removed (`clearAllUserDataAsync` on app scope). - [x] High #6: UI/data layering tightened for reports and invoice-scan (`ReportsOverviewStore`, `ProcessInvoiceScanUseCase`, `InvoiceTextReader`). -- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply + counterparty-search state handling + edit-load state mapping); additional decomposition still possible. +- [~] High #5/#7: major use-case consistency cleanup completed (`LoginUseCase`, `GetDashboardDataUseCase`, `GetBudgetsUseCase`, `GetTransactionsUseCase` wired in ViewModels) and transaction-form helpers extracted (account preservation + submission/id-resolution + auto-classify apply + counterparty-search state handling + edit-load state mapping + experience-mode expansion decision); additional decomposition still possible. - [x] High #8: instrumented/UI test coverage started (`androidTest` smoke test + CI instrumented job). - [x] Medium #9: `allowBackup` hardened (`false`). - [x] Medium #10: `CurrencyProvider` singleton access removed (`getInstance`/`setInstance` retired; formatting no longer relies on static provider state).