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:
parent
97583340a4
commit
b44bc8c3af
10 changed files with 326 additions and 141 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -31,6 +31,11 @@ Thumbs.db
|
|||
|
||||
# === Agent / tooling local data ===
|
||||
.zcode/
|
||||
backend/.testbin/
|
||||
|
||||
# === Windows system files ===
|
||||
desktop.ini
|
||||
Thumbs.db
|
||||
|
||||
# === Docker ===
|
||||
deploy/data/
|
||||
|
|
|
|||
48
android/app/proguard-rules.pro
vendored
Normal file
48
android/app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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)
|
||||
|
||||
// 2. Append to OpLog outbox
|
||||
val seq = nextClientSeq()
|
||||
opLogDao.insert(
|
||||
OpLogEntity(
|
||||
listId = listId,
|
||||
clientId = clientId,
|
||||
clientSeq = seq,
|
||||
clientSeq = nextClientSeq(),
|
||||
opType = "list_create",
|
||||
targetId = listId,
|
||||
payload = """{"name":"${escapeJson(name)}"}""",
|
||||
payload = buildPayload { put("name", name) },
|
||||
hlcTs = hlc
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Trigger immediate background sync
|
||||
syncManager.triggerImmediateSync()
|
||||
return list
|
||||
}
|
||||
|
|
@ -75,6 +83,7 @@ class ShoppingRepository @Inject constructor(
|
|||
val list = listDao.getList(listId) ?: return
|
||||
val hlc = HybridLogicalClock.tick()
|
||||
|
||||
db.withTransaction {
|
||||
listDao.upsertLww(
|
||||
id = listId,
|
||||
name = list.name,
|
||||
|
|
@ -84,19 +93,18 @@ class ShoppingRepository @Inject constructor(
|
|||
deletedAt = System.currentTimeMillis(),
|
||||
hlcTs = hlc
|
||||
)
|
||||
|
||||
val seq = nextClientSeq()
|
||||
opLogDao.insert(
|
||||
OpLogEntity(
|
||||
listId = listId,
|
||||
clientId = clientId,
|
||||
clientSeq = seq,
|
||||
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
|
||||
db.withTransaction {
|
||||
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,
|
||||
clientSeq = nextClientSeq(),
|
||||
opType = "item_add",
|
||||
targetId = itemId,
|
||||
payload = """{"name":"${escapeJson(name)}"$qtyJson}""",
|
||||
payload = buildPayload {
|
||||
put("name", name)
|
||||
if (!quantity.isNullOrBlank()) put("quantity", quantity)
|
||||
},
|
||||
hlcTs = hlc
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
syncManager.triggerImmediateSync()
|
||||
return item
|
||||
|
|
@ -150,6 +158,7 @@ class ShoppingRepository @Inject constructor(
|
|||
val newChecked = !item.checked
|
||||
val hlc = HybridLogicalClock.tick()
|
||||
|
||||
db.withTransaction {
|
||||
itemDao.upsertLww(
|
||||
id = item.id,
|
||||
listId = item.listId,
|
||||
|
|
@ -164,19 +173,18 @@ class ShoppingRepository @Inject constructor(
|
|||
clientId = item.clientId,
|
||||
checkedAt = if (newChecked) System.currentTimeMillis() else null
|
||||
)
|
||||
|
||||
val seq = nextClientSeq()
|
||||
opLogDao.insert(
|
||||
OpLogEntity(
|
||||
listId = item.listId,
|
||||
clientId = clientId,
|
||||
clientSeq = seq,
|
||||
clientSeq = nextClientSeq(),
|
||||
opType = "item_update",
|
||||
targetId = item.id,
|
||||
payload = """{"checked":$newChecked}""",
|
||||
payload = buildPayload { put("checked", newChecked) },
|
||||
hlcTs = hlc
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
syncManager.triggerImmediateSync()
|
||||
}
|
||||
|
|
@ -185,6 +193,7 @@ class ShoppingRepository @Inject constructor(
|
|||
val item = itemDao.getItem(itemId) ?: return
|
||||
val hlc = HybridLogicalClock.tick()
|
||||
|
||||
db.withTransaction {
|
||||
itemDao.upsertLww(
|
||||
id = item.id,
|
||||
listId = item.listId,
|
||||
|
|
@ -199,23 +208,28 @@ class ShoppingRepository @Inject constructor(
|
|||
clientId = item.clientId,
|
||||
checkedAt = item.checkedAt
|
||||
)
|
||||
|
||||
val seq = nextClientSeq()
|
||||
opLogDao.insert(
|
||||
OpLogEntity(
|
||||
listId = item.listId,
|
||||
clientId = clientId,
|
||||
clientSeq = seq,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
25
android/app/src/main/res/xml/network_security_config.xml
Normal file
25
android/app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue