From 34ed687234368c040ae71f2c9466b2f680573074 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sun, 6 Sep 2026 02:57:01 +0200 Subject: [PATCH] Bill Sync --- .../android/bill_edit/EditBillActivity.kt | 2 +- .../android/main/BillsListViewActivity.kt | 105 ++++++++++----- .../android/settings/SettingsScreen.kt | 76 +++++++++++ .../persistence/CowspentSQLiteOpenHelper.kt | 124 +++++++++--------- .../persistence/CowspentServerSyncHelper.kt | 57 +++++++- .../net/helcel/cowspent/util/SyncSettings.kt | 33 +++++ .../util/VersatileProjectSyncClient.kt | 3 + app/src/main/res/values/strings.xml | 8 ++ 8 files changed, 309 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/net/helcel/cowspent/util/SyncSettings.kt diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt index cb9a8f6..2f2356d 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt @@ -331,7 +331,7 @@ class EditBillActivity : AppCompatActivity() { bill.amount == viewModel.getFinalAmount() && bill.payerId == viewModel.payerId && bill.comment == viewModel.getFinalComment() && - bill.repeat == viewModel.repeat && + (bill.repeat ?: DBBill.NON_REPEATED) == viewModel.repeat && bill.categoryId == viewModel.categoryId && bill.paymentModeId == viewModel.paymentModeId && !owersChanged) diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt index c9b4f97..a1cb185 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt @@ -18,6 +18,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.compose.material.icons.Icons @@ -53,7 +54,9 @@ import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.ExportUtil import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.IRefreshBillsListCallback +import net.helcel.cowspent.util.SyncSettings import net.helcel.cowspent.util.SupportUtil +import net.helcel.cowspent.util.VersatileProjectSyncClient import java.io.IOException import java.text.SimpleDateFormat import java.util.Calendar @@ -70,6 +73,9 @@ class BillsListViewActivity : private val TAG = BillsListViewActivity::class.java.simpleName + // Opening a project only re-syncs it if it has not synced within this window. + private const val SELECTED_PROJECT_SYNC_INTERVAL_MS = 60 * 1000L + private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection" private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen" @@ -276,7 +282,7 @@ class BillsListViewActivity : labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId)) } }, - onRefresh = { synchronize(true) } + onRefresh = { synchronize(SyncTrigger.MANUAL) } ) } } @@ -330,10 +336,7 @@ class BillsListViewActivity : } viewModel.isRefreshing = false - if (db.cowspentServerSyncHelper.isSyncPossible) { - db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) - synchronize() - } + synchronize(SyncTrigger.APP_OPEN) registerBroadcastReceiver() updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) @@ -347,6 +350,9 @@ class BillsListViewActivity : } catch (_: RuntimeException) { if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...") } + // The helper outlives the activity, and it only drains its pull callbacks when a task + // starts; drop ours so a paused activity is not retained by it. + db.cowspentServerSyncHelper.removeCallbackPull(syncCallBack) isActivityVisible = false } @@ -373,7 +379,7 @@ class BillsListViewActivity : navigationSelection = Category(null, null) refreshLists(true) - synchronize() + synchronize(SyncTrigger.PROJECT_OPEN) } fun onManageProjectClick(projectId: Long) { @@ -406,7 +412,7 @@ class BillsListViewActivity : } setupDrawerProjects() refreshLists() - synchronize() + synchronize(SyncTrigger.PROJECT_OPEN) val projectNameString = proj.name.ifEmpty { proj.remoteId } showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString)) } @@ -421,20 +427,25 @@ class BillsListViewActivity : lifecycleScope.launch { val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch val isArchiving = !proj.isArchived - - val newArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L - + + val localArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L + val remoteArchivedTs = if (isArchiving) { + localArchivedTs + } else { + VersatileProjectSyncClient.REMOTE_ARCHIVED_TS_UNSET + } + withContext(Dispatchers.IO) { db.updateProject( projId = projectId, - newArchivedTs = newArchivedTs + newArchivedTs = localArchivedTs ) } if (!proj.isLocal) { db.cowspentServerSyncHelper.editRemoteProject( projId = projectId, - newArchivedTs = newArchivedTs, + newArchivedTs = remoteArchivedTs, callback = object : ICallback { override fun onFinish() {} override fun onFinish(result: String, message: String) { @@ -746,28 +757,60 @@ class BillsListViewActivity : } } - private fun synchronize(manual: Boolean = false) { + /** + * What prompted a sync. Only [APP_OPEN] may refresh the account and every project, and only + * then when the SyncOnOpen interval has elapsed; the other triggers touch the selected + * project alone. + */ + @VisibleForTesting + internal enum class SyncTrigger { APP_OPEN, PROJECT_OPEN, MANUAL } + + @VisibleForTesting + internal fun synchronize(trigger: SyncTrigger) { val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) - val offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false) - if (offlineMode && !manual) { - return - } + // isSyncPossible is networkConnected && !offlineMode, so offline mode is already covered + // here - including for a manual refresh, which cannot currently override it. + if (!db.cowspentServerSyncHelper.isSyncPossible) return + + val selectedProjectId = preferences.getLong("selected_project", 0) + val now = System.currentTimeMillis() + + // The account and all-projects refresh belongs to opening the app, throttled by the + // SyncOnOpen interval so that resuming within the interval does not repeat it. + val intervalMinutes = SyncSettings.intervalMinutes(applicationContext) + val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L) + val accountSyncDue = trigger == SyncTrigger.APP_OPEN && + now - lastAccountSync > intervalMinutes * 60 * 1000L + + lifecycleScope.launch { + val remoteProjects = withContext(Dispatchers.IO) { db.projects } + .filter { !it.isLocal && !it.isArchived } - if (db.cowspentServerSyncHelper.isSyncPossible) { viewModel.isRefreshing = true - val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) - if (selectedProjectId != 0L) { - lifecycleScope.launch { - val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) } - if (proj != null && !proj.isLocal) { - db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) - db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId, manual) - } else viewModel.isRefreshing = false + db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) + + val started = if (accountSyncDue) { + if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { + db.cowspentServerSyncHelper.runAccountProjectsSync() } - } else viewModel.isRefreshing = false - if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { - db.cowspentServerSyncHelper.runAccountProjectsSync() + remoteProjects.count { + db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null + } + } else { + val selectedProj = remoteProjects.find { it.id == selectedProjectId } + val lastSync = (selectedProj?.lastSyncedTimestamp ?: 0L) * 1000L + val due = trigger == SyncTrigger.MANUAL || + now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS + if (selectedProj != null && due && + db.cowspentServerSyncHelper.scheduleSync( + false, selectedProj, trigger == SyncTrigger.MANUAL + ) != null + ) 1 else 0 } + + // Only a task that actually started reports back through syncCallBack, so clear + // the indicator here when none did - nothing else would. + if (started == 0) viewModel.isRefreshing = false } } @@ -810,7 +853,7 @@ class BillsListViewActivity : refreshLists() } MainConstants.BROADCAST_SYNC_PROJECT -> { - synchronize() + synchronize(SyncTrigger.PROJECT_OPEN) } MainConstants.BROADCAST_NETWORK_AVAILABLE -> { } @@ -838,7 +881,7 @@ class BillsListViewActivity : refreshLists() if (db.cowspentServerSyncHelper.isSyncPossible) { db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) - synchronize() + synchronize(SyncTrigger.PROJECT_OPEN) } } } diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt index df21f9b..9263187 100644 --- a/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.RadioButton import androidx.compose.material.Scaffold +import androidx.compose.material.Slider import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text @@ -49,6 +50,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt import androidx.core.content.edit import androidx.preference.PreferenceManager import net.helcel.cowspent.R @@ -57,6 +59,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.util.ColorUtils import net.helcel.cowspent.util.Cowspent +import net.helcel.cowspent.util.SyncSettings @Composable fun SettingsScreen( @@ -81,7 +84,9 @@ fun SettingsScreen( val keyOfflineMode = stringResource(R.string.pref_key_offline_mode) val keyShowArchived = stringResource(R.string.pref_key_show_archived) val keyBetaFeatures = stringResource(R.string.pref_key_beta_features) + val keyAutoSyncOnOpen = stringResource(R.string.pref_key_auto_sync_on_open) val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last) + val keyLastAccountSync = stringResource(R.string.pref_key_last_account_sync_timestamp) val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context) @@ -127,6 +132,18 @@ fun SettingsScreen( var fillNewBillFromLast by remember(keyFillNewBillFromLast) { mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false)) } + + val syncIntervals = SyncSettings.INTERVAL_CHOICES_MINUTES + val syncIntervalLabels = listOf( + stringResource(R.string.pref_value_sync_1m), + stringResource(R.string.pref_value_sync_10m), + stringResource(R.string.pref_value_sync_1h), + stringResource(R.string.pref_value_sync_1d) + ) + + var syncInterval by remember(keyAutoSyncOnOpen) { + mutableIntStateOf(SyncSettings.intervalMinutes(context)) + } Scaffold( topBar = { @@ -286,6 +303,24 @@ fun SettingsScreen( } } ) + + SettingsSliderPreference( + title = stringResource(R.string.settings_auto_sync_on_open), + summary = stringResource(R.string.settings_auto_sync_on_open_summary), + icon = Icons.Default.Sync, + value = syncInterval, + values = syncIntervals, + labels = syncIntervalLabels, + onValueChange = { newInterval -> + // Slider reports every drag delta, not just the snapped steps. + if (newInterval != syncInterval) { + syncInterval = newInterval + sharedPreferences.edit { + putInt(keyAutoSyncOnOpen, newInterval) + } + } + } + ) } SettingsPreference( @@ -464,6 +499,47 @@ fun SettingsColorPreference( } } +@Composable +fun SettingsSliderPreference( + title: String, + summary: String? = null, + icon: Any? = null, + value: Int, + values: List, + labels: List, + onValueChange: (Int) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIcon(icon) + Spacer(modifier = Modifier.width(32.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.subtitle1) + if (summary != null) { + Text(text = summary, style = MaterialTheme.typography.caption) + } + } + } + + val currentIndex = values.indexOf(value).coerceAtLeast(0) + val steps = (values.size - 2).coerceAtLeast(0) + + Column(modifier = Modifier.padding(start = 56.dp, top = 8.dp)) { + Slider( + value = currentIndex.toFloat(), + onValueChange = { onValueChange(values[it.roundToInt()]) }, + valueRange = 0f..(values.size - 1).toFloat(), + steps = steps + ) + Text(text = labels[currentIndex], style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary) + } + } +} + @Composable fun SettingsIcon(icon: Any?) { Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt index d83689c..0d1f635 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -7,6 +7,7 @@ import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.text.TextUtils +import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.preference.PreferenceManager import net.helcel.cowspent.R @@ -390,8 +391,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : fun getActivatedMembersOfProject(projId: Long): List { return getMembersCustom( - "$key_projectid = ? AND $key_activated = 1", - arrayOf(projId.toString()), + "$key_projectid = ? AND $key_activated = 1 AND $key_state != ?", + arrayOf(projId.toString(), DBBill.STATE_DELETED.toString()), "$key_name ASC" ) } @@ -570,70 +571,54 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : fun searchBills(query: CharSequence?, projectId: Long): List { val andWhere: MutableList = ArrayList() val args: MutableList = ArrayList() - andWhere.add("($key_projectid = $projectId)") + andWhere.add("($key_projectid = ?)") + args.add(projectId.toString()) andWhere.add("($key_state != ${DBBill.STATE_DELETED})") - if (query != null) { - args.add("%$query%") - var whereStr = "($key_what LIKE ?" - if (SupportUtil.isDouble(query.toString())) { - whereStr += " OR ($key_amount <= (? + 10) AND $key_amount >= (? - 10))" - args.add(query.toString()) - args.add(query.toString()) + + val terms = query?.toString()?.split("\\s+".toRegex())?.filter { it.isNotEmpty() }.orEmpty() + if (terms.isNotEmpty()) { + val memberIdsByName = getMembersOfProject(projectId, null) + .associateBy({ it.name.lowercase(Locale.ROOT) }, { it.id }) + // Every clause appends its arguments as it is built, so args stay in the same order + // as the placeholders they bind to. + for (term in terms) { + andWhere.add(memberClause(term, memberIdsByName, args) ?: textClause(term, args)) } - val members = getMembersOfProject(projectId, null) - val memberNames: MutableList = ArrayList() - val memberIds: MutableList = ArrayList() - for (m in members) { - memberNames.add(m.name.lowercase(Locale.ROOT)) - memberIds.add(m.id) - } - val queryStr = query.toString() - val words = queryStr.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - var nameSql = "" - for (word in words) { - if (word.startsWith("+")) { - val nameQuery = word.replace("^\\+".toRegex(), "") - val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT)) - if (memberIndex != -1) { - val searchMemberId = memberIds[memberIndex] - nameSql += "($key_payer_id=?) AND " - args.add(searchMemberId.toString()) - } - } - if (word.startsWith("-")) { - val nameQuery = word.replace("^-".toRegex(), "") - val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT)) - if (memberIndex != -1) { - val searchMemberId = memberIds[memberIndex] - val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " + - "where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId" - nameSql += "($key_id IN ($joinOwer)) AND " - args.add(searchMemberId.toString()) - } - } - if (word.startsWith("@")) { - val nameQuery = word.replace("^@".toRegex(), "") - val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT)) - if (memberIndex != -1) { - val searchMemberId = memberIds[memberIndex] - nameSql += "( ($key_payer_id=?) OR " - args.add(searchMemberId.toString()) - val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " + - "where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId" - nameSql += "($key_id IN ($joinOwer)) ) AND " - args.add(searchMemberId.toString()) - } - } - } - if (nameSql != "") { - nameSql = nameSql.replace(" AND $".toRegex(), "") - whereStr += " OR ($nameSql)" - } - whereStr += ")" - andWhere.add(whereStr) } - val order = "$key_timestamp DESC" - return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), order) + return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), "$key_timestamp DESC") + } + + /** Clause for a `+name`/`-name`/`@name` term, or null when the term names no member. */ + private fun memberClause(term: String, memberIdsByName: Map, args: MutableList): String? { + val prefix = term.first() + if (prefix != '+' && prefix != '-' && prefix != '@') return null + val memberId = memberIdsByName[term.substring(1).lowercase(Locale.ROOT)] ?: return null + val owedByMember = "SELECT $table_bills.$key_id FROM $table_bills INNER JOIN $table_billowers " + + "WHERE $key_member_id = ? AND $table_bills.$key_id = $table_billowers.$key_billId" + args.add(memberId.toString()) + return when (prefix) { + '+' -> "($key_payer_id = ?)" + '-' -> "($key_id IN ($owedByMember))" + else -> { + args.add(memberId.toString()) + "(($key_payer_id = ?) OR ($key_id IN ($owedByMember)))" + } + } + } + + /** Clause matching a free text term against the description, the comment and the amount. */ + private fun textClause(term: String, args: MutableList): String { + val needle = "%" + term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + args.add(needle) + args.add(needle) + var clause = "(($key_what LIKE ? ESCAPE '\\') OR ($key_comment LIKE ? ESCAPE '\\')" + if (SupportUtil.isDouble(term)) { + clause += " OR ($key_amount <= (? + $AMOUNT_SEARCH_TOLERANCE) AND " + + "$key_amount >= (? - $AMOUNT_SEARCH_TOLERANCE))" + args.add(term) + args.add(term) + } + return "$clause)" } @WorkerThread @@ -1240,6 +1225,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : ) private const val default_order = "$key_id DESC" + /** Half width of the window a numeric search term matches, in project currency. */ + private const val AMOUNT_SEARCH_TOLERANCE = 10 + @Volatile private var instance: CowspentSQLiteOpenHelper? = null @@ -1249,5 +1237,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it } } } + + @VisibleForTesting + fun setInstance(helper: CowspentSQLiteOpenHelper?) { + instance = helper + } + + @VisibleForTesting + fun resetInstance() { + instance = null + } } } diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt index 94f3563..3ed7ca9 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -8,6 +8,7 @@ import android.content.SharedPreferences import android.net.ConnectivityManager import android.os.IBinder import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.core.content.edit import androidx.core.graphics.toColorInt import androidx.preference.PreferenceManager @@ -90,14 +91,38 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen } fun addCallbackPull(callback: ICallback) { - callbacksPull.add(callback) + // Callers register on every resume but the list is only drained when a task actually + // starts, so refuse duplicates rather than letting the same callback pile up. + if (!callbacksPull.contains(callback)) { + callbacksPull.add(callback) + } } - fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? { + fun removeCallbackPull(callback: ICallback) { + callbacksPull.remove(callback) + } + + fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? = + scheduleSync(onlyLocalChanges, projId, forceFullSync) { dbHelper.getProject(projId) } + + /** + * Overload for callers that already hold the project. Resolving one by id costs a query plus + * a blocking DataStore read and an AEAD decrypt in getProjectFromCursor, which adds up when + * scheduling a sync for every project at app open. + */ + fun scheduleSync(onlyLocalChanges: Boolean, project: DBProject, forceFullSync: Boolean = false): SyncTask? = + scheduleSync(onlyLocalChanges, project.id, forceFullSync) { project } + + private fun scheduleSync( + onlyLocalChanges: Boolean, + projId: Long, + forceFullSync: Boolean, + resolveProject: () -> DBProject? + ): SyncTask? { Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...") updateNetworkStatus() if (isSyncPossible && (!syncActive || onlyLocalChanges)) { - val project = dbHelper.getProject(projId) + val project = resolveProject() if (project != null) { Log.d(TAG, "... starting now") val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync) @@ -1027,8 +1052,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen } var status = LoginStatus.OK try { + // Pass current project values if the new ones are null to ensure a complete project object is sent to the server + val currentProj = project!! + val finalName = (newName ?: currentProj.name).let { if (it.isBlank() || it == "null") currentProj.remoteId else it } + // Stay null when the project has no main currency, so the PUT omits currencyName + // instead of silently setting the server-side currency. + val finalCurrency = (newMainCurrencyName ?: currentProj.currencyName) + ?.takeUnless { it.isBlank() || it == "null" } + val response = client!!.editRemoteProject( - project!!, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs + currentProj, + finalName, + newEmail ?: currentProj.email, + newPassword, + finalCurrency, + newArchivedTs ) if (BillsListViewActivity.DEBUG) { Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}") @@ -1418,6 +1456,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen } private fun onPostExecute(status: LoginStatus) { + if (status == LoginStatus.OK) { + preferences.edit { + putLong(appContext.getString(R.string.pref_key_last_account_sync_timestamp), System.currentTimeMillis()) + } + } if (status != LoginStatus.OK) { var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n" for (errorMessage in errorMessages) { @@ -1673,6 +1716,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen return instance!! } + @VisibleForTesting + fun resetInstance() { + instance = null + projectIdsToSync.clear() + } + fun isNextcloudAccountConfigured(context: Context): Boolean { val preferences = PreferenceManager.getDefaultSharedPreferences(context) return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() || diff --git a/app/src/main/java/net/helcel/cowspent/util/SyncSettings.kt b/app/src/main/java/net/helcel/cowspent/util/SyncSettings.kt new file mode 100644 index 0000000..25192c5 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/SyncSettings.kt @@ -0,0 +1,33 @@ +package net.helcel.cowspent.util + +import android.content.Context +import androidx.preference.PreferenceManager +import net.helcel.cowspent.R + +/** + * The SyncOnOpen preference: how often opening the app refreshes the account and every project. + * + * The choices and the default live here rather than in the settings screen so that the screen and + * the sync trigger cannot disagree about what is in effect. + */ +object SyncSettings { + + /** Steps offered by the slider, in minutes. */ + val INTERVAL_CHOICES_MINUTES = listOf(1, 10, 60, 1440) + + const val DEFAULT_INTERVAL_MINUTES = 10 + + /** + * The configured interval in minutes. A stored value that is not one of the offered steps — + * from a restored backup, or a build that changed the steps — falls back to the default + * rather than being displayed as the first step while a different value drives the sync. + */ + fun intervalMinutes(context: Context): Int { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val stored = prefs.getInt( + context.getString(R.string.pref_key_auto_sync_on_open), + DEFAULT_INTERVAL_MINUTES + ) + return if (stored in INTERVAL_CHOICES_MINUTES) stored else DEFAULT_INTERVAL_MINUTES + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt index 20004df..78b15e6 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -1528,5 +1528,8 @@ class VersatileProjectSyncClient( const val METHOD_POST = "POST" const val METHOD_PUT = "PUT" const val METHOD_DELETE = "DELETE" + + const val REMOTE_ARCHIVED_TS_NOW = 0L + const val REMOTE_ARCHIVED_TS_UNSET = -1L } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8019552..873b4fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,12 @@ Enable experimental features. Use at your own risk. Auto-fill from last bill Pre-fill payer, category, mode and owers from the last bill created in the project. + Sync interval + How often to refresh the account and all projects when opening the app. + 1 minute + 10 minutes + 1 hour + 1 day WARNING: "http" is unsafe. Use "https". Choose Color @@ -163,6 +169,8 @@ offlineMode showArchived betaFeatures + autoSyncOnOpen + lastAccountSyncTimestamp fillNewBillFromLast 1 2