diff --git a/.gitignore b/.gitignore index 7b67f16..4587acc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,11 @@ Thumbs.db # === Agent / tooling local data === .zcode/ +backend/.testbin/ + +# === Windows system files === +desktop.ini +Thumbs.db # === Docker === deploy/data/ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..0c778fd --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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.* ; +} +-if interface * { @retrofit2.http.* ; } +-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.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4282944..4d851d9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + + + + + + + diff --git a/android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt b/android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt index 4058f6e..5431ece 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/auth/AuthRepository.kt @@ -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." diff --git a/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt index 1a58144..b61e6ed 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/auth/SessionManager.kt @@ -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) { diff --git a/android/app/src/main/java/com/example/mitbringsl/data/local/dao/ListDao.kt b/android/app/src/main/java/com/example/mitbringsl/data/local/dao/ListDao.kt index e1d54ef..8f0b1c0 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/local/dao/ListDao.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/local/dao/ListDao.kt @@ -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 } diff --git a/android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt b/android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt index 3fc668d..267fd0a 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/repository/ShoppingRepository.kt @@ -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> { - val ownerId = sessionManager.getUserId() ?: "" + val ownerId = sessionManager.getUserId() return listDao.observeLists(ownerId) } suspend fun createList(name: String): ListEntity { - val ownerId = sessionManager.getUserId() ?: "" + val ownerId = sessionManager.getUserId() val hlc = HybridLogicalClock.tick() val listId = UUID.randomUUID().toString() @@ -49,24 +57,24 @@ class ShoppingRepository @Inject constructor( hlcTs = hlc ) - // 1. Write to Room local projection - listDao.upsert(list) - - // 2. Append to OpLog outbox - val seq = nextClientSeq() - opLogDao.insert( - OpLogEntity( - listId = listId, - clientId = clientId, - clientSeq = seq, - opType = "list_create", - targetId = listId, - payload = """{"name":"${escapeJson(name)}"}""", - hlcTs = hlc + // Atomic: write the local projection AND enqueue the outbox op together. + // If the process is killed in between, a non-transactional split would + // leave the local change without a matching op → silent sync loss. + db.withTransaction { + listDao.upsert(list) + opLogDao.insert( + OpLogEntity( + listId = listId, + clientId = clientId, + clientSeq = nextClientSeq(), + opType = "list_create", + targetId = listId, + payload = buildPayload { put("name", name) }, + hlcTs = hlc + ) ) - ) + } - // 3. Trigger immediate background sync syncManager.triggerImmediateSync() return list } @@ -75,28 +83,28 @@ class ShoppingRepository @Inject constructor( val list = listDao.getList(listId) ?: return val hlc = HybridLogicalClock.tick() - listDao.upsertLww( - id = listId, - name = list.name, - ownerId = list.ownerId, - createdAt = list.createdAt, - updatedAt = System.currentTimeMillis(), - deletedAt = System.currentTimeMillis(), - hlcTs = hlc - ) - - val seq = nextClientSeq() - opLogDao.insert( - OpLogEntity( - listId = listId, - clientId = clientId, - clientSeq = seq, - opType = "list_delete", - targetId = listId, - payload = "{}", + db.withTransaction { + listDao.upsertLww( + id = listId, + name = list.name, + ownerId = list.ownerId, + createdAt = list.createdAt, + updatedAt = System.currentTimeMillis(), + deletedAt = System.currentTimeMillis(), hlcTs = hlc ) - ) + opLogDao.insert( + OpLogEntity( + listId = listId, + clientId = clientId, + clientSeq = nextClientSeq(), + opType = "list_delete", + targetId = listId, + payload = "{}", + hlcTs = hlc + ) + ) + } syncManager.triggerImmediateSync() } @@ -123,23 +131,23 @@ class ShoppingRepository @Inject constructor( clientId = clientId ) - // 1. Write to Room - itemDao.upsert(item) - - // 2. Append to OpLog - val seq = nextClientSeq() - val qtyJson = if (quantity.isNullOrBlank()) "" else """, "quantity":"${escapeJson(quantity)}"""" - opLogDao.insert( - OpLogEntity( - listId = listId, - clientId = clientId, - clientSeq = seq, - opType = "item_add", - targetId = itemId, - payload = """{"name":"${escapeJson(name)}"$qtyJson}""", - hlcTs = hlc + db.withTransaction { + itemDao.upsert(item) + opLogDao.insert( + OpLogEntity( + listId = listId, + clientId = clientId, + clientSeq = nextClientSeq(), + opType = "item_add", + targetId = itemId, + payload = buildPayload { + put("name", name) + if (!quantity.isNullOrBlank()) put("quantity", quantity) + }, + hlcTs = hlc + ) ) - ) + } syncManager.triggerImmediateSync() return item @@ -150,33 +158,33 @@ class ShoppingRepository @Inject constructor( val newChecked = !item.checked val hlc = HybridLogicalClock.tick() - itemDao.upsertLww( - id = item.id, - listId = item.listId, - name = item.name, - quantity = item.quantity, - checked = newChecked, - sortOrder = item.sortOrder, - createdAt = item.createdAt, - updatedAt = System.currentTimeMillis(), - deletedAt = item.deletedAt, - hlcTs = hlc, - clientId = item.clientId, - checkedAt = if (newChecked) System.currentTimeMillis() else null - ) - - val seq = nextClientSeq() - opLogDao.insert( - OpLogEntity( + db.withTransaction { + itemDao.upsertLww( + id = item.id, listId = item.listId, - clientId = clientId, - clientSeq = seq, - opType = "item_update", - targetId = item.id, - payload = """{"checked":$newChecked}""", - hlcTs = hlc + name = item.name, + quantity = item.quantity, + checked = newChecked, + sortOrder = item.sortOrder, + createdAt = item.createdAt, + updatedAt = System.currentTimeMillis(), + deletedAt = item.deletedAt, + hlcTs = hlc, + clientId = item.clientId, + checkedAt = if (newChecked) System.currentTimeMillis() else null ) - ) + opLogDao.insert( + OpLogEntity( + listId = item.listId, + clientId = clientId, + clientSeq = nextClientSeq(), + opType = "item_update", + targetId = item.id, + payload = buildPayload { put("checked", newChecked) }, + hlcTs = hlc + ) + ) + } syncManager.triggerImmediateSync() } @@ -185,37 +193,43 @@ class ShoppingRepository @Inject constructor( val item = itemDao.getItem(itemId) ?: return val hlc = HybridLogicalClock.tick() - itemDao.upsertLww( - id = item.id, - listId = item.listId, - name = item.name, - quantity = item.quantity, - checked = item.checked, - sortOrder = item.sortOrder, - createdAt = item.createdAt, - updatedAt = System.currentTimeMillis(), - deletedAt = System.currentTimeMillis(), - hlcTs = hlc, - clientId = item.clientId, - checkedAt = item.checkedAt - ) - - val seq = nextClientSeq() - opLogDao.insert( - OpLogEntity( + db.withTransaction { + itemDao.upsertLww( + id = item.id, listId = item.listId, - clientId = clientId, - clientSeq = seq, - opType = "item_remove", - targetId = item.id, - payload = "{}", - hlcTs = hlc + name = item.name, + quantity = item.quantity, + checked = item.checked, + sortOrder = item.sortOrder, + createdAt = item.createdAt, + updatedAt = System.currentTimeMillis(), + deletedAt = System.currentTimeMillis(), + hlcTs = hlc, + clientId = item.clientId, + checkedAt = item.checkedAt ) - ) + opLogDao.insert( + OpLogEntity( + listId = item.listId, + clientId = clientId, + clientSeq = nextClientSeq(), + opType = "item_remove", + targetId = item.id, + payload = "{}", + hlcTs = hlc + ) + ) + } syncManager.triggerImmediateSync() } - private fun escapeJson(str: String): String = - str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + /** + * Builds a JSON object payload using the kotlinx.serialization JSON encoder. + * Avoids the manual string-concat escaping bugs (tab/CR/control chars). + */ + private inline fun buildPayload(builder: JsonObjectBuilder.() -> Unit): String { + val obj: JsonObject = buildJsonObject(builder) + return json.encodeToString(JsonObject.serializer(), obj) + } } diff --git a/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt b/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt index 48e9983..bd1f50d 100644 --- a/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt +++ b/android/app/src/main/java/com/example/mitbringsl/data/sync/SyncWorker.kt @@ -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 } } diff --git a/android/app/src/main/java/com/example/mitbringsl/di/DatabaseModule.kt b/android/app/src/main/java/com/example/mitbringsl/di/DatabaseModule.kt index c8c3957..df67aa7 100644 --- a/android/app/src/main/java/com/example/mitbringsl/di/DatabaseModule.kt +++ b/android/app/src/main/java/com/example/mitbringsl/di/DatabaseModule.kt @@ -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() diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..1f468e9 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,25 @@ + + + + + 10.0.2.2 + localhost + *.localhost + + + + + + + + + +