Fix: Critical sync, build and data-safety bugs in Android app

Sync engine (critical):
- SyncWorker now pulls server ops for EVERY tracked list, not just
  lists with pending local outbox ops. Previously remote edits on
  'quiet' lists (incl. shared/joined lists) never arrived.
- SyncWorker advances the local HLC with each incoming server op
  (tick(op.hlcTs)) so LWW is correct across devices with skewed
  clocks; previously a fast-clock device permanently won conflicts
  and a slow-clock device's own edits were silently rejected.
- Added missing 'list_create' branch in applyServerOpProjection.
- Use server hlc_ts for createdAt/updatedAt in projections so lists
  and items keep a stable order instead of jumping by sync time.
- Prune old synced op_log rows (deleteOldSynced) to bound growth.
- Added ListDao.getAllListIds() one-shot query for the pull loop.

Build / runtime (critical):
- Added proguard-rules.pro with keep rules for kotlinx.serialization
  serializers and Retrofit interfaces; release builds with R8 would
  otherwise crash with SerializationException on the first API call.
- Added network_security_config.xml allowing cleartext only to
  10.0.2.2/localhost so the debug build can reach the local backend
  (blocked by default on Android 9+).
- Manifest: disable default WorkManager initializer so Hilt's
  HiltWorkerFactory is used (otherwise SyncWorker can fail to
  instantiate); added ACCESS_NETWORK_STATE permission.

Data safety (critical/major):
- Removed fallbackToDestructiveMigration from DatabaseModule: in a
  local-first app a destructive migration on schema bump would wipe
  the source of truth. Provide explicit Migrations instead.
- ShoppingRepository: wrap every local projection write + op_log
  insert in db.withTransaction{} so a crash between them can no
  longer silently lose a pending sync op.
- ShoppingRepository: replace manual JSON string concatenation with
  kotlinx.serialization buildJsonObject; the old escapeJson did not
  handle tab/CR/control chars, producing malformed op payloads.
- Persist device clientId and clientSeq counter in SessionManager so
  they survive process restarts (idempotency stays stable per device).
- Trigger immediate + periodic sync after login/register/OIDC so
  users see their lists without waiting up to 15 minutes.

.gitignore: ignore desktop.ini and backend/.testbin.
This commit is contained in:
Tronax 2026-08-06 10:01:57 +02:00
parent 97583340a4
commit b44bc8c3af
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
10 changed files with 326 additions and 141 deletions

View file

@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".MitbringslApp"
@ -10,6 +12,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:windowSoftInputMode="adjustResize"
@ -23,6 +26,22 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
Disable the default WorkManager initializer so Hilt's HiltWorkerFactory
(set via Configuration.Provider in MitbringslApp) is the one used.
Without this, SyncWorker (AssistedInject) can fail to instantiate.
-->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View file

@ -4,6 +4,7 @@ import com.example.mitbringsl.data.remote.api.MitbringslApi
import com.example.mitbringsl.data.remote.dto.LoginRequestDto
import com.example.mitbringsl.data.remote.dto.OidcRequestDto
import com.example.mitbringsl.data.remote.dto.RegisterRequestDto
import com.example.mitbringsl.data.sync.SyncManager
import javax.inject.Inject
import javax.inject.Singleton
@ -16,6 +17,7 @@ sealed interface AuthResult {
class AuthRepository @Inject constructor(
private val api: MitbringslApi,
private val sessionManager: SessionManager,
private val syncManager: SyncManager,
) {
suspend fun login(email: String, pass: String): AuthResult {
@ -28,6 +30,7 @@ class AuthRepository @Inject constructor(
userId = body.user.id,
email = body.user.email
)
onLoggedIn()
AuthResult.Success(userId = body.user.id, email = body.user.email)
} else {
val err = response.errorBody()?.string() ?: "Anmeldung fehlgeschlagen"
@ -50,6 +53,7 @@ class AuthRepository @Inject constructor(
userId = body.user.id,
email = body.user.email
)
onLoggedIn()
AuthResult.Success(userId = body.user.id, email = body.user.email)
} else {
val err = response.errorBody()?.string() ?: "Registrierung fehlgeschlagen"
@ -70,6 +74,7 @@ class AuthRepository @Inject constructor(
userId = body.user.id,
email = body.user.email
)
onLoggedIn()
AuthResult.Success(userId = body.user.id, email = body.user.email)
} else {
val err = response.errorBody()?.string() ?: "OIDC Login fehlgeschlagen"
@ -90,6 +95,16 @@ class AuthRepository @Inject constructor(
}
}
/**
* Called after any successful login/registration: schedule periodic sync and
* trigger an immediate one so the user sees their lists without waiting up
* to 15 minutes for the first periodic run.
*/
private fun onLoggedIn() {
syncManager.schedulePeriodicSync()
syncManager.triggerImmediateSync()
}
private fun parseErrorMessage(errorBody: String, code: Int): String {
return when (code) {
401 -> "E-Mail oder Passwort falsch."

View file

@ -58,6 +58,36 @@ class SessionManager @Inject constructor(
return localId
}
/**
* Stable device installation id, generated once and persisted. Used as the
* `client_id` of all outbox ops so the server can de-duplicate across restarts
* and attribute ops to the same device.
*/
fun getDeviceClientId(): String {
var id = prefs.getString("device_client_id", null)
if (id.isNullOrBlank()) {
id = java.util.UUID.randomUUID().toString()
prefs.edit { putString("device_client_id", id) }
}
return id
}
/**
* Monotonic per-device sequence counter, persisted so it survives process
* restarts. Combined with [getDeviceClientId] this forms the idempotency key
* the server uses to deduplicate retried pushes.
*/
@Synchronized
fun nextClientSeq(): Long {
// Start the counter from a high base so it can never collide with an
// older non-persistent scheme that used System.currentTimeMillis().
var seq = prefs.getLong("client_seq", 0L)
if (seq == 0L) seq = System.currentTimeMillis()
seq += 1
prefs.edit { putLong("client_seq", seq) }
return seq
}
fun getUserEmail(): String? = prefs.getString("user_email", null)
fun saveSession(token: String, userId: String, email: String) {

View file

@ -48,4 +48,8 @@ interface ListDao {
@Query("SELECT COUNT(*) FROM lists WHERE ownerId = :ownerId AND deletedAt IS NULL")
suspend fun count(ownerId: String): Int
/** All non-deleted list ids for a given owner (one-shot, for sync pull loop). */
@Query("SELECT id FROM lists WHERE ownerId = :ownerId AND deletedAt IS NULL")
suspend fun getAllListIds(ownerId: String): List<String>
}

View file

@ -1,6 +1,8 @@
package com.example.mitbringsl.data.repository
import androidx.room.withTransaction
import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.data.local.AppDatabase
import com.example.mitbringsl.data.local.dao.ItemDao
import com.example.mitbringsl.data.local.dao.ListDao
import com.example.mitbringsl.data.local.dao.OpLogDao
@ -10,33 +12,39 @@ import com.example.mitbringsl.data.local.entity.OpLogEntity
import com.example.mitbringsl.data.sync.HybridLogicalClock
import com.example.mitbringsl.data.sync.SyncManager
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShoppingRepository @Inject constructor(
private val db: AppDatabase,
private val listDao: ListDao,
private val itemDao: ItemDao,
private val opLogDao: OpLogDao,
private val sessionManager: SessionManager,
private val syncManager: SyncManager,
private val json: Json,
) {
/** Device Installation Client ID */
private val clientId = UUID.randomUUID().toString()
private var clientSeqCounter = System.currentTimeMillis()
/** Stable, persisted device installation id (see SessionManager.getDeviceClientId). */
private val clientId: String get() = sessionManager.getDeviceClientId()
private fun nextClientSeq(): Long = synchronized(this) { ++clientSeqCounter }
private fun nextClientSeq(): Long = sessionManager.nextClientSeq()
// --- Lists ---
fun observeLists(): Flow<List<ListEntity>> {
val ownerId = sessionManager.getUserId() ?: ""
val ownerId = sessionManager.getUserId()
return listDao.observeLists(ownerId)
}
suspend fun createList(name: String): ListEntity {
val ownerId = sessionManager.getUserId() ?: ""
val ownerId = sessionManager.getUserId()
val hlc = HybridLogicalClock.tick()
val listId = UUID.randomUUID().toString()
@ -49,24 +57,24 @@ class ShoppingRepository @Inject constructor(
hlcTs = hlc
)
// 1. Write to Room local projection
listDao.upsert(list)
// 2. Append to OpLog outbox
val seq = nextClientSeq()
opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = seq,
opType = "list_create",
targetId = listId,
payload = """{"name":"${escapeJson(name)}"}""",
hlcTs = hlc
// Atomic: write the local projection AND enqueue the outbox op together.
// If the process is killed in between, a non-transactional split would
// leave the local change without a matching op → silent sync loss.
db.withTransaction {
listDao.upsert(list)
opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = nextClientSeq(),
opType = "list_create",
targetId = listId,
payload = buildPayload { put("name", name) },
hlcTs = hlc
)
)
)
}
// 3. Trigger immediate background sync
syncManager.triggerImmediateSync()
return list
}
@ -75,28 +83,28 @@ class ShoppingRepository @Inject constructor(
val list = listDao.getList(listId) ?: return
val hlc = HybridLogicalClock.tick()
listDao.upsertLww(
id = listId,
name = list.name,
ownerId = list.ownerId,
createdAt = list.createdAt,
updatedAt = System.currentTimeMillis(),
deletedAt = System.currentTimeMillis(),
hlcTs = hlc
)
val seq = nextClientSeq()
opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = seq,
opType = "list_delete",
targetId = listId,
payload = "{}",
db.withTransaction {
listDao.upsertLww(
id = listId,
name = list.name,
ownerId = list.ownerId,
createdAt = list.createdAt,
updatedAt = System.currentTimeMillis(),
deletedAt = System.currentTimeMillis(),
hlcTs = hlc
)
)
opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = nextClientSeq(),
opType = "list_delete",
targetId = listId,
payload = "{}",
hlcTs = hlc
)
)
}
syncManager.triggerImmediateSync()
}
@ -123,23 +131,23 @@ class ShoppingRepository @Inject constructor(
clientId = clientId
)
// 1. Write to Room
itemDao.upsert(item)
// 2. Append to OpLog
val seq = nextClientSeq()
val qtyJson = if (quantity.isNullOrBlank()) "" else """, "quantity":"${escapeJson(quantity)}""""
opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = seq,
opType = "item_add",
targetId = itemId,
payload = """{"name":"${escapeJson(name)}"$qtyJson}""",
hlcTs = hlc
db.withTransaction {
itemDao.upsert(item)
opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = nextClientSeq(),
opType = "item_add",
targetId = itemId,
payload = buildPayload {
put("name", name)
if (!quantity.isNullOrBlank()) put("quantity", quantity)
},
hlcTs = hlc
)
)
)
}
syncManager.triggerImmediateSync()
return item
@ -150,33 +158,33 @@ class ShoppingRepository @Inject constructor(
val newChecked = !item.checked
val hlc = HybridLogicalClock.tick()
itemDao.upsertLww(
id = item.id,
listId = item.listId,
name = item.name,
quantity = item.quantity,
checked = newChecked,
sortOrder = item.sortOrder,
createdAt = item.createdAt,
updatedAt = System.currentTimeMillis(),
deletedAt = item.deletedAt,
hlcTs = hlc,
clientId = item.clientId,
checkedAt = if (newChecked) System.currentTimeMillis() else null
)
val seq = nextClientSeq()
opLogDao.insert(
OpLogEntity(
db.withTransaction {
itemDao.upsertLww(
id = item.id,
listId = item.listId,
clientId = clientId,
clientSeq = seq,
opType = "item_update",
targetId = item.id,
payload = """{"checked":$newChecked}""",
hlcTs = hlc
name = item.name,
quantity = item.quantity,
checked = newChecked,
sortOrder = item.sortOrder,
createdAt = item.createdAt,
updatedAt = System.currentTimeMillis(),
deletedAt = item.deletedAt,
hlcTs = hlc,
clientId = item.clientId,
checkedAt = if (newChecked) System.currentTimeMillis() else null
)
)
opLogDao.insert(
OpLogEntity(
listId = item.listId,
clientId = clientId,
clientSeq = nextClientSeq(),
opType = "item_update",
targetId = item.id,
payload = buildPayload { put("checked", newChecked) },
hlcTs = hlc
)
)
}
syncManager.triggerImmediateSync()
}
@ -185,37 +193,43 @@ class ShoppingRepository @Inject constructor(
val item = itemDao.getItem(itemId) ?: return
val hlc = HybridLogicalClock.tick()
itemDao.upsertLww(
id = item.id,
listId = item.listId,
name = item.name,
quantity = item.quantity,
checked = item.checked,
sortOrder = item.sortOrder,
createdAt = item.createdAt,
updatedAt = System.currentTimeMillis(),
deletedAt = System.currentTimeMillis(),
hlcTs = hlc,
clientId = item.clientId,
checkedAt = item.checkedAt
)
val seq = nextClientSeq()
opLogDao.insert(
OpLogEntity(
db.withTransaction {
itemDao.upsertLww(
id = item.id,
listId = item.listId,
clientId = clientId,
clientSeq = seq,
opType = "item_remove",
targetId = item.id,
payload = "{}",
hlcTs = hlc
name = item.name,
quantity = item.quantity,
checked = item.checked,
sortOrder = item.sortOrder,
createdAt = item.createdAt,
updatedAt = System.currentTimeMillis(),
deletedAt = System.currentTimeMillis(),
hlcTs = hlc,
clientId = item.clientId,
checkedAt = item.checkedAt
)
)
opLogDao.insert(
OpLogEntity(
listId = item.listId,
clientId = clientId,
clientSeq = nextClientSeq(),
opType = "item_remove",
targetId = item.id,
payload = "{}",
hlcTs = hlc
)
)
}
syncManager.triggerImmediateSync()
}
private fun escapeJson(str: String): String =
str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
/**
* Builds a JSON object payload using the kotlinx.serialization JSON encoder.
* Avoids the manual string-concat escaping bugs (tab/CR/control chars).
*/
private inline fun buildPayload(builder: JsonObjectBuilder.() -> Unit): String {
val obj: JsonObject = buildJsonObject(builder)
return json.encodeToString(JsonObject.serializer(), obj)
}
}

View file

@ -9,9 +9,6 @@ import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.data.local.dao.ItemDao
import com.example.mitbringsl.data.local.dao.ListDao
import com.example.mitbringsl.data.local.dao.OpLogDao
import com.example.mitbringsl.data.local.entity.ItemEntity
import com.example.mitbringsl.data.local.entity.ListEntity
import com.example.mitbringsl.data.local.entity.OpLogEntity
import com.example.mitbringsl.data.remote.api.MitbringslApi
import com.example.mitbringsl.data.remote.dto.IncomingOpDto
import com.example.mitbringsl.data.remote.dto.PushRequestDto
@ -22,7 +19,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID
@HiltWorker
class SyncWorker @AssistedInject constructor(
@ -42,9 +38,11 @@ class SyncWorker @AssistedInject constructor(
}
return try {
val ownerId = sessionManager.getUserId() ?: return Result.success()
val ownerId = sessionManager.getUserId()
// 1. Fetch Remote Lists to populate initial local list cache
// 1. Fetch Remote Lists to populate / refresh the local list cache.
// Pull ops afterwards for every known list so remote changes on
// "quiet" lists (no local pending ops) still arrive.
val listsResp = api.getLists()
if (listsResp.isSuccessful && listsResp.body() != null) {
listsResp.body()!!.lists.forEach { listDto ->
@ -52,8 +50,10 @@ class SyncWorker @AssistedInject constructor(
id = listDto.id,
name = listDto.name,
ownerId = ownerId,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
// hlcTs is a monotonic logical timestamp; use it for ordering
// so lists do not jump around based on sync time.
createdAt = listDto.hlcTs,
updatedAt = listDto.hlcTs,
deletedAt = null,
hlcTs = listDto.hlcTs
)
@ -93,14 +93,17 @@ class SyncWorker @AssistedInject constructor(
}
}
// 3. Pull Server Ops per List
val userLists = listDao.observeLists(ownerId)
// Pull ops for each list we track
val unsyncedListIds = groupedByList.keys.toSet()
for (listId in unsyncedListIds) {
// 3. Pull Server Ops for EVERY tracked list (not just those with pending
// local ops). This is what makes shared lists receive remote edits.
val allListIds = listDao.getAllListIds(ownerId)
for (listId in allListIds) {
pullOpsForList(listId)
}
// 4. Prune old, already-synced ops to keep the outbox bounded.
val cutoff = System.currentTimeMillis() - SEVEN_DAYS_MS
opLogDao.deleteOldSynced(cutoff)
Result.success()
} catch (e: Exception) {
Log.e(TAG, "Sync failed", e)
@ -126,19 +129,37 @@ class SyncWorker @AssistedInject constructor(
}
private suspend fun applyServerOpProjection(listId: String, op: ServerOpDto) {
// Advance the local HLC with the server's op timestamp so subsequent local
// edits produce HLCs strictly greater than any server op we have seen.
// Without this, a device with a fast clock could permanently win LWW and a
// device with a slow clock could have its own edits silently rejected.
HybridLogicalClock.tick(op.hlcTs)
val payload = op.payload
val name = payload["name"]?.jsonPrimitive?.content ?: ""
val quantity = payload["quantity"]?.jsonPrimitive?.content
val checked = payload["checked"]?.jsonPrimitive?.booleanOrNull ?: false
val ownerId = sessionManager.getUserId()
when (op.opType) {
"list_create" -> {
listDao.upsertLww(
id = op.targetId,
name = name,
ownerId = ownerId,
createdAt = op.hlcTs,
updatedAt = op.hlcTs,
deletedAt = null,
hlcTs = op.hlcTs
)
}
"list_rename" -> {
listDao.upsertLww(
id = op.targetId,
name = name,
ownerId = sessionManager.getUserId() ?: "",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
ownerId = ownerId,
createdAt = op.hlcTs,
updatedAt = op.hlcTs,
deletedAt = null,
hlcTs = op.hlcTs
)
@ -146,11 +167,11 @@ class SyncWorker @AssistedInject constructor(
"list_delete" -> {
listDao.upsertLww(
id = op.targetId,
name = "",
ownerId = sessionManager.getUserId() ?: "",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
deletedAt = System.currentTimeMillis(),
name = name,
ownerId = ownerId,
createdAt = op.hlcTs,
updatedAt = op.hlcTs,
deletedAt = op.hlcTs,
hlcTs = op.hlcTs
)
}
@ -162,8 +183,8 @@ class SyncWorker @AssistedInject constructor(
quantity = quantity,
checked = false,
sortOrder = null,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
createdAt = op.hlcTs,
updatedAt = op.hlcTs,
deletedAt = null,
hlcTs = op.hlcTs,
clientId = op.clientId,
@ -179,12 +200,12 @@ class SyncWorker @AssistedInject constructor(
quantity = quantity ?: existing?.quantity,
checked = checked,
sortOrder = existing?.sortOrder,
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
createdAt = existing?.createdAt ?: op.hlcTs,
updatedAt = op.hlcTs,
deletedAt = existing?.deletedAt,
hlcTs = op.hlcTs,
clientId = existing?.clientId ?: op.clientId,
checkedAt = if (checked) System.currentTimeMillis() else null
checkedAt = if (checked) op.hlcTs else null
)
}
"item_remove" -> {
@ -196,9 +217,9 @@ class SyncWorker @AssistedInject constructor(
quantity = existing?.quantity,
checked = existing?.checked ?: false,
sortOrder = existing?.sortOrder,
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
deletedAt = System.currentTimeMillis(),
createdAt = existing?.createdAt ?: op.hlcTs,
updatedAt = op.hlcTs,
deletedAt = op.hlcTs,
hlcTs = op.hlcTs,
clientId = existing?.clientId,
checkedAt = existing?.checkedAt
@ -218,5 +239,6 @@ class SyncWorker @AssistedInject constructor(
companion object {
const val TAG = "SyncWorker"
const val WORK_NAME = "MitbringslSyncWorker"
private const val SEVEN_DAYS_MS = 7L * 24 * 60 * 60 * 1000
}
}

View file

@ -21,7 +21,10 @@ object DatabaseModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, AppDatabase.DATABASE_NAME)
.fallbackToDestructiveMigration(dropAllTables = false)
// Do NOT use fallbackToDestructiveMigration: in a local-first app the
// Room DB is the source of truth, so silently wiping it on a schema
// mismatch would lose all unsynced data. Provide explicit Migration
// objects when bumping AppDatabase.version instead.
.build()
@Provides fun provideListDao(db: AppDatabase): ListDao = db.listDao()

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
Allow cleartext (http://) only to the local emulator backend and localhost,
so the debug build can reach the Go server running on the host.
All other traffic must use https://.
-->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="true">*.localhost</domain>
</domain-config>
<!--
For self-hosted servers on a LAN (e.g. http://192.168.1.10:8080) the user
explicitly opts into cleartext via this debug rule. Production builds
should use https:// only.
-->
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>