diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index ad83b451..0ce6bee4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.nuvio.app.core.auth.AuthStorage import com.nuvio.app.core.deeplink.handleAppUrl import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner +import com.nuvio.app.core.sync.SyncClientIdentityStorage import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionMobileSettingsStorage import com.nuvio.app.features.collection.CollectionStorage @@ -66,6 +67,7 @@ class MainActivity : AppCompatActivity() { ThemeSettingsStorage.initialize(applicationContext) super.onCreate(savedInstanceState) window.setBackgroundDrawableResource(R.color.nuvio_background) + SyncClientIdentityStorage.initialize(applicationContext) AddonStorage.initialize(applicationContext) AuthStorage.initialize(applicationContext) LibraryStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.android.kt new file mode 100644 index 00000000..6dae30d5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.android.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.core.sync + +import android.content.Context +import android.content.SharedPreferences + +actual object SyncClientIdentityStorage { + private const val preferencesName = "nuvio_sync_client_identity" + private const val clientIdKey = "client_instance_id" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadClientId(): String? = + preferences?.getString(clientIdKey, null) + + actual fun saveClientId(clientId: String) { + preferences + ?.edit() + ?.putString(clientIdKey, clientId) + ?.apply() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 26616f91..083a4027 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -206,6 +206,7 @@ object ProfileSettingsSync { put("p_profile_id", profileId) put("p_platform", MOBILE_SYNC_PLATFORM) put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params) log.d { "pushToRemoteLocked(profileId=$profileId) — success" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/RealtimeSyncInvalidationService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/RealtimeSyncInvalidationService.kt index 1bad1c4d..145c30c0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/RealtimeSyncInvalidationService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/RealtimeSyncInvalidationService.kt @@ -135,13 +135,19 @@ object RealtimeSyncInvalidationService { private fun handleInsert(profileId: Int, record: JsonObject) { val eventId = record["id"]?.jsonPrimitive?.contentOrNull val createdAt = record["created_at"]?.jsonPrimitive?.contentOrNull + val originClientId = record["origin_client_id"]?.jsonPrimitive?.contentOrNull + if (originClientId != null && originClientId == SyncClientIdentity.currentClientId()) { + log.d { "Ignoring self-originated sync invalidation id=$eventId originClientId=$originClientId" } + return + } val surface = record["surface"]?.jsonPrimitive?.contentOrNull ?: run { log.w { "Received sync invalidation without surface id=$eventId createdAt=$createdAt keys=${record.keys}" } return } val eventProfileId = record["profile_id"]?.jsonPrimitive?.intOrNull log.i { - "Received sync invalidation id=$eventId surface=$surface eventProfile=$eventProfileId activeProfile=$profileId createdAt=$createdAt" + "Received sync invalidation id=$eventId surface=$surface eventProfile=$eventProfileId " + + "activeProfile=$profileId originClientId=$originClientId createdAt=$createdAt" } if (surface != "profiles" && eventProfileId != null && eventProfileId != profileId) { log.d { "Ignoring sync invalidation id=$eventId for inactive profile $eventProfileId" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentity.kt new file mode 100644 index 00000000..c116f88b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentity.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.core.sync + +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.put +import kotlin.random.Random + +private const val CLIENT_ID_LENGTH = 32 +private const val CLIENT_ID_PREFIX = "nuvio-mobile-" +private const val ORIGIN_CLIENT_ID_PARAM = "p_origin_client_id" +private const val CLIENT_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" + +object SyncClientIdentity { + private var cachedClientId: String? = null + + fun currentClientId(): String { + cachedClientId?.let { return it } + + val stored = SyncClientIdentityStorage.loadClientId() + ?.trim() + ?.takeIf { it.isValidSyncClientId() } + if (stored != null) { + cachedClientId = stored + return stored + } + + val generated = generateClientId() + SyncClientIdentityStorage.saveClientId(generated) + cachedClientId = generated + return generated + } + + private fun generateClientId(): String = + CLIENT_ID_PREFIX + buildString(CLIENT_ID_LENGTH) { + repeat(CLIENT_ID_LENGTH) { + append(CLIENT_ID_ALPHABET[Random.nextInt(CLIENT_ID_ALPHABET.length)]) + } + } + + private fun String.isValidSyncClientId(): Boolean = + length in 16..96 && all { it.isLetterOrDigit() || it == '-' || it == '_' } +} + +internal fun JsonObjectBuilder.putSyncOriginClientId() { + put(ORIGIN_CLIENT_ID_PARAM, SyncClientIdentity.currentClientId()) +} + +internal expect object SyncClientIdentityStorage { + fun loadClientId(): String? + fun saveClientId(clientId: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index f430f3ed..42d2875c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.addons import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.query.Order @@ -151,6 +152,7 @@ object AddonRepository { val params = buildJsonObject { put("p_profile_id", currentProfileId) put("p_addons", json.encodeToJsonElement(addons)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) log.i { "pullFromServer() — migration push done (${addons.size} addons)" } @@ -396,6 +398,7 @@ object AddonRepository { val params = buildJsonObject { put("p_profile_id", profileId) put("p_addons", json.encodeToJsonElement(addons)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) log.d { "pushToServer() — success" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index 76aac768..e756a1f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc @@ -117,6 +118,7 @@ object CollectionSyncService { val params = buildJsonObject { put("p_profile_id", profileId) put("p_collections_json", jsonElement) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_collections", params) log.d { "pushToRemote — success" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index 15f5ffc7..d9edcea8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc @@ -136,6 +137,7 @@ object HomeCatalogSettingsSyncService { put("p_profile_id", profileId) put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM) put("p_settings_json", jsonElement) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params) log.d { "pushToRemote — success" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 1c854f47..bafc1244 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository @@ -421,6 +422,7 @@ object LibraryRepository { val params = buildJsonObject { put("p_profile_id", profileId) put("p_items", json.encodeToJsonElement(syncItems)) + putSyncOriginClientId() } log.i { "Pushing library to server profile=$profileId itemCount=${syncItems.size}" } SupabaseProvider.client.postgrest.rpc("sync_push_library", params) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 279c1080..cd727a95 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.isAnonymous import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionRepository @@ -187,6 +188,7 @@ object ProfileRepository { val params = buildJsonObject { put("p_client_max_profiles", MAX_PROFILES) put("p_profiles", json.encodeToJsonElement(profiles)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_profiles", params) pullProfiles() @@ -277,7 +279,10 @@ object ProfileRepository { return } try { - val params = buildJsonObject { put("p_profile_id", profileIndex) } + val params = buildJsonObject { + put("p_profile_id", profileIndex) + putSyncOriginClientId() + } SupabaseProvider.client.postgrest.rpc("sync_delete_profile_data", params) pullProfiles() } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 8521e4cd..476bc766 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.watchprogress.WatchProgressEntry import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc @@ -104,6 +105,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { val params = buildJsonObject { put("p_profile_id", profileId) put("p_entries", json.encodeToJsonElement(syncEntries)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params) } @@ -122,6 +124,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { val params = buildJsonObject { put("p_profile_id", profileId) put("p_keys", json.encodeToJsonElement(progressKeys)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt index a9b52395..76f8a5c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import io.github.jan.supabase.postgrest.postgrest @@ -102,6 +103,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { val params = buildJsonObject { put("p_profile_id", profileId) put("p_items", json.encodeToJsonElement(syncItems)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_watched_items", params) } @@ -120,6 +122,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { val params = buildJsonObject { put("p_profile_id", profileId) put("p_keys", json.encodeToJsonElement(keys)) + putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_delete_watched_items", params) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.ios.kt new file mode 100644 index 00000000..f3c2f444 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/sync/SyncClientIdentityStorage.ios.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.core.sync + +import platform.Foundation.NSUserDefaults + +actual object SyncClientIdentityStorage { + private const val clientIdKey = "client_instance_id" + + actual fun loadClientId(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(clientIdKey) + + actual fun saveClientId(clientId: String) { + NSUserDefaults.standardUserDefaults.setObject(clientId, forKey = clientIdKey) + } +}