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 3ed7ca9..9add079 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -30,6 +30,7 @@ import net.helcel.cowspent.android.account.AccountActivity import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.util.CospendClientUtil.LoginStatus @@ -163,6 +164,24 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen } } + /** The server's ids for the things a bill points at, mapped to their local row ids. */ + private class RemoteIdMaps( + val members: Map, + val categories: Map, + val paymentModes: Map + ) + + /** One pull of the bills endpoint. */ + private class RemoteBills( + val bills: List, + /** + * Every bill id the server holds. Only the complete fetch reports this; after a paged + * walk it is empty, and nothing may be deleted locally on the strength of it. + */ + val allIds: List, + val syncTimestamp: Long? + ) + inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject, private val forceFullSync: Boolean = false) { private val callbacks: MutableList = ArrayList() private var nextcloudClient: NextcloudClient? = null @@ -555,332 +574,37 @@ 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 + * 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. + */ private fun pullRemoteChanges(): LoginStatus { Log.d(TAG, "pullRemoteChanges($project)") - val lastETag: String? = null - val lastModified: Long = 0 return try { - val projResponse = client!!.getProject(project, lastModified, lastETag) - Log.d(TAG,projResponse.toString()) - val name = projResponse.name - Log.i(TAG, "AAA getProjectInfo, project name: $name") - val email = projResponse.email - val currencyName = projResponse.currencyName - val deletionDisabled = projResponse.deletionDisabled - val myAccessLevel = projResponse.myAccessLevel - val archivedTs = projResponse.archivedTs + val projResponse = client!!.getProject(project, 0, null) - if (project.name == "" || name != project.name || project.email == null || project.email == "" || project.isDeletionDisabled != deletionDisabled || project.myAccessLevel != myAccessLevel || project.archivedTs != archivedTs || (project.currencyName == null) || (currencyName != project.currencyName) || email != project.email - ) { - Log.d(TAG, "update local project : $project") - project.name = name - project.currencyName = currencyName - project.isDeletionDisabled = deletionDisabled - project.myAccessLevel = myAccessLevel - project.archivedTs = archivedTs - dbHelper.updateProject( - projId = project.id, - newName = name, - newEmail = email, - newPassword = null, - newLastPayerId = null, - newLastSyncedTimestamp = null, - newCurrencyName = currencyName, - newDeletionDisabled = deletionDisabled, - newMyAccessLevel = myAccessLevel, - newBearerToken = null, - newArchivedTs = archivedTs ?: 0L - ) - } + updateLocalProject(projResponse) + syncPaymentModes(projResponse) + syncCategories(projResponse) + syncCurrencies(projResponse) + val remoteMembersByRemoteId = syncMembers(projResponse) - val remotePaymentModes = projResponse.getPaymentModes(project.id) - val remotePaymentModesByRemoteId = remotePaymentModes.associateBy { it.remoteId } - - for (pm in remotePaymentModes) { - val localPaymentMode = dbHelper.getPaymentMode(pm.remoteId, project.id) - if (localPaymentMode == null) { - Log.d(TAG, "Add local pm : $pm") - dbHelper.addPaymentMode(pm) - } else { - if (pm.name == localPaymentMode.name && - pm.color == localPaymentMode.color && - pm.icon == localPaymentMode.icon - ) { - Log.d(TAG, "Nothing to do for pm : $localPaymentMode") - } else { - Log.d(TAG, "Update local pm : $pm") - dbHelper.updatePaymentMode(localPaymentMode.id, pm.name, pm.icon, pm.color) - } - } - } - - val localPaymentModes = dbHelper.getPaymentModes(project.id) - for (localPaymentMode in localPaymentModes) { - if (localPaymentMode.state == DBBill.STATE_OK && !remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) { - dbHelper.deletePaymentMode(localPaymentMode.id) - Log.d(TAG, "Delete local pm : $localPaymentMode") - } - } - - val remoteCategories = projResponse.getCategories(project.id) - val remoteCategoriesByRemoteId = remoteCategories.associateBy { it.remoteId } - - for (c in remoteCategories) { - if (c.remoteId == DBBill.CATEGORY_REIMBURSEMENT) continue - val localCategory = dbHelper.getCategory(c.remoteId, project.id) - if (localCategory == null) { - Log.d(TAG, "Add local category : $c") - dbHelper.addCategory(c) - } else { - if (c.name == localCategory.name && - c.color == localCategory.color && - c.icon == localCategory.icon - ) { - Log.d(TAG, "Nothing to do for category : $localCategory") - } else { - Log.d(TAG, "Update local category : $c") - dbHelper.updateCategory(localCategory.id, c.name, c.icon, c.color) - } - } - } - - val localCategories = dbHelper.getCategories(project.id) - for (localCategory in localCategories) { - if (localCategory.state == DBBill.STATE_OK && !remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) { - dbHelper.deleteCategory(localCategory.id) - Log.d(TAG, "Delete local category : $localCategory") - } - } - - val remoteCurrencies = projResponse.getCurrencies(project.id) - val remoteCurrenciesByRemoteId = remoteCurrencies.associateBy { it.remoteId } - - for (c in remoteCurrencies) { - val localCurrency = dbHelper.getCurrency(c.remoteId, project.id) - if (localCurrency == null) { - Log.d(TAG, "Add local currency : $c") - dbHelper.addCurrency(c) - } else { - if (c.name == localCurrency.name && - c.exchangeRate == localCurrency.exchangeRate - ) { - Log.d(TAG, "Nothing to do for currency : $localCurrency") - } else { - Log.d(TAG, "Update local currency : $c") - dbHelper.updateCurrency(localCurrency.id, c.name, c.exchangeRate) - } - } - } - - val localCurrencies = dbHelper.getCurrencies(project.id) - for (localCurrency in localCurrencies) { - if (localCurrency.state == DBBill.STATE_OK && !remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) { - dbHelper.deleteCurrency(localCurrency.id) - Log.d(TAG, "Delete local currency : $localCurrencies") - } - } - - val remoteMembers = projResponse.getMembers(project.id) - val remoteMembersByRemoteId = remoteMembers.associateBy { it.remoteId } - - for (m in remoteMembers) { - val localMember = dbHelper.getMember(m.remoteId, project.id) - if (localMember == null) { - Log.d(TAG, "Add local member : $m") - val mid = dbHelper.addMember(m) - if (!m.ncUserId.isNullOrEmpty()) { - updateMemberAvatar(mid) - } - } else { - val ncUserIdChanged = ( - (m.ncUserId == null && localMember.ncUserId != null) || - (m.ncUserId != null && localMember.ncUserId == null) || - (m.ncUserId != null && m.ncUserId != localMember.ncUserId) - ) - Log.e("PULLREMOTE", "member NC user id : ${localMember.ncUserId} => ${m.ncUserId} ID changed $ncUserIdChanged") - if (ncUserIdChanged && m.ncUserId == null) { - m.ncUserId = "" - } - if (m.name == localMember.name && - m.weight == localMember.weight && - m.isActivated == localMember.isActivated && - ((m.r == null && m.g == null && m.b == null) || - (m.r == localMember.r && m.g == localMember.g && m.b == localMember.b)) && - !ncUserIdChanged - ) { - Log.d(TAG, "Nothing to do for member : $localMember") - if (!localMember.ncUserId.isNullOrEmpty() && localMember.avatar.isNullOrEmpty()) { - Log.d(TAG, "except updating avatar") - updateMemberAvatar(localMember.id) - } - } else { - Log.d(TAG, "Update local member : $m") - var r = m.r - var g = m.g - var b = m.b - if (m.r == null && m.g == null && m.b == null) { - r = localMember.r - g = localMember.g - b = localMember.b - } - val needAvatarUpdate = (ncUserIdChanged && !m.ncUserId.isNullOrEmpty()) - val newAvatar = if (ncUserIdChanged) "" else null - dbHelper.updateMember( - localMember.id, m.name, m.weight, - m.isActivated, null, null, - r, g, b, m.ncUserId, newAvatar - ) - if (needAvatarUpdate) { - Log.e("PLOP", "pullremote : update member avatar") - updateMemberAvatar(localMember.id) - } - } - } - } - - val dbMembers = dbHelper.getMembersOfProject(project.id, null) - val memberRemoteIdToId = dbMembers.associate { it.remoteId to it.id } - - val dbCategories = dbHelper.getCategories(project.id) - val categoriesRemoteIdToId = dbCategories.associate { it.remoteId to it.id }.toMutableMap() - // Map hardcoded constants to their local IDs if they exist in DB, else to themselves - categoriesRemoteIdToId[DBBill.CATEGORY_REIMBURSEMENT] = DBBill.CATEGORY_REIMBURSEMENT - dbCategories.filter { it.remoteId < 0 }.forEach { categoriesRemoteIdToId[it.remoteId] = it.id } - - val dbPaymentModes = dbHelper.getPaymentModes(project.id) - val paymentModesRemoteIdToId = dbPaymentModes.associate { it.remoteId to it.id }.toMutableMap() - dbPaymentModes.filter { it.remoteId < 0 }.forEach { paymentModesRemoteIdToId[it.remoteId] = it.id } - - val isIHM = project.type == ProjectType.IHATEMONEY - var serverSyncTimestamp = project.lastSyncedTimestamp - val remoteBills = mutableListOf() - val remoteAllBillIds = mutableListOf() + // 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() val localBills = dbHelper.getBillsOfProject(project.id) val localBillsByRemoteId = localBills.associateBy { it.remoteId } + val pulled = fetchRemoteBills(idMaps, localBillsByRemoteId) - if (project.type == ProjectType.COSPEND && !forceFullSync && localBills.isNotEmpty()) { - Log.d(TAG, "Starting partial sync for project ${project.remoteId}") - var offset = 0 - val limit = 50 - var allMatched = false - while (!allMatched) { - val partialResponse = client!!.getBills(project, offset, limit, true, 0) - val partialRemoteBills = partialResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId) - if (partialRemoteBills.isEmpty()) break - - remoteBills.addAll(partialRemoteBills) - - var frameMatched = true - for (rb in partialRemoteBills) { - val lb = localBillsByRemoteId[rb.remoteId] - if (lb == null || hasChanged(lb, rb)) { - frameMatched = false - break - } - } - - if (offset == 0 && partialResponse.syncTimestamp > 0) { - serverSyncTimestamp = partialResponse.syncTimestamp - } - - if (frameMatched) { - allMatched = true - } else { - offset += limit - } - } - } else { - Log.d(TAG, "Starting full sync for project ${project.remoteId}") - val billsResponse = client!!.getBills(project) - serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp - if (isIHM) { - remoteBills.addAll(billsResponse.getBillsIHM(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId)) - remoteAllBillIds.addAll(remoteBills.map { it.remoteId }) - } else { - remoteBills.addAll(billsResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId)) - remoteAllBillIds.addAll(billsResponse.allBillIds) - } - } - - val remoteBillsByRemoteId = remoteBills.associateBy { it.remoteId } - - for (remoteBill in remoteBills) { - if (!localBillsByRemoteId.containsKey(remoteBill.remoteId)) { - dbHelper.addBill(remoteBill) - nbPulledNewBills++ - newBillsDialogText += "+ ${remoteBill.what}\n" - } else { - val localBill = localBillsByRemoteId[remoteBill.remoteId]!! - if (hasChanged(localBill, remoteBill)) { - dbHelper.updateBill( - localBill.id, null, remoteBill.payerId, - remoteBill.amount, remoteBill.timestamp, - remoteBill.what, DBBill.STATE_OK, remoteBill.repeat, - remoteBill.paymentMode, remoteBill.paymentModeId, - remoteBill.categoryId, remoteBill.comment - ) - nbPulledUpdatedBills++ - updatedBillsDialogText += "✏ ${remoteBill.what}\n" - } else { - Log.d(TAG, "Nothing to do for bill : $localBill") - } - - val localBillOwersByIds = localBill.billOwers.associateBy { it.memberId } - val remoteBillOwersByIds = remoteBill.billOwers.associateBy { it.memberId } - - for (rbo in remoteBill.billOwers) { - if (!localBillOwersByIds.containsKey(rbo.memberId)) { - dbHelper.addBillower(localBill.id, rbo.memberId) - Log.d(TAG, "Add local billOwer : $rbo") - } - } - for (lbo in localBill.billOwers) { - if (!remoteBillOwersByIds.containsKey(lbo.memberId)) { - dbHelper.deleteBillOwer(lbo.id) - Log.d(TAG, "Delete local billOwer : $lbo") - } - } - } - } - - if (remoteAllBillIds.isNotEmpty() && (project.type == ProjectType.COSPEND || project.type == ProjectType.IHATEMONEY)) { - for (localBill in localBills) { - if (!remoteAllBillIds.contains(localBill.remoteId)) { - dbHelper.deleteBill(localBill.id) - nbPulledDeletedBills++ - deletedBillsDialogText += "🗑 ${localBill.what}\n" - Log.d(TAG, "Delete local bill : $localBill") - } - } - } else if (remoteAllBillIds.isNotEmpty()) { - for (localBill in localBills) { - if (!remoteBillsByRemoteId.containsKey(localBill.remoteId)) { - dbHelper.deleteBill(localBill.id) - nbPulledDeletedBills++ - deletedBillsDialogText += "🗑 ${localBill.what}\n" - Log.d(TAG, "Delete local bill : $localBill") - } - } - } - - val localMembers = dbHelper.getMembersOfProject(project.id, null) - for (localMember in localMembers) { - if (!remoteMembersByRemoteId.containsKey(localMember.remoteId)) { - if (dbHelper.getBillsOfMember(localMember.id).isEmpty() - && dbHelper.getBillowersOfMember(localMember.id).isEmpty() - ) { - dbHelper.deleteMember(localMember.id) - Log.d(TAG, "Delete local member : $localMember") - } else { - Log.d(TAG, "WARNING local member : ${localMember.name} does not exist remotely but is still involved in some bills") - } - } - } + applyRemoteBills(pulled.bills, localBillsByRemoteId) + deleteVanishedBills(pulled, localBills) + deleteVanishedMembers(remoteMembersByRemoteId) dbHelper.updateProject( projId = project.id, - newLastSyncedTimestamp = serverSyncTimestamp + newLastSyncedTimestamp = pulled.syncTimestamp ) LoginStatus.OK } catch (_: ServerResponse.NotModifiedException) { @@ -904,6 +628,380 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen LoginStatus.REQ_FAILED } } + private fun updateLocalProject(projResponse: ServerResponse.ProjectResponse) { + val name = projResponse.name + val email = projResponse.email + val currencyName = projResponse.currencyName + val deletionDisabled = projResponse.deletionDisabled + val myAccessLevel = projResponse.myAccessLevel + val archivedTs = projResponse.archivedTs + + val unchanged = project.name.isNotEmpty() && + name == project.name && + !project.email.isNullOrEmpty() && + email == project.email && + project.isDeletionDisabled == deletionDisabled && + project.myAccessLevel == myAccessLevel && + project.archivedTs == archivedTs && + project.currencyName != null && + currencyName == project.currencyName + if (unchanged) return + + Log.d(TAG, "update local project : $project") + project.name = name + project.currencyName = currencyName + project.isDeletionDisabled = deletionDisabled + project.myAccessLevel = myAccessLevel + project.archivedTs = archivedTs + dbHelper.updateProject( + projId = project.id, + newName = name, + newEmail = email, + newPassword = null, + newLastPayerId = null, + newLastSyncedTimestamp = null, + newCurrencyName = currencyName, + newDeletionDisabled = deletionDisabled, + newMyAccessLevel = myAccessLevel, + newBearerToken = null, + newArchivedTs = archivedTs ?: 0L + ) + } + + private fun syncPaymentModes(projResponse: ServerResponse.ProjectResponse) { + val remote = projResponse.getPaymentModes(project.id) + for (pm in remote) { + val local = dbHelper.getPaymentMode(pm.remoteId, project.id) + if (local == null) { + Log.d(TAG, "Add local pm : $pm") + dbHelper.addPaymentMode(pm) + } else if (pm.name == local.name && pm.color == local.color && pm.icon == local.icon) { + Log.d(TAG, "Nothing to do for pm : $local") + } else { + Log.d(TAG, "Update local pm : $pm") + dbHelper.updatePaymentMode(local.id, pm.name, pm.icon, pm.color) + } + } + + // Only settled rows may be dropped; one still waiting to be pushed is not "gone". + val remoteIds = remote.map { it.remoteId }.toSet() + for (local in dbHelper.getPaymentModes(project.id)) { + if (local.state == DBBill.STATE_OK && local.remoteId !in remoteIds) { + dbHelper.deletePaymentMode(local.id) + Log.d(TAG, "Delete local pm : $local") + } + } + } + + private fun syncCategories(projResponse: ServerResponse.ProjectResponse) { + val remote = projResponse.getCategories(project.id) + for (c in remote) { + if (c.remoteId == DBBill.CATEGORY_REIMBURSEMENT) continue + val local = dbHelper.getCategory(c.remoteId, project.id) + if (local == null) { + Log.d(TAG, "Add local category : $c") + dbHelper.addCategory(c) + } else if (c.name == local.name && c.color == local.color && c.icon == local.icon) { + Log.d(TAG, "Nothing to do for category : $local") + } else { + Log.d(TAG, "Update local category : $c") + dbHelper.updateCategory(local.id, c.name, c.icon, c.color) + } + } + + val remoteIds = remote.map { it.remoteId }.toSet() + for (local in dbHelper.getCategories(project.id)) { + if (local.state == DBBill.STATE_OK && local.remoteId !in remoteIds) { + dbHelper.deleteCategory(local.id) + Log.d(TAG, "Delete local category : $local") + } + } + } + + private fun syncCurrencies(projResponse: ServerResponse.ProjectResponse) { + val remote = projResponse.getCurrencies(project.id) + for (c in remote) { + val local = dbHelper.getCurrency(c.remoteId, project.id) + if (local == null) { + Log.d(TAG, "Add local currency : $c") + dbHelper.addCurrency(c) + } else if (c.name == local.name && c.exchangeRate == local.exchangeRate) { + Log.d(TAG, "Nothing to do for currency : $local") + } else { + Log.d(TAG, "Update local currency : $c") + dbHelper.updateCurrency(local.id, c.name, c.exchangeRate) + } + } + + val remoteIds = remote.map { it.remoteId }.toSet() + for (local in dbHelper.getCurrencies(project.id)) { + if (local.state == DBBill.STATE_OK && local.remoteId !in remoteIds) { + dbHelper.deleteCurrency(local.id) + Log.d(TAG, "Delete local currency : $local") + } + } + } + + /** Returns the members the server listed, keyed by remote id, for the later cleanup pass. */ + private fun syncMembers(projResponse: ServerResponse.ProjectResponse): Map { + val remote = projResponse.getMembers(project.id) + for (m in remote) { + val local = dbHelper.getMember(m.remoteId, project.id) + if (local == null) { + Log.d(TAG, "Add local member : $m") + val mid = dbHelper.addMember(m) + if (!m.ncUserId.isNullOrEmpty()) { + updateMemberAvatar(mid) + } + } else { + updateLocalMember(m, local) + } + } + return remote.associateBy { it.remoteId } + } + + private fun updateLocalMember(remote: DBMember, local: DBMember) { + val ncUserIdChanged = remote.ncUserId != local.ncUserId + Log.d(TAG, "member NC user id : ${local.ncUserId} => ${remote.ncUserId} ID changed $ncUserIdChanged") + if (ncUserIdChanged && remote.ncUserId == null) { + remote.ncUserId = "" + } + + // The server omits colours it has never been told about, which is not the same as + // clearing them, so a null triple means "unchanged" rather than "no colour". + val remoteHasNoColour = remote.r == null && remote.g == null && remote.b == null + val colourUnchanged = remoteHasNoColour || + (remote.r == local.r && remote.g == local.g && remote.b == local.b) + + if (remote.name == local.name && remote.weight == local.weight && + remote.isActivated == local.isActivated && colourUnchanged && !ncUserIdChanged + ) { + Log.d(TAG, "Nothing to do for member : $local") + if (!local.ncUserId.isNullOrEmpty() && local.avatar.isNullOrEmpty()) { + Log.d(TAG, "except updating avatar") + updateMemberAvatar(local.id) + } + return + } + + Log.d(TAG, "Update local member : $remote") + val r = if (remoteHasNoColour) local.r else remote.r + val g = if (remoteHasNoColour) local.g else remote.g + val b = if (remoteHasNoColour) local.b else remote.b + val needAvatarUpdate = ncUserIdChanged && !remote.ncUserId.isNullOrEmpty() + dbHelper.updateMember( + local.id, remote.name, remote.weight, + remote.isActivated, null, null, + r, g, b, remote.ncUserId, if (ncUserIdChanged) "" else null + ) + if (needAvatarUpdate) { + updateMemberAvatar(local.id) + } + } + + private fun buildRemoteIdMaps(): RemoteIdMaps { + val members = dbHelper.getMembersOfProject(project.id, null) + .associate { it.remoteId to it.id } + + val categories = dbHelper.getCategories(project.id) + .associate { it.remoteId to it.id } + .toMutableMap() + // The built-in categories keep their negative ids rather than getting local rows. + categories[DBBill.CATEGORY_REIMBURSEMENT] = DBBill.CATEGORY_REIMBURSEMENT + dbHelper.getCategories(project.id).filter { it.remoteId < 0 } + .forEach { categories[it.remoteId] = it.id } + + val paymentModes = dbHelper.getPaymentModes(project.id) + .associate { it.remoteId to it.id } + .toMutableMap() + dbHelper.getPaymentModes(project.id).filter { it.remoteId < 0 } + .forEach { paymentModes[it.remoteId] = it.id } + + return RemoteIdMaps(members, categories, paymentModes) + } + + /** + * Fetches the bills, by paged walk where the server supports one and by complete fetch + * otherwise. Falls back to the complete fetch whenever the walk cannot be trusted, so the + * caller always gets a usable result. + */ + private fun fetchRemoteBills( + idMaps: RemoteIdMaps, + localBillsByRemoteId: Map + ): RemoteBills { + val usePagedWalk = project.type == ProjectType.COSPEND && !forceFullSync && + localBillsByRemoteId.isNotEmpty() && client!!.supportsPagedBills + if (usePagedWalk) { + walkBillPages(idMaps, localBillsByRemoteId)?.let { return it } + } + return fetchAllBills(idMaps) + } + + /** + * Walks back through pages of bills, newest first, stopping at the first page that already + * matches locally and taking everything older on trust. + * + * That only terminates while the server really does reverse the order and honour the + * offset. One that does neither returns the same oldest-first page every time, and since + * the walk compares against local rows it never writes, a single unknown bill keeps every + * page mismatched and the same page is requested forever. Returns null when the responses + * show that happening, so the caller can fall back to the complete fetch. + */ + private fun walkBillPages( + idMaps: RemoteIdMaps, + localBillsByRemoteId: Map + ): RemoteBills? { + Log.d(TAG, "Starting partial sync for project ${project.remoteId}") + val limit = 50 + val bills = mutableListOf() + var syncTimestamp = project.lastSyncedTimestamp + var offset = 0 + var previousPageIds: List? = null + + while (true) { + val response = client!!.getBills(project, offset, limit, true, 0) + val page = response.getBillsCospend( + project.id, idMaps.members, idMaps.categories, idMaps.paymentModes + ) + if (page.isEmpty()) break + + if (page.first().timestamp < page.last().timestamp) { + Log.w(TAG, "Server returned bills oldest-first; the paged walk cannot be trusted") + return null + } + val pageIds = page.map { it.remoteId } + if (pageIds == previousPageIds) { + Log.w(TAG, "Server returned the same page for offset $offset; it is ignoring the offset") + return null + } + previousPageIds = pageIds + + bills.addAll(page) + if (offset == 0 && response.syncTimestamp > 0) { + syncTimestamp = response.syncTimestamp + } + + val pageAlreadyLocal = page.all { remote -> + val local = localBillsByRemoteId[remote.remoteId] + local != null && !hasChanged(local, remote) + } + // A short page is the end of the collection: there is nothing older to walk back + // to, so asking for the next offset would only refetch it. + if (pageAlreadyLocal || page.size < limit) break + offset += limit + } + + // A walk reports no allIds, so it never causes a local deletion. + return RemoteBills(bills, emptyList(), syncTimestamp) + } + + private fun fetchAllBills(idMaps: RemoteIdMaps): RemoteBills { + Log.d(TAG, "Starting full sync for project ${project.remoteId}") + val response = client!!.getBills(project) + return if (project.type == ProjectType.IHATEMONEY) { + val bills = response.getBillsIHM( + project.id, idMaps.members, idMaps.categories, idMaps.paymentModes + ) + RemoteBills(bills, bills.map { it.remoteId }, 0L) + } else { + RemoteBills( + response.getBillsCospend( + project.id, idMaps.members, idMaps.categories, idMaps.paymentModes + ), + response.allBillIds, + response.syncTimestamp + ) + } + } + + private fun applyRemoteBills( + remoteBills: List, + localBillsByRemoteId: Map + ) { + for (remoteBill in remoteBills) { + val localBill = localBillsByRemoteId[remoteBill.remoteId] + if (localBill == null) { + dbHelper.addBill(remoteBill) + nbPulledNewBills++ + newBillsDialogText += "+ ${remoteBill.what}\n" + continue + } + + if (hasChanged(localBill, remoteBill)) { + dbHelper.updateBill( + localBill.id, null, remoteBill.payerId, + remoteBill.amount, remoteBill.timestamp, + remoteBill.what, DBBill.STATE_OK, remoteBill.repeat, + remoteBill.paymentMode, remoteBill.paymentModeId, + remoteBill.categoryId, remoteBill.comment + ) + nbPulledUpdatedBills++ + updatedBillsDialogText += "✏ ${remoteBill.what}\n" + } else { + Log.d(TAG, "Nothing to do for bill : $localBill") + } + + syncBillOwers(localBill, remoteBill) + } + } + + private fun syncBillOwers(localBill: DBBill, remoteBill: DBBill) { + val localMemberIds = localBill.billOwers.map { it.memberId }.toSet() + val remoteMemberIds = remoteBill.billOwers.map { it.memberId }.toSet() + + for (rbo in remoteBill.billOwers) { + if (rbo.memberId !in localMemberIds) { + dbHelper.addBillower(localBill.id, rbo.memberId) + Log.d(TAG, "Add local billOwer : $rbo") + } + } + for (lbo in localBill.billOwers) { + if (lbo.memberId !in remoteMemberIds) { + dbHelper.deleteBillOwer(lbo.id) + Log.d(TAG, "Delete local billOwer : $lbo") + } + } + } + + /** + * Drops local bills the server no longer has. An empty allIds means the server never told + * us the full set - after a paged walk, say - and nothing may be deleted on that basis. + */ + private fun deleteVanishedBills(pulled: RemoteBills, localBills: List) { + if (pulled.allIds.isEmpty()) return + + val stillRemote: Set = + if (project.type == ProjectType.COSPEND || project.type == ProjectType.IHATEMONEY) { + pulled.allIds.toSet() + } else { + pulled.bills.map { it.remoteId }.toSet() + } + + for (localBill in localBills) { + if (localBill.remoteId !in stillRemote) { + dbHelper.deleteBill(localBill.id) + nbPulledDeletedBills++ + deletedBillsDialogText += "🗑 ${localBill.what}\n" + Log.d(TAG, "Delete local bill : $localBill") + } + } + } + + private fun deleteVanishedMembers(remoteMembersByRemoteId: Map) { + for (localMember in dbHelper.getMembersOfProject(project.id, null)) { + if (remoteMembersByRemoteId.containsKey(localMember.remoteId)) continue + + // A member still named by a bill cannot be removed without orphaning it. + if (dbHelper.getBillsOfMember(localMember.id).isEmpty() && + dbHelper.getBillowersOfMember(localMember.id).isEmpty() + ) { + dbHelper.deleteMember(localMember.id) + Log.d(TAG, "Delete local member : $localMember") + } else { + Log.d(TAG, "WARNING local member : ${localMember.name} does not exist remotely but is still involved in some bills") + } + } + } private fun onPostExecute(status: LoginStatus) { if (status != LoginStatus.OK) { diff --git a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt index 59e0f4d..31fe7c7 100644 --- a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt @@ -51,7 +51,7 @@ class NextcloudClient( @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) fun getCapabilities(project: DBProject?): ServerResponse.CapabilitiesResponse { - val target: String = if (project == null || url != "") { + val target: String = if (project == null || url != "" || nextcloudAPI != null) { "/ocs/v2.php/cloud/capabilities" } else { val realServerUrl = project.serverUrl!! 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 78b15e6..dee8c5a 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -47,6 +47,8 @@ class VersatileProjectSyncClient( gt } + val supportsPagedBills: Boolean get() = cospendVersionGT161 + fun canAccessProjectWithNCLogin(project: DBProject): Boolean { return (project.password == "" && url.replace("/+$".toRegex(), "") != ""