PartialSync avoids Members and Project sync

This commit is contained in:
2026-09-09 08:56:05 +02:00
parent 2ec5c00df1
commit de5f9b09a1
2 changed files with 131 additions and 49 deletions
@@ -144,7 +144,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
* walk it is empty, and nothing may be deleted locally on the strength of it. * walk it is empty, and nothing may be deleted locally on the strength of it.
*/ */
val allIds: List<Long>, val allIds: List<Long>,
val syncTimestamp: Long? val syncTimestamp: Long?,
/**
* A bill referenced a member, category or payment mode this device does not know. A
* partial sync skips re-reading the project, so this is what tells it to go back for it.
*/
val hasUnresolvedReferences: Boolean = false
) )
inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject, private val forceFullSync: Boolean = false) { inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject, private val forceFullSync: Boolean = false) {
@@ -188,12 +193,19 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (project.type == ProjectType.COSPEND) { if (project.type == ProjectType.COSPEND) {
nextcloudClient = createNextcloudClient() nextcloudClient = createNextcloudClient()
if (nextcloudClient != null) { if (nextcloudClient != null) {
// The server's version only moves when the server itself is upgraded, and
// asking costs a whole round trip before any project data moves. Remember it
// per server, and re-read it whenever a full sync is asked for.
version = if (forceFullSync) null else cachedCospendVersion()
if (version == null) {
try { try {
val response = nextcloudClient!!.getCapabilities(project) val response = nextcloudClient!!.getCapabilities(project)
version = response.cospendVersion version = response.cospendVersion
rememberCospendVersion(version)
} catch (e: Exception) { } catch (e: Exception) {
Log.i(TAG, "Failed to get cospend version when syncing: $e") Log.i(TAG, "Failed to get cospend version when syncing: $e")
} }
}
} else if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { } else if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
return LoginStatus.SSO_TOKEN_MISMATCH return LoginStatus.SSO_TOKEN_MISMATCH
} }
@@ -218,15 +230,32 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
deferred?.await() ?: LoginStatus.CONNECTION_FAILED deferred?.await() ?: LoginStatus.CONNECTION_FAILED
} }
/** Keyed by server rather than by project, since several projects can share one. */
private fun versionKey() = "cospend_version_" + (project.serverUrl ?: "")
private fun cachedCospendVersion(): String? =
preferences.getString(versionKey(), null)
private fun rememberCospendVersion(version: String?) {
// A failed lookup is not an answer, and caching it would pin the wrong API dialect
// until the next forced sync.
if (version.isNullOrEmpty()) return
preferences.edit { putString(versionKey(), version) }
}
private fun pushLocalChanges(): LoginStatus { private fun pushLocalChanges(): LoginStatus {
Log.d(TAG, "PUSH LOCAL CHANGES") Log.d(TAG, "PUSH LOCAL CHANGES")
return try { return try {
// The remote member list is only needed to match members waiting to be created,
// and pullRemoteChanges fetches them again as part of the project anyway, so it
// is not worth a round trip of its own when there is nothing to add.
val membersToAdd = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_ADDED)
if (membersToAdd.isNotEmpty()) {
val membersResponse = client!!.getMembers(project) val membersResponse = client!!.getMembers(project)
val remoteMembers = membersResponse.getMembers(project.id) val remoteMembers = membersResponse.getMembers(project.id)
val remoteMembersNames = remoteMembers.map { it.name } val remoteMembersNames = remoteMembers.map { it.name }
val membersToAdd = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (mToAdd in membersToAdd) { for (mToAdd in membersToAdd) {
val searchIndex = remoteMembersNames.indexOf(mToAdd.name) val searchIndex = remoteMembersNames.indexOf(mToAdd.name)
if (searchIndex != -1) { if (searchIndex != -1) {
@@ -250,6 +279,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} }
} }
}
val membersToEdit = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_EDITED) val membersToEdit = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (mToEdit in membersToEdit) { for (mToEdit in membersToEdit) {
@@ -540,32 +570,53 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
/** /**
* Brings the local copy of the project in line with the server: its own row, then each of * Reads the project's own row and each of its collections. One request, and everything it
* its collections, then its bills. Each step is applied as it goes, so a failure part way * carries is applied as it goes, so a failure part way through leaves the earlier steps
* through leaves the earlier steps written - the next sync picks up where this one stopped. * written. Returns the members it saw, keyed by remote id.
*/ */
private fun pullRemoteChanges(): LoginStatus { private fun pullProject(): Map<Long, DBMember> {
Log.d(TAG, "pullRemoteChanges($project)")
return try {
val projResponse = client!!.getProject(project, 0, null) val projResponse = client!!.getProject(project, 0, null)
updateLocalProject(projResponse) updateLocalProject(projResponse)
syncPaymentModes(projResponse) syncPaymentModes(projResponse)
syncCategories(projResponse) syncCategories(projResponse)
syncCurrencies(projResponse) syncCurrencies(projResponse)
val remoteMembersByRemoteId = syncMembers(projResponse) return syncMembers(projResponse)
}
/**
* Brings the local copy of the project in line with the server: its own row, then each of
* its collections, then its bills. Each step is applied as it goes, so a failure part way
* through leaves the earlier steps written - the next sync picks up where this one stopped.
*
* A partial sync is meant to cost one request. A project's members and labels change far
* less often than its bills, so it skips reading them and goes straight for the bills; it
* only goes back for the project when a bill turns out to name something this device has
* never seen. What that trades away is noticing a rename or a deletion that no bill
* refers to - those land on the next full sync.
*/
private fun pullRemoteChanges(): LoginStatus {
Log.d(TAG, "pullRemoteChanges($project)")
return try {
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
val partial = !forceFullSync && localBillsByRemoteId.isNotEmpty()
var remoteMembersByRemoteId: Map<Long, DBMember>? = null
if (!partial) remoteMembersByRemoteId = pullProject()
// Bills arrive with the server's ids for their member, category and payment mode, // Bills arrive with the server's ids for their member, category and payment mode,
// so the maps have to be built after those collections are in place. // so the maps have to be built after those collections are in place.
val idMaps = buildRemoteIdMaps() var pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
val localBills = dbHelper.getBillsOfProject(project.id) if (pulled.hasUnresolvedReferences && remoteMembersByRemoteId == null) {
val localBillsByRemoteId = localBills.associateBy { it.remoteId } Log.d(TAG, "Bill referenced an unknown member or label; re-reading the project")
val pulled = fetchRemoteBills(idMaps, localBillsByRemoteId) remoteMembersByRemoteId = pullProject()
pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
}
applyRemoteBills(pulled.bills, localBillsByRemoteId) applyRemoteBills(pulled.bills, localBillsByRemoteId)
deleteVanishedBills(pulled, localBills) deleteVanishedBills(pulled, localBills)
deleteVanishedMembers(remoteMembersByRemoteId) remoteMembersByRemoteId?.let { deleteVanishedMembers(it) }
dbHelper.updateProject( dbHelper.updateProject(
projId = project.id, projId = project.id,
@@ -825,6 +876,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
var syncTimestamp = project.lastSyncedTimestamp var syncTimestamp = project.lastSyncedTimestamp
var offset = 0 var offset = 0
var previousPageIds: List<Long>? = null var previousPageIds: List<Long>? = null
var unresolved = false
// Counted across pages, not restarted at each one: a run that begins near the end of // Counted across pages, not restarted at each one: a run that begins near the end of
// a page still finishes on the next. // a page still finishes on the next.
var unchangedRun = 0 var unchangedRun = 0
@@ -834,6 +886,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val page = response.getBillsCospend( val page = response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
) )
if (response.hasUnresolvedReferences) unresolved = true
if (page.isEmpty()) break if (page.isEmpty()) break
if (page.first().timestamp < page.last().timestamp) { if (page.first().timestamp < page.last().timestamp) {
@@ -873,7 +926,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
// A walk reports no allIds, so it never causes a local deletion. // A walk reports no allIds, so it never causes a local deletion.
return RemoteBills(bills, emptyList(), syncTimestamp) return RemoteBills(bills, emptyList(), syncTimestamp, unresolved)
} }
/** /**
@@ -888,14 +941,16 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val bills = response.getBillsIHM( val bills = response.getBillsIHM(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
) )
RemoteBills(bills, bills.map { it.remoteId }, 0L) RemoteBills(bills, bills.map { it.remoteId }, 0L, response.hasUnresolvedReferences)
} else { } else {
RemoteBills( val bills = response.getBillsCospend(
response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
), )
RemoteBills(
bills,
response.allBillIds, response.allBillIds,
response.syncTimestamp response.syncTimestamp,
response.hasUnresolvedReferences
) )
} }
} }
@@ -28,6 +28,33 @@ open class ServerResponse(
) { ) {
private val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) private val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
/**
* Set while parsing bills when one referenced a member, category or payment mode that none of
* the supplied maps knew about. A partial sync does not re-read the project, so this is how it
* learns that its copy of those collections has gone stale and has to be refreshed.
*/
var hasUnresolvedReferences = false
private set
/** Members are only ever addressed by the server's own id, so an unknown one is stale state. */
private fun resolveMember(map: Map<Long, Long>, remoteId: Long): Long {
map[remoteId]?.let { return it }
if (remoteId != 0L) hasUnresolvedReferences = true
return 0L
}
/**
* Built-in labels are negative constants with no row of their own on a remote project, so they
* never appear in these maps and are carried through as-is. An unknown *positive* id is a real
* gap - it is dropped rather than kept, as it would collide with a local id.
*/
private fun resolveLabel(map: Map<Long, Long>, remoteId: Long): Long {
map[remoteId]?.let { return it }
if (remoteId < 0) return remoteId
if (remoteId > 0) hasUnresolvedReferences = true
return 0L
}
class NotModifiedException : IOException() class NotModifiedException : IOException()
protected val content: String protected val content: String
@@ -744,10 +771,10 @@ open class ServerResponse(
} }
if (!json.isNull("payer_id")) { if (!json.isNull("payer_id")) {
payerRemoteId = json.getLong("payer_id") payerRemoteId = json.getLong("payer_id")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0 payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
} else if (!json.isNull("payer")) { } else if (!json.isNull("payer")) {
payerRemoteId = json.getLong("payer") payerRemoteId = json.getLong("payer")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0 payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
} }
if (!json.isNull("amount")) { if (!json.isNull("amount")) {
amount = json.getDouble("amount") amount = json.getDouble("amount")
@@ -794,8 +821,8 @@ open class ServerResponse(
paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE
} }
val categoryId = catRemoteIdToId[categoryRemoteId] ?: 0L val categoryId = resolveLabel(catRemoteIdToId, categoryRemoteId)
val paymentModeId = pmRemoteIdToId[paymentModeRemoteId] ?: 0L val paymentModeId = resolveLabel(pmRemoteIdToId, paymentModeRemoteId)
val bill = DBBill( val bill = DBBill(
0, remoteId, projId, payerId, amount, timestamp, what, 0, remoteId, projId, payerId, amount, timestamp, what,
@@ -822,7 +849,7 @@ open class ServerResponse(
} else { } else {
jsonOs.getLong(i) jsonOs.getLong(i)
} }
val memberLocalId = memberRemoteIdToId[memberRemoteId] ?: 0 val memberLocalId = resolveMember(memberRemoteIdToId, memberRemoteId)
billOwers.add(DBBillOwer(0, 0, memberLocalId)) billOwers.add(DBBillOwer(0, 0, memberLocalId))
} }
} }