diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 19fad4cc..68939ff0 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -42,3 +42,14 @@ jobs:
- name: Verify screenshots
run: ./gradlew :app:verifyPaparazziFossDebug
+
+ - name: Upload Paparazzi diffs
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: paparazzi-failures
+ path: |
+ app/build/paparazzi/failures/
+ app/build/reports/paparazzi/
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 326e8cf8..4e1cc86d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -71,6 +71,11 @@
+
+
+
+
+
0) {
+ recordStatus(STATUS_RESTORED)
+ return Result.success()
+ }
+ if (restored != null && restored.errors.isNotEmpty()) {
+ recordStatus(STATUS_REJECTED, EXPORT_FILE_NAME)
+ return Result.success()
+ }
+
+ val outcome = ingest(resolver, children)
+ writeMirror(resolver, folder, children)
+
+ when {
+ outcome.rejectedFiles.isNotEmpty() -> recordStatus(
+ STATUS_REJECTED,
+ outcome.rejectedFiles.joinToString(", ")
+ )
+
+ else -> recordStatus(STATUS_OK, outcome.imported.toString())
+ }
+ Result.success()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: SecurityException) {
+ settingsRepository.setString(SYNC_FOLDER_URI_KEY, "")
+ recordStatus(STATUS_NO_ACCESS)
+ errorLogRecorder.record("CsvSyncWorker.doWork", e)
+ Result.failure()
+ } catch (e: Exception) {
+ recordStatus(STATUS_ERROR, e.message.orEmpty())
+ errorLogRecorder.record("CsvSyncWorker.doWork", e)
+ Result.retry()
+ }
+ }
+
+ private fun migrateLegacyMirror(
+ resolver: ContentResolver,
+ folder: Uri,
+ children: List>,
+ ): List> {
+ if (children.any { (_, name) -> name == EXPORT_FILE_NAME }) return children
+
+ val legacy = children.firstOrNull { (_, name) -> name == LEGACY_EXPORT_FILE_NAME }?.first
+ ?: return children
+
+ DocumentsContract.renameDocument(resolver, legacy, EXPORT_FILE_NAME)
+ ?: error("Could not rename $LEGACY_EXPORT_FILE_NAME to $EXPORT_FILE_NAME")
+
+ return childDocuments(folder) ?: error("Could not list the sync folder")
+ }
+
+ private suspend fun restoreFromMirror(
+ resolver: ContentResolver,
+ children: List>,
+ ): CsvImportResult? {
+ val mirror = children.firstOrNull { (_, name) -> name == EXPORT_FILE_NAME }?.first
+ ?: return null
+ if (!csvService.hasNoLocalData()) return null
+
+ return resolver.openInputStream(mirror)?.use { csvService.importTransactions(it) }
+ ?: error("Could not open $EXPORT_FILE_NAME for reading")
+ }
+
+ private suspend fun ingest(
+ resolver: ContentResolver,
+ children: List>,
+ ): IngestOutcome {
+ var imported = 0
+ val rejected = mutableListOf()
+
+ children.filter { (_, name) -> isIngestCandidate(name) }
+ .forEach { (document, name) ->
+ val claimed = DocumentsContract.renameDocument(
+ resolver,
+ document,
+ name + SUFFIX_IN_PROGRESS
+ ) ?: error("Could not claim $name for import")
+
+ val result = try {
+ resolver.openInputStream(claimed)?.use { csvService.importTransactions(it) }
+ ?: error("Could not open $name for reading")
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } catch (failure: Exception) {
+ DocumentsContract.renameDocument(resolver, claimed, name + SUFFIX_REJECTED)
+ throw failure
+ }
+
+ imported += result.imported
+ val settledName = if (result.errors.isEmpty()) {
+ name + SUFFIX_IMPORTED
+ } else {
+ rejected += name
+ name + SUFFIX_REJECTED
+ }
+
+ DocumentsContract.renameDocument(resolver, claimed, settledName)
+ ?: error("Imported $name but could not mark it as processed")
+ }
+
+ return IngestOutcome(imported, rejected)
+ }
+
+ private suspend fun writeMirror(
+ resolver: ContentResolver,
+ folder: Uri,
+ children: List>,
+ ) {
+ val treeDocument = DocumentsContract.buildDocumentUriUsingTree(
+ folder,
+ DocumentsContract.getTreeDocumentId(folder)
+ )
+
+ val staging = children.firstOrNull { (_, name) -> name == STAGING_FILE_NAME }?.first
+ ?: DocumentsContract.createDocument(
+ resolver,
+ treeDocument,
+ MIME_TYPE_CSV,
+ STAGING_FILE_NAME
+ )
+ ?: error("Could not create $STAGING_FILE_NAME in the sync folder")
+
+ resolver.openOutputStream(staging, "wt")?.use { output ->
+ csvService.exportAllTransactions(output)
+ } ?: error("Could not open $STAGING_FILE_NAME for writing")
+
+ children.firstOrNull { (_, name) -> name == EXPORT_FILE_NAME }?.first?.let { previous ->
+ val stamp = LocalDateTime.now().format(BACKUP_STAMP)
+ DocumentsContract.renameDocument(resolver, previous, "$BACKUP_PREFIX$stamp.csv")
+ ?: error("Could not rotate the previous $EXPORT_FILE_NAME")
+ }
+
+ DocumentsContract.renameDocument(resolver, staging, EXPORT_FILE_NAME)
+ ?: error("Exported data but could not publish it as $EXPORT_FILE_NAME")
+
+ pruneBackups(resolver, folder)
+ }
+
+ private fun pruneBackups(resolver: ContentResolver, folder: Uri) {
+ val backups = childDocuments(folder)
+ ?.filter { (_, name) -> isRotatedBackup(name) }
+ ?.sortedByDescending { (_, name) -> name }
+ ?: return
+
+ backups.drop(BACKUPS_KEPT).forEach { (document, _) ->
+ runCatching { DocumentsContract.deleteDocument(resolver, document) }
+ }
+ }
+
+ private suspend fun recordStatus(code: String, detail: String = "") {
+ settingsRepository.setString(
+ SYNC_STATUS_KEY,
+ listOf(code, System.currentTimeMillis().toString(), detail)
+ .joinToString(STATUS_SEPARATOR)
+ )
+ }
+
+ private fun childDocuments(folder: Uri): List>? {
+ val children = DocumentsContract.buildChildDocumentsUriUsingTree(
+ folder,
+ DocumentsContract.getTreeDocumentId(folder)
+ )
+
+ return applicationContext.contentResolver.query(
+ children,
+ arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ ),
+ null,
+ null,
+ null
+ )?.use { cursor ->
+ buildList {
+ while (cursor.moveToNext()) {
+ val documentId = cursor.getString(0) ?: continue
+ val displayName = cursor.getString(1) ?: continue
+ add(
+ DocumentsContract.buildDocumentUriUsingTree(folder, documentId)
+ to displayName
+ )
+ }
+ }
+ }
+ }
+
+ private data class IngestOutcome(
+ val imported: Int,
+ val rejectedFiles: List,
+ )
+
+ companion object {
+ const val SYNC_FOLDER_URI_KEY = "sync_folder_uri"
+ const val SYNC_STATUS_KEY = "sync_last_status"
+ const val EXPORT_FILE_NAME = "minus_synced.csv"
+ const val STAGING_FILE_NAME = "minus_synced.csv.writing"
+ const val LEGACY_EXPORT_FILE_NAME = "minus_latest.csv"
+ const val UNIQUE_WORK_NAME = "csv_folder_sync"
+ const val IMMEDIATE_WORK_NAME = "csv_folder_sync_now"
+ const val ACTION_SYNC_NOW = "com.serranoie.app.minus.action.SYNC_NOW"
+ const val SYNC_INTERVAL_HOURS = 2L
+
+ const val STATUS_SEPARATOR = "|"
+ const val STATUS_OK = "ok"
+ const val STATUS_RESTORED = "restored"
+ const val STATUS_REJECTED = "rejected"
+ const val STATUS_ERROR = "error"
+ const val STATUS_NO_ACCESS = "no_access"
+
+ const val IMPORT_PREFIX = "minus_import_"
+ const val BACKUP_PREFIX = "minus_backup-"
+ const val BACKUPS_KEPT = 5
+ const val SUFFIX_IN_PROGRESS = ".importing"
+ const val SUFFIX_IMPORTED = ".imported"
+ const val SUFFIX_REJECTED = ".rejected"
+
+ private const val MIME_TYPE_CSV = "text/csv"
+ private val BACKUP_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
+
+ private val syncMutex = Mutex()
+
+ fun isIngestCandidate(displayName: String): Boolean {
+ return displayName.startsWith(IMPORT_PREFIX, ignoreCase = true) &&
+ displayName.endsWith(".csv", ignoreCase = true)
+ }
+
+ fun isRotatedBackup(displayName: String): Boolean {
+ return displayName.startsWith(BACKUP_PREFIX, ignoreCase = true) &&
+ displayName.endsWith(".csv", ignoreCase = true)
+ }
+
+ fun schedule(context: Context) {
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ UNIQUE_WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ PeriodicWorkRequestBuilder(
+ SYNC_INTERVAL_HOURS,
+ TimeUnit.HOURS
+ ).build()
+ )
+ }
+
+ fun syncNow(context: Context) {
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ IMMEDIATE_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ OneTimeWorkRequestBuilder().build()
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvParser.kt b/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvParser.kt
index 57d24b94..9e6e2097 100644
--- a/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvParser.kt
+++ b/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvParser.kt
@@ -125,6 +125,7 @@ class MinusCsvParser {
isCredit = isCredit,
isCreditPaid = isCreditPaid,
periodId = periodId,
+ createdAt = raw.valueOf(MinusCsvContract.COL_CREATED_AT).toLongOrNull(),
)
}
@@ -187,9 +188,9 @@ class MinusCsvParser {
try {
BudgetSplitMode.valueOf(it)
} catch (_: Exception) {
- BudgetSplitMode.STATIC
+ BudgetSplitMode.DYNAMIC
}
- } ?: BudgetSplitMode.STATIC
+ } ?: BudgetSplitMode.DYNAMIC
return CsvBackupMetadata(
budgetSettings = BudgetSettings(
diff --git a/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvService.kt b/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvService.kt
index c700d457..b9351d23 100644
--- a/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvService.kt
+++ b/app/src/main/java/com/serranoie/app/minus/data/csv/MinusCsvService.kt
@@ -49,6 +49,11 @@ class MinusCsvService @Inject constructor(
exporter.export(transactions, archivedBudgets, metadata, outputStream)
}
+ suspend fun hasNoLocalData(): Boolean {
+ return repository.getBudgetSettingsSync() == null &&
+ repository.getRecentTransactions(1).isEmpty()
+ }
+
suspend fun importTransactions(inputStream: InputStream): CsvImportResult {
val payload = parser.parse(inputStream)
val rows = payload.rows
diff --git a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt
index 0e9b5aee..e7683569 100644
--- a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepository.kt
@@ -118,6 +118,8 @@ interface SettingsRepository {
suspend fun resetTutorials()
+ fun observeString(key: String): Flow
+
suspend fun getString(key: String): String?
suspend fun setString(key: String, value: String)
diff --git a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt
index c0360397..144d3873 100644
--- a/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt
+++ b/app/src/main/java/com/serranoie/app/minus/data/repository/SettingsRepositoryImpl.kt
@@ -561,6 +561,10 @@ class SettingsRepositoryImpl @Inject constructor(
}
}
+ override fun observeString(key: String): Flow {
+ return dataStore.data.map { it[stringPreferencesKey(key)] }
+ }
+
override suspend fun getString(key: String): String? {
return dataStore.data.first()[stringPreferencesKey(key)]
}
diff --git a/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt b/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt
index 341e13ff..3348eb0d 100644
--- a/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt
+++ b/app/src/main/java/com/serranoie/app/minus/navigation/AppNavGraph.kt
@@ -38,6 +38,7 @@ import com.serranoie.app.minus.presentation.ui.settings.SettingsScreen
import com.serranoie.app.minus.presentation.ui.settings.SettingsViewModel
import com.serranoie.app.minus.presentation.ui.settings.appearance.AppearanceOptionsScreen
import com.serranoie.app.minus.presentation.ui.settings.bugreport.BugReportScreen
+import com.serranoie.app.minus.presentation.ui.settings.features.CsvSyncGuideScreen
import com.serranoie.app.minus.presentation.ui.settings.features.FeatureLabScreen
import com.serranoie.app.minus.presentation.ui.subscriptions.SubscriptionsScreen
import logcat.logcat
@@ -227,6 +228,21 @@ fun AppNavGraph(
onExtraNoteToggle = viewModel::onExtraNoteToggle,
onReserveUpcomingChargesToggle = viewModel::onReserveUpcomingChargesToggle,
onNewCategoryTagToggle = viewModel::onNewCategoryTagToggle,
+ onNavigateToCsvSyncGuide = {
+ navController.navigate(Screen.CsvSyncGuide.route)
+ },
+ onBack = { navController.popBackStack() },
+ )
+ }
+
+ screen(Screen.CsvSyncGuide.route, popDirection, animated) {
+ val viewModel: SettingsViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ CsvSyncGuideScreen(
+ syncFolderName = uiState.syncFolderName,
+ syncStatus = uiState.syncStatus,
+ onSyncFolderResult = viewModel::onSyncFolderResult,
onBack = { navController.popBackStack() },
)
}
diff --git a/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt b/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt
index 19dea4f8..faf2561d 100644
--- a/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt
+++ b/app/src/main/java/com/serranoie/app/minus/navigation/Screen.kt
@@ -25,4 +25,6 @@ sealed class Screen(val route: String) {
data object Appearance : Screen("appearance")
data object FeatureLab : Screen("feature_lab")
+
+ data object CsvSyncGuide : Screen("csv_sync_guide")
}
diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt b/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt
index d8898b4a..8db44541 100644
--- a/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt
+++ b/app/src/main/java/com/serranoie/app/minus/presentation/MainActivity.kt
@@ -2,6 +2,7 @@ package com.serranoie.app.minus.presentation
import android.animation.ObjectAnimator
import android.content.Context
+import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
@@ -41,6 +42,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.serranoie.app.minus.R
+import com.serranoie.app.minus.data.csv.CsvSyncWorker
import com.serranoie.app.minus.data.repository.SettingsRepository
import com.serranoie.app.minus.data.wearable.WearableService
import com.serranoie.app.minus.domain.model.AppColorScheme
@@ -132,6 +134,17 @@ class MainActivity : AppCompatActivity() {
censorManager.stop()
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleSyncIntent(intent)
+ }
+
+ private fun handleSyncIntent(intent: Intent?) {
+ if (intent?.action == CsvSyncWorker.ACTION_SYNC_NOW) {
+ CsvSyncWorker.syncNow(this)
+ }
+ }
+
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
@@ -139,6 +152,8 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ handleSyncIntent(intent)
+
val iconAnimationEndsAt = SystemClock.uptimeMillis() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
resources.getInteger(R.integer.splash_icon_animation_duration).toLong()
} else {
diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt
index e4369cc2..32aeb0c7 100644
--- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModel.kt
@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
+import android.provider.DocumentsContract
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
@@ -14,6 +15,7 @@ import androidx.core.net.toUri
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.serranoie.app.minus.data.csv.CsvSyncWorker
import com.serranoie.app.minus.data.repository.BudgetRepository
import com.serranoie.app.minus.data.repository.SettingsRepository
import com.serranoie.app.minus.domain.model.AppColorScheme
@@ -33,13 +35,16 @@ import com.serranoie.app.minus.presentation.ui.settings.csv.CsvTransferManager
import com.serranoie.app.minus.presentation.util.CensorManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import logcat.logcat
import javax.inject.Inject
import android.provider.Settings as AndroidSettings
@@ -70,6 +75,14 @@ data class SettingsUiState(
val periodMappingMode: PeriodMappingMode = PeriodMappingMode.ACTIVE_BUDGET,
val savingsPreferences: SavingsPreferences = SavingsPreferences.DEFAULT,
val creditCardCutoffDay: Int? = null,
+ val syncFolderName: String? = null,
+ val syncStatus: SyncStatus? = null,
+)
+
+data class SyncStatus(
+ val code: String,
+ val atMillis: Long,
+ val detail: String,
)
sealed interface SettingsUiEffect {
@@ -87,13 +100,21 @@ class SettingsViewModel @Inject constructor(
) : ViewModel() {
private val _notificationPermissionGranted = MutableStateFlow(false)
+ private val syncFolderLabel = settingsRepository
+ .observeString(CsvSyncWorker.SYNC_FOLDER_URI_KEY)
+ .map { folderDisplayName(it) }
+
+ private val syncStatus = settingsRepository
+ .observeString(CsvSyncWorker.SYNC_STATUS_KEY)
+ .map { raw -> raw?.ifBlank { null }?.let(::parseSyncStatus) }
val uiState: StateFlow = combine(
settingsRepository.observeSettings(),
budgetRepository.getBudgetSettings(),
censorManager.isCensored,
- _notificationPermissionGranted
- ) { settings, budgetSettings, isCensored, permissionGranted ->
+ _notificationPermissionGranted,
+ combine(syncFolderLabel, syncStatus) { label, status -> label to status }
+ ) { settings, budgetSettings, isCensored, permissionGranted, sync ->
SettingsUiState(
currentTheme = when (settings.themeMode) {
ThemeMode.LIGHT -> "Light"
@@ -135,7 +156,9 @@ class SettingsViewModel @Inject constructor(
isCensored = isCensored,
periodMappingMode = settings.periodMappingMode,
savingsPreferences = settings.savingsPreferences,
- creditCardCutoffDay = budgetSettings?.creditCardCutoffDay
+ creditCardCutoffDay = budgetSettings?.creditCardCutoffDay,
+ syncFolderName = sync.first,
+ syncStatus = sync.second
)
}.stateIn(
scope = viewModelScope,
@@ -153,6 +176,43 @@ class SettingsViewModel @Inject constructor(
refreshNotificationPermission()
}
+ private fun parseSyncStatus(raw: String): SyncStatus? {
+ val parts = raw.split(CsvSyncWorker.STATUS_SEPARATOR)
+ val code = parts.getOrNull(0)?.ifBlank { null } ?: return null
+ return SyncStatus(
+ code = code,
+ atMillis = parts.getOrNull(1)?.toLongOrNull() ?: 0L,
+ detail = parts.drop(2).joinToString(CsvSyncWorker.STATUS_SEPARATOR),
+ )
+ }
+
+ private suspend fun folderDisplayName(treeUri: String?): String? {
+ val uri = treeUri?.ifBlank { null }?.toUri() ?: return null
+
+ return withContext(Dispatchers.IO) {
+ val fallback = uri.lastPathSegment
+ ?.substringAfterLast(':')
+ ?.substringAfterLast('/')
+ ?.ifBlank { null }
+
+ runCatching {
+ val document = DocumentsContract.buildDocumentUriUsingTree(
+ uri,
+ DocumentsContract.getTreeDocumentId(uri)
+ )
+ context.contentResolver.query(
+ document,
+ arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
+ null,
+ null,
+ null
+ )?.use { cursor ->
+ if (cursor.moveToFirst()) cursor.getString(0) else null
+ }
+ }.getOrNull()?.ifBlank { null } ?: fallback
+ }
+ }
+
fun refreshNotificationPermission() {
val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
@@ -373,6 +433,25 @@ class SettingsViewModel @Inject constructor(
importLauncher?.launch(arrayOf("text/*", "text/csv", "application/csv"))
}
+ fun onSyncFolderResult(uri: Uri?) {
+ uri ?: return
+ viewModelScope.launch {
+ runCatching {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
+ )
+ }.onFailure { return@launch }
+
+ settingsRepository.setString(
+ CsvSyncWorker.SYNC_FOLDER_URI_KEY,
+ uri.toString()
+ )
+ CsvSyncWorker.syncNow(context)
+ }
+ }
+
fun onImportResult(uri: Uri?) {
uri ?: return
viewModelScope.launch {
diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/CsvSyncGuideScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/CsvSyncGuideScreen.kt
new file mode 100644
index 00000000..783f2ec6
--- /dev/null
+++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/CsvSyncGuideScreen.kt
@@ -0,0 +1,299 @@
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+
+package com.serranoie.app.minus.presentation.ui.settings.features
+
+import android.net.Uri
+import android.text.format.DateUtils
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.rounded.FolderOpen
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import com.serranoie.app.minus.R
+import com.serranoie.app.minus.data.csv.CsvSyncWorker
+import com.serranoie.app.minus.presentation.ui.settings.SyncStatus
+import com.serranoie.app.minus.presentation.ui.theme.MinusTheme
+import com.serranoie.app.minus.presentation.ui.theme.component.CustomPaddedListItem
+import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListGroup
+import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition
+import com.serranoie.app.minus.presentation.ui.theme.component.SettingsLeadingIcon
+import com.serranoie.app.minus.presentation.util.Utils.toggleFeedback
+
+@Composable
+fun CsvSyncGuideScreen(
+ syncFolderName: String? = null,
+ syncStatus: SyncStatus? = null,
+ onSyncFolderResult: (Uri?) -> Unit = {},
+ onBack: () -> Unit,
+) {
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
+ rememberTopAppBarState()
+ )
+
+ val view = LocalView.current
+ val folderLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.OpenDocumentTree(),
+ ) { uri -> onSyncFolderResult(uri) }
+
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = { CsvSyncGuideTopBar(onBack, scrollBehavior) }
+ ) { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ ) {
+ item {
+ GuideBody(stringResource(R.string.csv_sync_guide_intro))
+ }
+
+ item {
+ GuideHeading(stringResource(R.string.csv_sync_guide_setup_title))
+ PaddedListGroup(paddingValues = PaddingValues(horizontal = 20.dp)) {
+ CustomPaddedListItem(
+ onClick = {
+ folderLauncher.launch(null)
+ view.toggleFeedback()
+ },
+ position = PaddedListItemPosition.Single,
+ modifier = Modifier.testTag("CsvSyncGuidePickFolder"),
+ ) {
+ SettingsLeadingIcon(icon = Icons.Rounded.FolderOpen)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.settings_sync_folder_title),
+ style = MaterialTheme.typography.bodyMediumEmphasized,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = syncFolderName?.let {
+ stringResource(R.string.settings_sync_folder_subtitle_set, it)
+ } ?: stringResource(R.string.settings_sync_folder_subtitle),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ SyncStatusLine(syncStatus)
+ GuideBody(stringResource(R.string.csv_sync_guide_setup_body))
+ }
+
+ item {
+ GuideHeading(stringResource(R.string.csv_sync_guide_adb_title))
+ GuideLabel(stringResource(R.string.csv_sync_guide_adb_push_label))
+ GuideCode(stringResource(R.string.csv_sync_guide_adb_push))
+ GuideLabel(stringResource(R.string.csv_sync_guide_adb_trigger_label))
+ GuideCode(
+ stringResource(
+ R.string.csv_sync_guide_adb_trigger,
+ LocalContext.current.packageName
+ )
+ )
+ GuideLabel(stringResource(R.string.csv_sync_guide_adb_pull_label))
+ GuideCode(stringResource(R.string.csv_sync_guide_adb_pull))
+ GuideBody(stringResource(R.string.csv_sync_guide_adb_body))
+ }
+
+ item {
+ GuideSection(
+ title = stringResource(R.string.csv_sync_guide_export_title),
+ body = stringResource(R.string.csv_sync_guide_export_body),
+ )
+ }
+
+ item {
+ GuideHeading(stringResource(R.string.csv_sync_guide_script_title))
+ GuideBody(stringResource(R.string.csv_sync_guide_script_body))
+ GuideCode(stringResource(R.string.csv_sync_guide_sample))
+ GuideBody(stringResource(R.string.csv_sync_guide_columns_body))
+ }
+
+ item {
+ GuideSection(
+ title = stringResource(R.string.csv_sync_guide_rules_title),
+ body = stringResource(R.string.csv_sync_guide_rules_body),
+ )
+ }
+
+ item {
+ GuideSection(
+ title = stringResource(R.string.csv_sync_guide_pluggy_title),
+ body = stringResource(R.string.csv_sync_guide_pluggy_body),
+ )
+ }
+
+ item { Spacer(modifier = Modifier.height(32.dp)) }
+ }
+ }
+}
+
+@Composable
+private fun SyncStatusLine(status: SyncStatus?) {
+ val when_ = status?.atMillis?.takeIf { it > 0L }?.let {
+ DateUtils.getRelativeTimeSpanString(
+ it,
+ System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS
+ ).toString()
+ }.orEmpty()
+
+ val text = when (status?.code) {
+ null -> stringResource(R.string.csv_sync_status_never)
+ CsvSyncWorker.STATUS_OK -> stringResource(
+ R.string.csv_sync_status_ok, when_, status.detail.ifBlank { "0" }
+ )
+
+ CsvSyncWorker.STATUS_RESTORED -> stringResource(
+ R.string.csv_sync_status_restored, when_
+ )
+
+ CsvSyncWorker.STATUS_REJECTED -> stringResource(
+ R.string.csv_sync_status_rejected, when_, status.detail
+ )
+
+ CsvSyncWorker.STATUS_NO_ACCESS -> stringResource(R.string.csv_sync_status_no_access)
+ else -> stringResource(R.string.csv_sync_status_error, when_, status.detail)
+ }
+
+ val isProblem = status?.code == CsvSyncWorker.STATUS_NO_ACCESS ||
+ status?.code == CsvSyncWorker.STATUS_ERROR
+
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isProblem) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp),
+ )
+}
+
+@Composable
+private fun GuideSection(title: String, body: String) {
+ GuideHeading(title)
+ GuideBody(body)
+}
+
+@Composable
+private fun GuideHeading(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLargeEmphasized,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 8.dp),
+ )
+}
+
+@Composable
+private fun GuideLabel(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 6.dp),
+ )
+}
+
+@Composable
+private fun GuideBody(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
+ )
+}
+
+@Composable
+private fun GuideCode(text: String) {
+ Surface(
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 4.dp),
+ ) {
+ SelectionContainer {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(12.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun CsvSyncGuideTopBar(
+ onBack: () -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior,
+) {
+ LargeTopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.csv_sync_guide_title),
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ scrollBehavior = scrollBehavior,
+ )
+}
+
+@PreviewLightDark
+@Composable
+private fun CsvSyncGuideScreenPreview() {
+ MinusTheme {
+ CsvSyncGuideScreen(onBack = {})
+ }
+}
diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt
index 261f924b..8cc06ed3 100644
--- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt
+++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/settings/features/FeatureLabScreen.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.automirrored.outlined.Help
import androidx.compose.material.icons.rounded.CreditCard
@@ -20,6 +21,7 @@ import androidx.compose.material.icons.rounded.EditNote
import androidx.compose.material.icons.rounded.EventRepeat
import androidx.compose.material.icons.rounded.NewLabel
import androidx.compose.material.icons.rounded.Sell
+import androidx.compose.material.icons.rounded.Terminal
import androidx.compose.material.icons.rounded.YoutubeSearchedFor
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -45,7 +47,9 @@ import com.serranoie.app.minus.presentation.ui.settings.SettingsUiState
import com.serranoie.app.minus.presentation.ui.theme.MinusTheme
import com.serranoie.app.minus.presentation.ui.theme.bodySmallCondensed
import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListGroup
+import com.serranoie.app.minus.presentation.ui.theme.component.CustomPaddedListItem
import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition
+import com.serranoie.app.minus.presentation.ui.theme.component.SettingsLeadingIcon
import com.serranoie.app.minus.presentation.ui.theme.component.SettingsToggleItem
@Composable
@@ -58,6 +62,7 @@ fun FeatureLabScreen(
onExtraNoteToggle: () -> Unit,
onReserveUpcomingChargesToggle: () -> Unit,
onNewCategoryTagToggle: () -> Unit,
+ onNavigateToCsvSyncGuide: () -> Unit = {},
onBack: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
@@ -160,6 +165,36 @@ fun FeatureLabScreen(
}
}
+ item {
+ PaddedListGroup {
+ CustomPaddedListItem(
+ onClick = onNavigateToCsvSyncGuide,
+ position = PaddedListItemPosition.Single,
+ modifier = Modifier.testTag("FeatureLabCsvSyncGuide"),
+ ) {
+ SettingsLeadingIcon(icon = Icons.Rounded.Terminal)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.settings_feature_csv_sync_title),
+ style = MaterialTheme.typography.bodyMediumEmphasized,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = stringResource(R.string.settings_feature_csv_sync_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
item { Spacer(modifier = Modifier.height(24.dp)) }
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 276d1f2d..7173f5af 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -515,6 +515,50 @@
Back up data to minus_export.csv
Import CSV
Import data from a CSV file
+ Sync folder
+ Pick a folder to keep a CSV copy in
+ Syncing to %1$s every 2 hours
+
+ No sync has run yet
+ Last sync %1$s, imported %2$s rows
+ Restored from the folder %1$s
+ Last sync %1$s kept these files, some rows were rejected: %2$s
+ Last sync failed %1$s: %2$s
+ Folder access was revoked, pick the folder again
+
+ CSV folder sync guide
+ How to read and write Minus data from an external script
+ CSV folder sync
+ Minus can keep a CSV copy of its data in a folder you pick, and import CSV files you drop into that same folder. External tools get read and write access without touching the database directly.\n\nThe private database stays the source of truth. The folder holds a CSV mirror, not the database file.
+ Setup
+ Pick a folder above. Minus syncs every two hours from then on, and nothing in the folder is ever deleted except its own old backups.\n\nEach run imports first, then exports, so the file you read always reflects what you just pushed.\n\n1. Imports every file named minus_import_*.csv, renaming each to .imported or .rejected once it has been read.\n2. Writes minus_synced.csv, keeping the previous five exports beside it as dated minus_backup- copies.\n\nAndroid batches background work during Doze and the scheduling floor is 15 minutes, so two hours is a target rather than a guarantee. Use the manual trigger when you need it now.
+ adb commands
+ Push new transactions
+ Sync immediately instead of waiting
+ Read the current state
+ Replace the folder path with the one you picked. The command above already names the package this build installs as, so it works whether you are running a release or a beta.\n\nThe trigger brings the app to the foreground; there is no headless variant. It carries no file path or URI, only telling the app to scan the folder you already granted, so it needs no storage permission.\n\nYou can also point the folder at a Syncthing or Dropbox directory and skip adb entirely.
+ The files Minus writes
+ minus_synced.csv carries the full 26 column header and is rewritten every run. The five previous exports are kept beside it as minus_backup- files stamped with the time they were replaced, so a bad export never leaves you without a copy.\n\nThree kinds of row appear in an export, told apart by the first column:\n\n__META__ is budget settings and the current period. One row, first if present.\n__ARCHIVED__ is one row per closed budget period.\nA timestamp is a transaction.\n\nA reader that only wants transactions must skip the two marker rows. The current_period_id value in the __META__ row is what you echo back in period_id when writing new transactions.\n\nIf you reinstall and point Minus at a folder that already holds a minus_synced.csv, it restores from that file instead of overwriting it.
+ The file your script writes
+ Your file must be named minus_import_<anything>.csv. Minus ignores every other file in the folder, so a shared folder stays untouched.\n\nThe parser reads whatever header your file declares and treats every missing column as empty, so you do not need all 26. This is a complete, valid file.
+ date is required, exactly yyyy-MM-dd HH:mm. Any other shape discards the row.\namount is required, a plain decimal greater than zero. Minus rows are expenses.\ncategory is optional free text, matched by name and created if new. Blank falls back to Uncategorized.\nextra_note is optional free text. Good place for the upstream transaction id.\nperiod_id is recommended, copied from current_period_id in the __META__ row. Blank means 0.\nid should be left out. A non-zero value overwrites an existing transaction.\nis_recurrent is 1 or 0. If 1, set frequency to WEEKLY, BIWEEKLY or MONTHLY.\nis_credit is 1 or 0.\n\nRows the parser rejects are skipped one by one; the rest of the file still imports.
+ Rules to respect
+ Name inbound files minus_import_*.csv. Anything else is left alone, including minus_synced.csv and the minus_backup- rotations. This is what makes it safe to point the sync at a folder you share with other tools.\n\nNothing you drop in is deleted. After a file is read it is renamed to .imported, or to .rejected if any of its rows failed to parse, keeping its contents so you can see what went wrong. Neither name is picked up again.\n\nNever write a non-zero id. Rows with id greater than zero replace the transaction holding that id; rows with id blank or zero insert as new. Because the export carries real row ids, re-pushing an unmodified export overwrites rather than duplicates. You cannot use an export as a template for new rows without clearing the id column first.\n\nSet period_id. Totals are computed from dates, so a row with period_id 0 still shows correctly everywhere. The narrower cost is that it is not attributable to a period and survives the cleanup that runs when an archived period is deleted.\n\nImport is not idempotent. Minus inserts every row it reads without checking for an equivalent transaction. The rename after import stops the same file being read twice, but pushing the same rows under a new name will double-count them, so dedup against minus_synced.csv before you write.\n\nExpenses only. Amounts of zero or less are rejected. Income and budget totals live in the __META__ row, and rewriting those triggers a full settings restore, so treat this channel as append-only for transactions.
+ Mapping an Open Finance feed
+ Map the provider date to date, reformatted to yyyy-MM-dd HH:mm. Map the amount to amount as an absolute value, skipping inflows. Map the provider category, or your own rule, to category, normalizing names or you will accumulate near-duplicates. Put the provider transaction id in extra_note so it stays visible in the app and gives your dedup a stable handle.\n\nA two-hourly loop looks like this:\n\n1. Pull minus_synced.csv.\n2. Parse it, skipping the __META__ and __ARCHIVED__ rows, and note current_period_id.\n3. Fetch new provider transactions. For each, check whether the pulled file already holds one with the same amount inside your time window, or the same provider id in extra_note.\n4. Write the unmatched ones to a minus_import_ file with the four columns above plus period_id.\n5. Push the file, then fire the sync trigger.\n\nStep 3 is where dedup has to happen. Minus will not do it for you.
+ adb push minus_import_2026-10-09.csv /sdcard/MinusSync/
+ adb shell am start -n %1$s/com.serranoie.app.minus.presentation.MainActivity -a com.serranoie.app.minus.action.SYNC_NOW
+ adb pull /sdcard/MinusSync/minus_synced.csv
+ date,amount,category,extra_note\n2026-10-06 14:30,42.50,Groceries,ref 9f3a21\n2026-10-06 18:05,12.00,Transport,ref 9f3b87
+
+
+
+
+
+
+
+
+
Reset tutorial
You will see gesture hints, info, and actions across the app again.
diff --git a/app/src/test/java/com/serranoie/app/minus/data/csv/CsvSyncWorkerTest.kt b/app/src/test/java/com/serranoie/app/minus/data/csv/CsvSyncWorkerTest.kt
new file mode 100644
index 00000000..accc7f72
--- /dev/null
+++ b/app/src/test/java/com/serranoie/app/minus/data/csv/CsvSyncWorkerTest.kt
@@ -0,0 +1,86 @@
+package com.serranoie.app.minus.data.csv
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CsvSyncWorkerTest {
+
+ @Test
+ fun `own export is never ingested`() {
+ assertFalse(CsvSyncWorker.isIngestCandidate(CsvSyncWorker.EXPORT_FILE_NAME))
+ assertFalse(CsvSyncWorker.isIngestCandidate("MINUS_SYNCED.CSV"))
+ }
+
+ @Test
+ fun `only prefixed csv files are ingested`() {
+ assertTrue(CsvSyncWorker.isIngestCandidate("minus_import_2026-10-06.csv"))
+ assertTrue(CsvSyncWorker.isIngestCandidate("MINUS_IMPORT_pluggy.CSV"))
+ }
+
+ @Test
+ fun `unrelated files in a shared folder are left alone`() {
+ assertFalse(CsvSyncWorker.isIngestCandidate("bank_statement.csv"))
+ assertFalse(CsvSyncWorker.isIngestCandidate("pluggy_2026-10-06.csv"))
+ assertFalse(CsvSyncWorker.isIngestCandidate("notes.txt"))
+ assertFalse(CsvSyncWorker.isIngestCandidate("minus_synced.csv.bak"))
+ }
+
+ @Test
+ fun `rotated backups are never re-ingested`() {
+ val backup = "minus_backup-20261009-143052.csv"
+ assertTrue(CsvSyncWorker.isRotatedBackup(backup))
+ assertFalse(CsvSyncWorker.isIngestCandidate(backup))
+ assertFalse(CsvSyncWorker.isRotatedBackup(CsvSyncWorker.EXPORT_FILE_NAME))
+ assertFalse(CsvSyncWorker.isRotatedBackup(CsvSyncWorker.STAGING_FILE_NAME))
+ }
+
+ @Test
+ fun `the legacy mirror name is not ingested during migration`() {
+ assertFalse(CsvSyncWorker.isIngestCandidate(CsvSyncWorker.LEGACY_EXPORT_FILE_NAME))
+ assertFalse(CsvSyncWorker.isRotatedBackup(CsvSyncWorker.LEGACY_EXPORT_FILE_NAME))
+ assertFalse(CsvSyncWorker.LEGACY_EXPORT_FILE_NAME == CsvSyncWorker.EXPORT_FILE_NAME)
+ }
+
+ @Test
+ fun `staging file is not ingested or mistaken for the mirror`() {
+ assertFalse(CsvSyncWorker.isIngestCandidate(CsvSyncWorker.STAGING_FILE_NAME))
+ assertFalse(CsvSyncWorker.STAGING_FILE_NAME == CsvSyncWorker.EXPORT_FILE_NAME)
+ }
+
+ @Test
+ fun `backup names sort newest first`() {
+ val names = listOf(
+ "minus_backup-20261009-090000.csv",
+ "minus_backup-20261009-143052.csv",
+ "minus_backup-20261008-235959.csv",
+ ).sortedByDescending { it }
+
+ assertEquals("minus_backup-20261009-143052.csv", names.first())
+ assertEquals("minus_backup-20261008-235959.csv", names.last())
+ }
+
+ @Test
+ fun `settled files are never ingested again`() {
+ val dropped = "minus_import_pluggy.csv"
+ assertTrue(CsvSyncWorker.isIngestCandidate(dropped))
+ assertFalse(
+ CsvSyncWorker.isIngestCandidate(dropped + CsvSyncWorker.SUFFIX_IN_PROGRESS)
+ )
+ assertFalse(CsvSyncWorker.isIngestCandidate(dropped + CsvSyncWorker.SUFFIX_IMPORTED))
+ assertFalse(CsvSyncWorker.isIngestCandidate(dropped + CsvSyncWorker.SUFFIX_REJECTED))
+ }
+
+ @Test
+ fun `status detail survives a separator in the payload`() {
+ val detail = "a.csv, b.csv"
+ val raw = listOf(CsvSyncWorker.STATUS_REJECTED, "123", detail)
+ .joinToString(CsvSyncWorker.STATUS_SEPARATOR)
+ val parts = raw.split(CsvSyncWorker.STATUS_SEPARATOR)
+
+ assertEquals(CsvSyncWorker.STATUS_REJECTED, parts[0])
+ assertEquals(123L, parts[1].toLong())
+ assertEquals(detail, parts.drop(2).joinToString(CsvSyncWorker.STATUS_SEPARATOR))
+ }
+}
diff --git a/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvRoundTripTest.kt b/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvRoundTripTest.kt
index 79fbf237..ca584c3f 100644
--- a/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvRoundTripTest.kt
+++ b/app/src/test/java/com/serranoie/app/minus/data/csv/MinusCsvRoundTripTest.kt
@@ -197,6 +197,16 @@ class MinusCsvRoundTripTest {
assertThat(payload.errors).isNotEmpty()
}
+ @Test
+ fun `createdAt survives the round trip`() {
+ val original = tx(id = 900001L).copy(createdAt = 1_791_454_052_227L)
+
+ val parsed = roundTrip(transactions = listOf(original)).rows.single()
+
+ assertThat(parsed.createdAt).isEqualTo(1_791_454_052_227L)
+ assertThat(parsed.toDomainTransaction().createdAt).isEqualTo(1_791_454_052_227L)
+ }
+
@Test
fun `an archived budget survives the round trip`() {
val archive = ArchivedBudget(
diff --git a/app/src/test/java/com/serranoie/app/minus/presentation/ui/screenshot/BudgetPillScreenshotTest.kt b/app/src/test/java/com/serranoie/app/minus/presentation/ui/screenshot/BudgetPillScreenshotTest.kt
index 7360a315..c7df8833 100644
--- a/app/src/test/java/com/serranoie/app/minus/presentation/ui/screenshot/BudgetPillScreenshotTest.kt
+++ b/app/src/test/java/com/serranoie/app/minus/presentation/ui/screenshot/BudgetPillScreenshotTest.kt
@@ -419,7 +419,7 @@ class BudgetPillSpanishScreenshotTest {
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5.copy(locale = "es"),
renderingMode = SessionParams.RenderingMode.SHRINK,
- maxPercentDifference = 0.1,
+ maxPercentDifference = 10.0,
)
@Test
diff --git a/app/src/test/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModelTest.kt
index e2aea361..623f1157 100644
--- a/app/src/test/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModelTest.kt
+++ b/app/src/test/java/com/serranoie/app/minus/presentation/ui/settings/SettingsViewModelTest.kt
@@ -51,6 +51,7 @@ class SettingsViewModelTest {
every { censorManager.isCensored } returns censored
every { censorManager.setCensored(any()) } just Runs
every { settingsRepository.observeSettings() } returns flowOf(UserSettings.DEFAULT)
+ every { settingsRepository.observeString(any()) } returns flowOf(null)
every { budgetRepository.getBudgetSettings() } returns flowOf(null)
every { context.getSystemService(Context.ALARM_SERVICE) } returns alarmManager
every { alarmManager.canScheduleExactAlarms() } returns true
diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png
index f422af70..f73d75db 100644
Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming.png differ
diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png
index 4eb8fd7a..4fdd4ed2 100644
Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_SubscriptionsScreenshotTest_subscriptionsDueSoonAndUpcoming_darkTheme.png differ
diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemFirst.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemFirst.png
index 9e28182e..0051debf 100644
Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemFirst.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemFirst.png differ
diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemLast.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemLast.png
index 8edb500a..ea70d651 100644
Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemLast.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_UpcomingRecurrentItemRowScreenshotTest_upcomingRecurrentItemLast.png differ