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.
*/
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) {
@@ -188,11 +193,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (project.type == ProjectType.COSPEND) {
nextcloudClient = createNextcloudClient()
if (nextcloudClient != null) {
try {
val response = nextcloudClient!!.getCapabilities(project)
version = response.cospendVersion
} catch (e: Exception) {
Log.i(TAG, "Failed to get cospend version when syncing: $e")
// 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 {
val response = nextcloudClient!!.getCapabilities(project)
version = response.cospendVersion
rememberCospendVersion(version)
} catch (e: Exception) {
Log.i(TAG, "Failed to get cospend version when syncing: $e")
}
}
} else if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
return LoginStatus.SSO_TOKEN_MISMATCH
@@ -218,35 +230,53 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
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 {
Log.d(TAG, "PUSH LOCAL CHANGES")
return try {
val membersResponse = client!!.getMembers(project)
val remoteMembers = membersResponse.getMembers(project.id)
val remoteMembersNames = remoteMembers.map { it.name }
// 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)
for (mToAdd in membersToAdd) {
val searchIndex = remoteMembersNames.indexOf(mToAdd.name)
if (searchIndex != -1) {
val remoteMember = remoteMembers[searchIndex]
dbHelper.updateMember(
mToAdd.id, null,
remoteMember.weight, remoteMember.isActivated,
DBBill.STATE_OK, remoteMember.remoteId, remoteMember.r,
remoteMember.g, remoteMember.b,
remoteMember.ncUserId, ""
)
} else {
val createRemoteMemberResponse = client!!.createRemoteMember(project, mToAdd)
val newRemoteId = createRemoteMemberResponse.remoteMemberId
if (newRemoteId > 0) {
if (membersToAdd.isNotEmpty()) {
val membersResponse = client!!.getMembers(project)
val remoteMembers = membersResponse.getMembers(project.id)
val remoteMembersNames = remoteMembers.map { it.name }
for (mToAdd in membersToAdd) {
val searchIndex = remoteMembersNames.indexOf(mToAdd.name)
if (searchIndex != -1) {
val remoteMember = remoteMembers[searchIndex]
dbHelper.updateMember(
mToAdd.id, null,
null, null, DBBill.STATE_OK, newRemoteId,
null, null, null, null, null
remoteMember.weight, remoteMember.isActivated,
DBBill.STATE_OK, remoteMember.remoteId, remoteMember.r,
remoteMember.g, remoteMember.b,
remoteMember.ncUserId, ""
)
} else {
val createRemoteMemberResponse = client!!.createRemoteMember(project, mToAdd)
val newRemoteId = createRemoteMemberResponse.remoteMemberId
if (newRemoteId > 0) {
dbHelper.updateMember(
mToAdd.id, null,
null, null, DBBill.STATE_OK, newRemoteId,
null, null, null, null, null
)
}
}
}
}
@@ -539,33 +569,54 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
/**
* Reads the project's own row and each of its collections. One request, and everything it
* carries is applied as it goes, so a failure part way through leaves the earlier steps
* written. Returns the members it saw, keyed by remote id.
*/
private fun pullProject(): Map<Long, DBMember> {
val projResponse = client!!.getProject(project, 0, null)
updateLocalProject(projResponse)
syncPaymentModes(projResponse)
syncCategories(projResponse)
syncCurrencies(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 projResponse = client!!.getProject(project, 0, null)
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
val partial = !forceFullSync && localBillsByRemoteId.isNotEmpty()
updateLocalProject(projResponse)
syncPaymentModes(projResponse)
syncCategories(projResponse)
syncCurrencies(projResponse)
val remoteMembersByRemoteId = syncMembers(projResponse)
var remoteMembersByRemoteId: Map<Long, DBMember>? = null
if (!partial) remoteMembersByRemoteId = pullProject()
// 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.
val idMaps = buildRemoteIdMaps()
var pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
val pulled = fetchRemoteBills(idMaps, localBillsByRemoteId)
if (pulled.hasUnresolvedReferences && remoteMembersByRemoteId == null) {
Log.d(TAG, "Bill referenced an unknown member or label; re-reading the project")
remoteMembersByRemoteId = pullProject()
pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
}
applyRemoteBills(pulled.bills, localBillsByRemoteId)
deleteVanishedBills(pulled, localBills)
deleteVanishedMembers(remoteMembersByRemoteId)
remoteMembersByRemoteId?.let { deleteVanishedMembers(it) }
dbHelper.updateProject(
projId = project.id,
@@ -825,6 +876,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
var syncTimestamp = project.lastSyncedTimestamp
var offset = 0
var previousPageIds: List<Long>? = null
var unresolved = false
// Counted across pages, not restarted at each one: a run that begins near the end of
// a page still finishes on the next.
var unchangedRun = 0
@@ -834,6 +886,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val page = response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
)
if (response.hasUnresolvedReferences) unresolved = true
if (page.isEmpty()) break
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.
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(
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 {
val bills = response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
)
RemoteBills(
response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
),
bills,
response.allBillIds,
response.syncTimestamp
response.syncTimestamp,
response.hasUnresolvedReferences
)
}
}
@@ -28,6 +28,33 @@ open class ServerResponse(
) {
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()
protected val content: String
@@ -744,10 +771,10 @@ open class ServerResponse(
}
if (!json.isNull("payer_id")) {
payerRemoteId = json.getLong("payer_id")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0
payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
} else if (!json.isNull("payer")) {
payerRemoteId = json.getLong("payer")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0
payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
}
if (!json.isNull("amount")) {
amount = json.getDouble("amount")
@@ -794,8 +821,8 @@ open class ServerResponse(
paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE
}
val categoryId = catRemoteIdToId[categoryRemoteId] ?: 0L
val paymentModeId = pmRemoteIdToId[paymentModeRemoteId] ?: 0L
val categoryId = resolveLabel(catRemoteIdToId, categoryRemoteId)
val paymentModeId = resolveLabel(pmRemoteIdToId, paymentModeRemoteId)
val bill = DBBill(
0, remoteId, projId, payerId, amount, timestamp, what,
@@ -822,7 +849,7 @@ open class ServerResponse(
} else {
jsonOs.getLong(i)
}
val memberLocalId = memberRemoteIdToId[memberRemoteId] ?: 0
val memberLocalId = resolveMember(memberRemoteIdToId, memberRemoteId)
billOwers.add(DBBillOwer(0, 0, memberLocalId))
}
}