From d6ee50bc342e2deacc3a52ec2ad103217db36d42 Mon Sep 17 00:00:00 2001 From: soraefir Date: Thu, 10 Sep 2026 00:26:55 +0200 Subject: [PATCH] Sync Flag lifecycle --- .../persistence/CowspentServerSyncHelper.kt | 87 ++++++++++++++----- 1 file changed, 67 insertions(+), 20 deletions(-) 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 4a5139e..d354ed4 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -14,6 +14,7 @@ import com.nextcloud.android.sso.api.NextcloudAPI import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException import com.nextcloud.android.sso.exceptions.TokenMismatchException import com.nextcloud.android.sso.helper.SingleAccountHelper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -176,14 +177,22 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen } fun execute(): SyncTask { + syncActive = true deferred = scope.async { - syncActive = true - val status = withContext(Dispatchers.IO) { - doWork() + try { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + status + } catch (e: CancellationException) { + syncActive = false + throw e + } catch (e: Exception) { + Log.e(TAG, "Sync failed for ${project.remoteId}", e) + syncActive = false + LoginStatus.CONNECTION_FAILED } - onPostExecute(status) - syncActive = false - status } return this } @@ -293,12 +302,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen null, null, null, null, null ) } - } catch (e: IOException) { - if (e.message == "{\"message\": \"Internal Server Error\"}") { - Log.d(TAG, "EDIT MEMBER FAILED : it does not exist remotely") - } else { - throw e - } + } catch (e: Exception) { + Log.e(TAG, "EDIT MEMBER FAILED for ${mToEdit.name}", e) } } @@ -1048,6 +1053,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen for (localMember in dbHelper.getMembersOfProject(project.id, null)) { if (remoteMembersByRemoteId.containsKey(localMember.remoteId)) continue + if (localMember.state != DBBill.STATE_OK) 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() @@ -1514,11 +1521,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen fun execute(): SyncAccountProjectsTask { scope.launch { syncAccountProjectsActive = true - val status = withContext(Dispatchers.IO) { - doWork() + try { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Account projects sync failed", e) + } finally { + syncAccountProjectsActive = false } - onPostExecute(status) - syncAccountProjectsActive = false } return this } @@ -1547,20 +1561,25 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val response = client.getAccountProjects(useOcsApi) val remoteAccountProjects = response.getAccountProjects(url) + val forgotten = forgottenAccountProjects(preferences) dbHelper.clearAccountProjects() for (remoteAccountProject in remoteAccountProjects) { dbHelper.addAccountProject(remoteAccountProject) Log.v(TAG, "received account project $remoteAccountProject") - val existingProj = localProjects.find { - it.remoteId == remoteAccountProject.remoteId && - it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend" + val existingProj = localProjects.find { + it.remoteId == remoteAccountProject.remoteId && + it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH } if (existingProj == null) { + if (accountProjectKey(remoteAccountProject.remoteId, remoteAccountProject.ncUrl) in forgotten) { + Log.d(TAG, "skipping ${remoteAccountProject.remoteId}, deleted on this device") + continue + } val newProj = DBProject(0, remoteAccountProject.remoteId, "", remoteAccountProject.name, - remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend", + remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH, "", null, ProjectType.COSPEND, @@ -1896,6 +1915,34 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false) } + /** The path a Cospend project's URL carries on top of its Nextcloud server URL. */ + const val COSPEND_PATH = "/index.php/apps/cospend" + + private const val FORGOTTEN_ACCOUNT_PROJECTS = "forgottenAccountProjects" + + private fun trimSlashes(url: String) = url.replace("/+$".toRegex(), "") + + /** Names one project an account offers, by server and remote id, with no local row needed. */ + private fun accountProjectKey(remoteId: String, ncUrl: String) = "${trimSlashes(ncUrl)}|$remoteId" + + /** The same key for a stored project, or null when it is not one an account can offer. */ + private fun accountProjectKey(project: DBProject): String? { + val url = trimSlashes(project.serverUrl.orEmpty()) + if (!url.endsWith(COSPEND_PATH)) return null + return accountProjectKey(project.remoteId, url.removeSuffix(COSPEND_PATH)) + } + + private fun forgottenAccountProjects(preferences: SharedPreferences): Set = + preferences.getStringSet(FORGOTTEN_ACCOUNT_PROJECTS, emptySet()).orEmpty() + + fun forgetAccountProject(context: Context, project: DBProject) { + val key = accountProjectKey(project) ?: return + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences.edit { + putStringSet(FORGOTTEN_ACCOUNT_PROJECTS, forgottenAccountProjects(preferences) + key) + } + } + fun getNextcloudAccountServerUrl(context: Context): String { val preferences = PreferenceManager.getDefaultSharedPreferences(context) return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {