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

5
.gitignore vendored
View file

@ -31,6 +31,11 @@ Thumbs.db
# === Agent / tooling local data === # === Agent / tooling local data ===
.zcode/ .zcode/
backend/.testbin/
# === Windows system files ===
desktop.ini
Thumbs.db
# === Docker === # === Docker ===
deploy/data/ deploy/data/

48
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,48 @@
# ============================================================================
# Mitbringsl ProGuard / R8 rules
# ============================================================================
# --- Kotlinx Serialization ---
# R8 strips the serializer companions resolved via reflection unless kept.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
# Keep the @Serializable companions and their serializers.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.example.mitbringsl.**$$serializer { *; }
-keepclassmembers class com.example.mitbringsl.** {
*** Companion;
}
# --- Retrofit ---
# Retrofit uses reflection to parse annotations and build service interfaces.
-keepattributes Signature, Exceptions
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Do not strip method/parameter annotations on service interfaces.
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>
# --- OkHttp ---
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn org.conscrypt.**
# --- Keep all DTO/model classes used by serialization (paranoia / safety) ---
-keep class com.example.mitbringsl.data.remote.dto.** { *; }
# --- Room (KSP-generated code should be safe, but keep entities) ---
-keep class com.example.mitbringsl.data.local.entity.** { *; }

View file

@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:name=".MitbringslApp" android:name=".MitbringslApp"
@ -10,6 +12,7 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
@ -23,6 +26,22 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </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.LoginRequestDto
import com.example.mitbringsl.data.remote.dto.OidcRequestDto import com.example.mitbringsl.data.remote.dto.OidcRequestDto
import com.example.mitbringsl.data.remote.dto.RegisterRequestDto import com.example.mitbringsl.data.remote.dto.RegisterRequestDto
import com.example.mitbringsl.data.sync.SyncManager
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -16,6 +17,7 @@ sealed interface AuthResult {
class AuthRepository @Inject constructor( class AuthRepository @Inject constructor(
private val api: MitbringslApi, private val api: MitbringslApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val syncManager: SyncManager,
) { ) {
suspend fun login(email: String, pass: String): AuthResult { suspend fun login(email: String, pass: String): AuthResult {
@ -28,6 +30,7 @@ class AuthRepository @Inject constructor(
userId = body.user.id, userId = body.user.id,
email = body.user.email email = body.user.email
) )
onLoggedIn()
AuthResult.Success(userId = body.user.id, email = body.user.email) AuthResult.Success(userId = body.user.id, email = body.user.email)
} else { } else {
val err = response.errorBody()?.string() ?: "Anmeldung fehlgeschlagen" val err = response.errorBody()?.string() ?: "Anmeldung fehlgeschlagen"
@ -50,6 +53,7 @@ class AuthRepository @Inject constructor(
userId = body.user.id, userId = body.user.id,
email = body.user.email email = body.user.email
) )
onLoggedIn()
AuthResult.Success(userId = body.user.id, email = body.user.email) AuthResult.Success(userId = body.user.id, email = body.user.email)
} else { } else {
val err = response.errorBody()?.string() ?: "Registrierung fehlgeschlagen" val err = response.errorBody()?.string() ?: "Registrierung fehlgeschlagen"
@ -70,6 +74,7 @@ class AuthRepository @Inject constructor(
userId = body.user.id, userId = body.user.id,
email = body.user.email email = body.user.email
) )
onLoggedIn()
AuthResult.Success(userId = body.user.id, email = body.user.email) AuthResult.Success(userId = body.user.id, email = body.user.email)
} else { } else {
val err = response.errorBody()?.string() ?: "OIDC Login fehlgeschlagen" 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 { private fun parseErrorMessage(errorBody: String, code: Int): String {
return when (code) { return when (code) {
401 -> "E-Mail oder Passwort falsch." 401 -> "E-Mail oder Passwort falsch."

View file

@ -58,6 +58,36 @@ class SessionManager @Inject constructor(
return localId 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 getUserEmail(): String? = prefs.getString("user_email", null)
fun saveSession(token: String, userId: String, email: String) { 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") @Query("SELECT COUNT(*) FROM lists WHERE ownerId = :ownerId AND deletedAt IS NULL")
suspend fun count(ownerId: String): Int 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 package com.example.mitbringsl.data.repository
import androidx.room.withTransaction
import com.example.mitbringsl.data.auth.SessionManager 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.ItemDao
import com.example.mitbringsl.data.local.dao.ListDao import com.example.mitbringsl.data.local.dao.ListDao
import com.example.mitbringsl.data.local.dao.OpLogDao 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.HybridLogicalClock
import com.example.mitbringsl.data.sync.SyncManager import com.example.mitbringsl.data.sync.SyncManager
import kotlinx.coroutines.flow.Flow 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 java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ShoppingRepository @Inject constructor( class ShoppingRepository @Inject constructor(
private val db: AppDatabase,
private val listDao: ListDao, private val listDao: ListDao,
private val itemDao: ItemDao, private val itemDao: ItemDao,
private val opLogDao: OpLogDao, private val opLogDao: OpLogDao,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val json: Json,
) { ) {
/** Device Installation Client ID */ /** Stable, persisted device installation id (see SessionManager.getDeviceClientId). */
private val clientId = UUID.randomUUID().toString() private val clientId: String get() = sessionManager.getDeviceClientId()
private var clientSeqCounter = System.currentTimeMillis()
private fun nextClientSeq(): Long = synchronized(this) { ++clientSeqCounter } private fun nextClientSeq(): Long = sessionManager.nextClientSeq()
// --- Lists --- // --- Lists ---
fun observeLists(): Flow<List<ListEntity>> { fun observeLists(): Flow<List<ListEntity>> {
val ownerId = sessionManager.getUserId() ?: "" val ownerId = sessionManager.getUserId()
return listDao.observeLists(ownerId) return listDao.observeLists(ownerId)
} }
suspend fun createList(name: String): ListEntity { suspend fun createList(name: String): ListEntity {
val ownerId = sessionManager.getUserId() ?: "" val ownerId = sessionManager.getUserId()
val hlc = HybridLogicalClock.tick() val hlc = HybridLogicalClock.tick()
val listId = UUID.randomUUID().toString() val listId = UUID.randomUUID().toString()
@ -49,24 +57,24 @@ class ShoppingRepository @Inject constructor(
hlcTs = hlc hlcTs = hlc
) )
// 1. Write to Room local projection // Atomic: write the local projection AND enqueue the outbox op together.
listDao.upsert(list) // If the process is killed in between, a non-transactional split would
// leave the local change without a matching op → silent sync loss.
// 2. Append to OpLog outbox db.withTransaction {
val seq = nextClientSeq() listDao.upsert(list)
opLogDao.insert( opLogDao.insert(
OpLogEntity( OpLogEntity(
listId = listId, listId = listId,
clientId = clientId, clientId = clientId,
clientSeq = seq, clientSeq = nextClientSeq(),
opType = "list_create", opType = "list_create",
targetId = listId, targetId = listId,
payload = """{"name":"${escapeJson(name)}"}""", payload = buildPayload { put("name", name) },
hlcTs = hlc hlcTs = hlc
)
) )
) }
// 3. Trigger immediate background sync
syncManager.triggerImmediateSync() syncManager.triggerImmediateSync()
return list return list
} }
@ -75,28 +83,28 @@ class ShoppingRepository @Inject constructor(
val list = listDao.getList(listId) ?: return val list = listDao.getList(listId) ?: return
val hlc = HybridLogicalClock.tick() val hlc = HybridLogicalClock.tick()
listDao.upsertLww( db.withTransaction {
id = listId, listDao.upsertLww(
name = list.name, id = listId,
ownerId = list.ownerId, name = list.name,
createdAt = list.createdAt, ownerId = list.ownerId,
updatedAt = System.currentTimeMillis(), createdAt = list.createdAt,
deletedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
hlcTs = hlc deletedAt = System.currentTimeMillis(),
)
val seq = nextClientSeq()
opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = seq,
opType = "list_delete",
targetId = listId,
payload = "{}",
hlcTs = hlc hlcTs = hlc
) )
) opLogDao.insert(
OpLogEntity(
listId = listId,
clientId = clientId,
clientSeq = nextClientSeq(),
opType = "list_delete",
targetId = listId,
payload = "{}",
hlcTs = hlc
)
)
}
syncManager.triggerImmediateSync() syncManager.triggerImmediateSync()
} }
@ -123,23 +131,23 @@ class ShoppingRepository @Inject constructor(
clientId = clientId clientId = clientId
) )
// 1. Write to Room db.withTransaction {
itemDao.upsert(item) itemDao.upsert(item)
opLogDao.insert(
// 2. Append to OpLog OpLogEntity(
val seq = nextClientSeq() listId = listId,
val qtyJson = if (quantity.isNullOrBlank()) "" else """, "quantity":"${escapeJson(quantity)}"""" clientId = clientId,
opLogDao.insert( clientSeq = nextClientSeq(),
OpLogEntity( opType = "item_add",
listId = listId, targetId = itemId,
clientId = clientId, payload = buildPayload {
clientSeq = seq, put("name", name)
opType = "item_add", if (!quantity.isNullOrBlank()) put("quantity", quantity)
targetId = itemId, },
payload = """{"name":"${escapeJson(name)}"$qtyJson}""", hlcTs = hlc
hlcTs = hlc )
) )
) }
syncManager.triggerImmediateSync() syncManager.triggerImmediateSync()
return item return item
@ -150,33 +158,33 @@ class ShoppingRepository @Inject constructor(
val newChecked = !item.checked val newChecked = !item.checked
val hlc = HybridLogicalClock.tick() val hlc = HybridLogicalClock.tick()
itemDao.upsertLww( db.withTransaction {
id = item.id, itemDao.upsertLww(
listId = item.listId, id = item.id,
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(
listId = item.listId, listId = item.listId,
clientId = clientId, name = item.name,
clientSeq = seq, quantity = item.quantity,
opType = "item_update", checked = newChecked,
targetId = item.id, sortOrder = item.sortOrder,
payload = """{"checked":$newChecked}""", createdAt = item.createdAt,
hlcTs = hlc 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() syncManager.triggerImmediateSync()
} }
@ -185,37 +193,43 @@ class ShoppingRepository @Inject constructor(
val item = itemDao.getItem(itemId) ?: return val item = itemDao.getItem(itemId) ?: return
val hlc = HybridLogicalClock.tick() val hlc = HybridLogicalClock.tick()
itemDao.upsertLww( db.withTransaction {
id = item.id, itemDao.upsertLww(
listId = item.listId, id = item.id,
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(
listId = item.listId, listId = item.listId,
clientId = clientId, name = item.name,
clientSeq = seq, quantity = item.quantity,
opType = "item_remove", checked = item.checked,
targetId = item.id, sortOrder = item.sortOrder,
payload = "{}", createdAt = item.createdAt,
hlcTs = hlc 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() 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.ItemDao
import com.example.mitbringsl.data.local.dao.ListDao import com.example.mitbringsl.data.local.dao.ListDao
import com.example.mitbringsl.data.local.dao.OpLogDao 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.api.MitbringslApi
import com.example.mitbringsl.data.remote.dto.IncomingOpDto import com.example.mitbringsl.data.remote.dto.IncomingOpDto
import com.example.mitbringsl.data.remote.dto.PushRequestDto 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.JsonObject
import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID
@HiltWorker @HiltWorker
class SyncWorker @AssistedInject constructor( class SyncWorker @AssistedInject constructor(
@ -42,9 +38,11 @@ class SyncWorker @AssistedInject constructor(
} }
return try { 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() val listsResp = api.getLists()
if (listsResp.isSuccessful && listsResp.body() != null) { if (listsResp.isSuccessful && listsResp.body() != null) {
listsResp.body()!!.lists.forEach { listDto -> listsResp.body()!!.lists.forEach { listDto ->
@ -52,8 +50,10 @@ class SyncWorker @AssistedInject constructor(
id = listDto.id, id = listDto.id,
name = listDto.name, name = listDto.name,
ownerId = ownerId, ownerId = ownerId,
createdAt = System.currentTimeMillis(), // hlcTs is a monotonic logical timestamp; use it for ordering
updatedAt = System.currentTimeMillis(), // so lists do not jump around based on sync time.
createdAt = listDto.hlcTs,
updatedAt = listDto.hlcTs,
deletedAt = null, deletedAt = null,
hlcTs = listDto.hlcTs hlcTs = listDto.hlcTs
) )
@ -93,14 +93,17 @@ class SyncWorker @AssistedInject constructor(
} }
} }
// 3. Pull Server Ops per List // 3. Pull Server Ops for EVERY tracked list (not just those with pending
val userLists = listDao.observeLists(ownerId) // local ops). This is what makes shared lists receive remote edits.
// Pull ops for each list we track val allListIds = listDao.getAllListIds(ownerId)
val unsyncedListIds = groupedByList.keys.toSet() for (listId in allListIds) {
for (listId in unsyncedListIds) {
pullOpsForList(listId) 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() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Sync failed", e) Log.e(TAG, "Sync failed", e)
@ -126,19 +129,37 @@ class SyncWorker @AssistedInject constructor(
} }
private suspend fun applyServerOpProjection(listId: String, op: ServerOpDto) { 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 payload = op.payload
val name = payload["name"]?.jsonPrimitive?.content ?: "" val name = payload["name"]?.jsonPrimitive?.content ?: ""
val quantity = payload["quantity"]?.jsonPrimitive?.content val quantity = payload["quantity"]?.jsonPrimitive?.content
val checked = payload["checked"]?.jsonPrimitive?.booleanOrNull ?: false val checked = payload["checked"]?.jsonPrimitive?.booleanOrNull ?: false
val ownerId = sessionManager.getUserId()
when (op.opType) { 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" -> { "list_rename" -> {
listDao.upsertLww( listDao.upsertLww(
id = op.targetId, id = op.targetId,
name = name, name = name,
ownerId = sessionManager.getUserId() ?: "", ownerId = ownerId,
createdAt = System.currentTimeMillis(), createdAt = op.hlcTs,
updatedAt = System.currentTimeMillis(), updatedAt = op.hlcTs,
deletedAt = null, deletedAt = null,
hlcTs = op.hlcTs hlcTs = op.hlcTs
) )
@ -146,11 +167,11 @@ class SyncWorker @AssistedInject constructor(
"list_delete" -> { "list_delete" -> {
listDao.upsertLww( listDao.upsertLww(
id = op.targetId, id = op.targetId,
name = "", name = name,
ownerId = sessionManager.getUserId() ?: "", ownerId = ownerId,
createdAt = System.currentTimeMillis(), createdAt = op.hlcTs,
updatedAt = System.currentTimeMillis(), updatedAt = op.hlcTs,
deletedAt = System.currentTimeMillis(), deletedAt = op.hlcTs,
hlcTs = op.hlcTs hlcTs = op.hlcTs
) )
} }
@ -162,8 +183,8 @@ class SyncWorker @AssistedInject constructor(
quantity = quantity, quantity = quantity,
checked = false, checked = false,
sortOrder = null, sortOrder = null,
createdAt = System.currentTimeMillis(), createdAt = op.hlcTs,
updatedAt = System.currentTimeMillis(), updatedAt = op.hlcTs,
deletedAt = null, deletedAt = null,
hlcTs = op.hlcTs, hlcTs = op.hlcTs,
clientId = op.clientId, clientId = op.clientId,
@ -179,12 +200,12 @@ class SyncWorker @AssistedInject constructor(
quantity = quantity ?: existing?.quantity, quantity = quantity ?: existing?.quantity,
checked = checked, checked = checked,
sortOrder = existing?.sortOrder, sortOrder = existing?.sortOrder,
createdAt = existing?.createdAt ?: System.currentTimeMillis(), createdAt = existing?.createdAt ?: op.hlcTs,
updatedAt = System.currentTimeMillis(), updatedAt = op.hlcTs,
deletedAt = existing?.deletedAt, deletedAt = existing?.deletedAt,
hlcTs = op.hlcTs, hlcTs = op.hlcTs,
clientId = existing?.clientId ?: op.clientId, clientId = existing?.clientId ?: op.clientId,
checkedAt = if (checked) System.currentTimeMillis() else null checkedAt = if (checked) op.hlcTs else null
) )
} }
"item_remove" -> { "item_remove" -> {
@ -196,9 +217,9 @@ class SyncWorker @AssistedInject constructor(
quantity = existing?.quantity, quantity = existing?.quantity,
checked = existing?.checked ?: false, checked = existing?.checked ?: false,
sortOrder = existing?.sortOrder, sortOrder = existing?.sortOrder,
createdAt = existing?.createdAt ?: System.currentTimeMillis(), createdAt = existing?.createdAt ?: op.hlcTs,
updatedAt = System.currentTimeMillis(), updatedAt = op.hlcTs,
deletedAt = System.currentTimeMillis(), deletedAt = op.hlcTs,
hlcTs = op.hlcTs, hlcTs = op.hlcTs,
clientId = existing?.clientId, clientId = existing?.clientId,
checkedAt = existing?.checkedAt checkedAt = existing?.checkedAt
@ -218,5 +239,6 @@ class SyncWorker @AssistedInject constructor(
companion object { companion object {
const val TAG = "SyncWorker" const val TAG = "SyncWorker"
const val WORK_NAME = "MitbringslSyncWorker" 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 @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase = fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, AppDatabase.DATABASE_NAME) 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() .build()
@Provides fun provideListDao(db: AppDatabase): ListDao = db.listDao() @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>