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 ===
|
# === 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
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"?>
|
<?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>
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
// 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)
|
listDao.upsert(list)
|
||||||
|
|
||||||
// 2. Append to OpLog outbox
|
|
||||||
val seq = nextClientSeq()
|
|
||||||
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,6 +83,7 @@ class ShoppingRepository @Inject constructor(
|
||||||
val list = listDao.getList(listId) ?: return
|
val list = listDao.getList(listId) ?: return
|
||||||
val hlc = HybridLogicalClock.tick()
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
|
||||||
|
db.withTransaction {
|
||||||
listDao.upsertLww(
|
listDao.upsertLww(
|
||||||
id = listId,
|
id = listId,
|
||||||
name = list.name,
|
name = list.name,
|
||||||
|
|
@ -84,19 +93,18 @@ class ShoppingRepository @Inject constructor(
|
||||||
deletedAt = System.currentTimeMillis(),
|
deletedAt = System.currentTimeMillis(),
|
||||||
hlcTs = hlc
|
hlcTs = hlc
|
||||||
)
|
)
|
||||||
|
|
||||||
val seq = nextClientSeq()
|
|
||||||
opLogDao.insert(
|
opLogDao.insert(
|
||||||
OpLogEntity(
|
OpLogEntity(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
clientSeq = seq,
|
clientSeq = nextClientSeq(),
|
||||||
opType = "list_delete",
|
opType = "list_delete",
|
||||||
targetId = listId,
|
targetId = listId,
|
||||||
payload = "{}",
|
payload = "{}",
|
||||||
hlcTs = hlc
|
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)
|
||||||
|
|
||||||
// 2. Append to OpLog
|
|
||||||
val seq = nextClientSeq()
|
|
||||||
val qtyJson = if (quantity.isNullOrBlank()) "" else """, "quantity":"${escapeJson(quantity)}""""
|
|
||||||
opLogDao.insert(
|
opLogDao.insert(
|
||||||
OpLogEntity(
|
OpLogEntity(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
clientSeq = seq,
|
clientSeq = nextClientSeq(),
|
||||||
opType = "item_add",
|
opType = "item_add",
|
||||||
targetId = itemId,
|
targetId = itemId,
|
||||||
payload = """{"name":"${escapeJson(name)}"$qtyJson}""",
|
payload = buildPayload {
|
||||||
|
put("name", name)
|
||||||
|
if (!quantity.isNullOrBlank()) put("quantity", quantity)
|
||||||
|
},
|
||||||
hlcTs = hlc
|
hlcTs = hlc
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
syncManager.triggerImmediateSync()
|
syncManager.triggerImmediateSync()
|
||||||
return item
|
return item
|
||||||
|
|
@ -150,6 +158,7 @@ class ShoppingRepository @Inject constructor(
|
||||||
val newChecked = !item.checked
|
val newChecked = !item.checked
|
||||||
val hlc = HybridLogicalClock.tick()
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
|
||||||
|
db.withTransaction {
|
||||||
itemDao.upsertLww(
|
itemDao.upsertLww(
|
||||||
id = item.id,
|
id = item.id,
|
||||||
listId = item.listId,
|
listId = item.listId,
|
||||||
|
|
@ -164,19 +173,18 @@ class ShoppingRepository @Inject constructor(
|
||||||
clientId = item.clientId,
|
clientId = item.clientId,
|
||||||
checkedAt = if (newChecked) System.currentTimeMillis() else null
|
checkedAt = if (newChecked) System.currentTimeMillis() else null
|
||||||
)
|
)
|
||||||
|
|
||||||
val seq = nextClientSeq()
|
|
||||||
opLogDao.insert(
|
opLogDao.insert(
|
||||||
OpLogEntity(
|
OpLogEntity(
|
||||||
listId = item.listId,
|
listId = item.listId,
|
||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
clientSeq = seq,
|
clientSeq = nextClientSeq(),
|
||||||
opType = "item_update",
|
opType = "item_update",
|
||||||
targetId = item.id,
|
targetId = item.id,
|
||||||
payload = """{"checked":$newChecked}""",
|
payload = buildPayload { put("checked", newChecked) },
|
||||||
hlcTs = hlc
|
hlcTs = hlc
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
syncManager.triggerImmediateSync()
|
syncManager.triggerImmediateSync()
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +193,7 @@ class ShoppingRepository @Inject constructor(
|
||||||
val item = itemDao.getItem(itemId) ?: return
|
val item = itemDao.getItem(itemId) ?: return
|
||||||
val hlc = HybridLogicalClock.tick()
|
val hlc = HybridLogicalClock.tick()
|
||||||
|
|
||||||
|
db.withTransaction {
|
||||||
itemDao.upsertLww(
|
itemDao.upsertLww(
|
||||||
id = item.id,
|
id = item.id,
|
||||||
listId = item.listId,
|
listId = item.listId,
|
||||||
|
|
@ -199,23 +208,28 @@ class ShoppingRepository @Inject constructor(
|
||||||
clientId = item.clientId,
|
clientId = item.clientId,
|
||||||
checkedAt = item.checkedAt
|
checkedAt = item.checkedAt
|
||||||
)
|
)
|
||||||
|
|
||||||
val seq = nextClientSeq()
|
|
||||||
opLogDao.insert(
|
opLogDao.insert(
|
||||||
OpLogEntity(
|
OpLogEntity(
|
||||||
listId = item.listId,
|
listId = item.listId,
|
||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
clientSeq = seq,
|
clientSeq = nextClientSeq(),
|
||||||
opType = "item_remove",
|
opType = "item_remove",
|
||||||
targetId = item.id,
|
targetId = item.id,
|
||||||
payload = "{}",
|
payload = "{}",
|
||||||
hlcTs = hlc
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
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