diff --git a/.gitignore b/.gitignore
index c7156d5f7..80df24020 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,4 @@ asset
scripts/scrape_android_compose_animation_docs.py
tools
AGENTS.md
+server
\ No newline at end of file
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index aef2d5479..8dadc1c01 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -44,6 +44,15 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|}
""".trimMargin()
)
+ resolve("SyncBackendBootstrapConfig.kt").writeText(
+ """
+ |package com.nuvio.app.core.network
+ |
+ |object SyncBackendBootstrapConfig {
+ | const val SWITCH_MANIFEST_URL = "${props.getProperty("SYNC_BACKEND_MANIFEST_URL", "")}"
+ |}
+ """.trimMargin()
+ )
}
outDir.resolve("com/nuvio/app/features/tmdb/TmdbConfig.kt").delete()
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index ad83b451b..745c91d55 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatActivity
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.network.SyncBackendStorage
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
@@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity() {
window.setBackgroundDrawableResource(R.color.nuvio_background)
AddonStorage.initialize(applicationContext)
AuthStorage.initialize(applicationContext)
+ SyncBackendStorage.initialize(applicationContext)
LibraryStorage.initialize(applicationContext)
WatchedStorage.initialize(applicationContext)
MetaScreenSettingsStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt
new file mode 100644
index 000000000..e87613d29
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.android.kt
@@ -0,0 +1,58 @@
+package com.nuvio.app.core.network
+
+import android.content.Context
+import android.content.SharedPreferences
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.net.Proxy
+import java.util.concurrent.TimeUnit
+
+internal actual object SyncBackendStorage {
+ private const val PREFS_NAME = "nuvio_sync_backend"
+ private const val KEY_SELECTION_PAYLOAD = "selection_payload_v1"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ actual fun loadSelectionPayload(): String? =
+ preferences?.getString(KEY_SELECTION_PAYLOAD, null)
+
+ actual fun saveSelectionPayload(payload: String) {
+ preferences
+ ?.edit()
+ ?.putString(KEY_SELECTION_PAYLOAD, payload)
+ ?.apply()
+ }
+}
+
+private val syncBackendHttpClient = OkHttpClient.Builder()
+ .dns(IPv4FirstDns())
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .writeTimeout(10, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .proxy(Proxy.NO_PROXY)
+ .build()
+
+internal actual suspend fun fetchSyncBackendManifestText(url: String): String =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(url)
+ .get()
+ .header("Accept", "application/json")
+ .build()
+
+ syncBackendHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ error("Sync backend manifest request failed with HTTP ${response.code}")
+ }
+ response.body?.string()?.takeIf { it.isNotBlank() }
+ ?: error("Sync backend manifest response was empty")
+ }
+ }
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 313d53338..be123bdf7 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -532,6 +532,7 @@
Status
Anonymous
Signed in
+ Sync backend
AMOLED Black
Use pure black backgrounds for OLED screens.
App Language
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 929ae5a8a..0dadb2ddf 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -81,6 +81,9 @@ import com.nuvio.app.core.deeplink.AppDeepLink
import com.nuvio.app.core.deeplink.AppDeepLinkRepository
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
+import com.nuvio.app.core.network.SupabaseProvider
+import com.nuvio.app.core.network.SyncBackendRefreshResult
+import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.sync.AppForegroundMonitor
import com.nuvio.app.core.sync.ProfileSettingsSync
import com.nuvio.app.core.sync.SyncManager
@@ -408,6 +411,32 @@ private enum class AppGateScreen {
Main,
}
+private suspend fun refreshSyncBackendSelection() {
+ SyncBackendRepository.ensureLoaded()
+
+ when (val result = SyncBackendRepository.refreshFromManifest()) {
+ SyncBackendRefreshResult.NotConfigured,
+ is SyncBackendRefreshResult.Failed,
+ SyncBackendRefreshResult.Unchanged,
+ -> Unit
+ is SyncBackendRefreshResult.Applied -> {
+ SupabaseProvider.rebuildClient()
+ NetworkStatusRepository.requestRefresh(force = true)
+ }
+ is SyncBackendRefreshResult.RequiresLogout -> {
+ AuthRepository.resetForSyncBackendChange()
+ .onSuccess {
+ SyncBackendRepository.applyBackendAfterLogout(
+ backend = result.targetBackend,
+ revision = result.revision,
+ )
+ SupabaseProvider.rebuildClient()
+ NetworkStatusRepository.requestRefresh(force = true)
+ }
+ }
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
@@ -430,9 +459,16 @@ fun App() {
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
LaunchedEffect(Unit) {
+ refreshSyncBackendSelection()
AuthRepository.initialize()
}
+ LaunchedEffect(Unit) {
+ AppForegroundMonitor.events().collect {
+ refreshSyncBackendSelection()
+ }
+ }
+
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt
index 7e5f9d85d..11c318fc6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt
@@ -2,6 +2,7 @@ package com.nuvio.app.core.auth
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
+import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.storage.LocalAccountDataCleaner
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.providers.builtin.Email
@@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@@ -35,35 +37,42 @@ object AuthRepository {
if (initialized) return
initialized = true
- val savedAnonId = AuthStorage.loadAnonymousUserId()
- if (savedAnonId != null) {
- _state.value = AuthState.Authenticated(
- userId = savedAnonId,
- email = null,
- isAnonymous = true,
- )
- }
-
scope.launch {
- SupabaseProvider.client.auth.sessionStatus.collect { status ->
- if (AuthStorage.loadAnonymousUserId() != null) return@collect
- when (status) {
- is SessionStatus.Authenticated -> {
- val user = status.session.user
- _state.value = AuthState.Authenticated(
- userId = user?.id ?: "",
- email = user?.email,
- isAnonymous = false,
- )
- }
- is SessionStatus.NotAuthenticated -> {
- _state.value = AuthState.Unauthenticated
- }
- is SessionStatus.Initializing -> {
- if (savedAnonId == null) _state.value = AuthState.Loading
- }
- is SessionStatus.RefreshFailure -> {
- _state.value = AuthState.Unauthenticated
+ SyncBackendRepository.state.collectLatest { backendState ->
+ if (!backendState.isLoaded) return@collectLatest
+
+ AuthStorage.loadAnonymousUserId()?.let { savedAnonId ->
+ _state.value = AuthState.Authenticated(
+ userId = savedAnonId,
+ email = null,
+ isAnonymous = true,
+ )
+ } ?: run {
+ _state.value = AuthState.Loading
+ }
+
+ SupabaseProvider.client.auth.sessionStatus.collect { status ->
+ if (AuthStorage.loadAnonymousUserId() != null) return@collect
+ when (status) {
+ is SessionStatus.Authenticated -> {
+ val user = status.session.user
+ _state.value = AuthState.Authenticated(
+ userId = user?.id ?: "",
+ email = user?.email,
+ isAnonymous = false,
+ )
+ }
+ is SessionStatus.NotAuthenticated -> {
+ _state.value = AuthState.Unauthenticated
+ }
+ is SessionStatus.Initializing -> {
+ if (AuthStorage.loadAnonymousUserId() == null) {
+ _state.value = AuthState.Loading
+ }
+ }
+ is SessionStatus.RefreshFailure -> {
+ _state.value = AuthState.Unauthenticated
+ }
}
}
}
@@ -119,6 +128,26 @@ object AuthRepository {
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
}
+ suspend fun resetForSyncBackendChange(): Result = runCatching {
+ _error.value = null
+ val wasAnonymous = AuthStorage.loadAnonymousUserId() != null
+ AuthStorage.clearAnonymousUserId()
+
+ if (!wasAnonymous) {
+ runCatching {
+ SupabaseProvider.client.auth.signOut()
+ }.onFailure { e ->
+ log.w(e) { "Supabase sign-out failed during sync backend reset; continuing local reset" }
+ }
+ }
+
+ _state.value = AuthState.Unauthenticated
+ LocalAccountDataCleaner.wipe()
+ }.onFailure { e ->
+ log.e(e) { "Sync backend auth reset failed" }
+ _error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
+ }
+
suspend fun deleteAccount(): Result = runCatching {
_error.value = null
SupabaseProvider.client.functions.invoke("delete-account")
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt
index 4288603fc..626732718 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt
@@ -136,9 +136,10 @@ object NetworkStatusRepository {
return NetworkCondition.NoInternet
}
+ val backendConfig = SyncBackendRepository.selectedBackend
val supabaseReachable = probeReachable(
- url = "${SupabaseConfig.URL.trimEnd('/')}/rest/v1/",
- headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
+ url = "${backendConfig.normalizedSupabaseUrl}/rest/v1/",
+ headers = mapOf("apikey" to backendConfig.anonKey),
)
if (!supabaseReachable) {
return NetworkCondition.ServersUnreachable
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt
index 77d7090c8..0e317985e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt
@@ -1,6 +1,7 @@
package com.nuvio.app.core.network
import com.nuvio.app.core.build.AppVersionConfig
+import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.annotations.SupabaseInternal
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
@@ -10,12 +11,34 @@ import io.ktor.client.plugins.defaultRequest
import io.ktor.http.HttpHeaders
object SupabaseProvider {
+ private data class ClientHolder(
+ val backend: SyncBackendConfig,
+ val client: SupabaseClient,
+ )
+
+ private var holder: ClientHolder? = null
+
+ val selectedBackend: SyncBackendConfig
+ get() = SyncBackendRepository.selectedBackend
+
@OptIn(SupabaseInternal::class)
- val client by lazy {
+ val client: SupabaseClient
+ get() = clientFor(selectedBackend)
+
+ fun rebuildClient() {
+ holder = null
+ }
+
+ @OptIn(SupabaseInternal::class)
+ private fun clientFor(config: SyncBackendConfig): SupabaseClient {
+ holder
+ ?.takeIf { it.backend.hasSameConnectionIdentity(config) }
+ ?.let { return it.client }
+
val userAgent = "NuvioMobile/${AppVersionConfig.VERSION_NAME.ifBlank { "dev" }}"
- createSupabaseClient(
- supabaseUrl = SupabaseConfig.URL,
- supabaseKey = SupabaseConfig.ANON_KEY,
+ val nextClient = createSupabaseClient(
+ supabaseUrl = config.normalizedSupabaseUrl,
+ supabaseKey = config.anonKey,
) {
httpConfig {
defaultRequest {
@@ -26,5 +49,7 @@ object SupabaseProvider {
install(Postgrest)
install(Functions)
}
+ holder = ClientHolder(backend = config, client = nextClient)
+ return nextClient
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt
new file mode 100644
index 000000000..0ff7db977
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt
@@ -0,0 +1,150 @@
+package com.nuvio.app.core.network
+
+import kotlinx.serialization.Serializable
+
+internal const val SYNC_BACKEND_HOSTED_ID = "hosted"
+internal const val SYNC_BACKEND_NUVIO_ID = "nuvio"
+
+@Serializable
+data class SyncBackendConfig(
+ val id: String,
+ val displayName: String,
+ val supabaseUrl: String,
+ val anonKey: String,
+ val avatarPublicBaseUrl: String,
+ val schemaVersion: Int = 1,
+) {
+ val normalizedSupabaseUrl: String
+ get() = supabaseUrl.trim().trimEnd('/')
+
+ val normalizedAvatarPublicBaseUrl: String
+ get() = avatarPublicBaseUrl.trim().trimEnd('/')
+
+ fun avatarStorageUrl(storagePath: String): String =
+ "${normalizedAvatarPublicBaseUrl}/${storagePath.trim().trimStart('/')}"
+
+ fun normalized(): SyncBackendConfig =
+ copy(
+ id = id.trim().lowercase(),
+ supabaseUrl = normalizedSupabaseUrl,
+ anonKey = anonKey.trim(),
+ avatarPublicBaseUrl = normalizedAvatarPublicBaseUrl,
+ )
+}
+
+@Serializable
+data class SyncBackendManifest(
+ val version: Int = 1,
+ val activeBackend: String,
+ val revision: String = "",
+ val forceLogoutOnChange: Boolean = true,
+ val backends: Map = emptyMap(),
+)
+
+@Serializable
+data class SyncBackendManifestBackend(
+ val displayName: String = "",
+ val supabaseUrl: String = "",
+ val anonKey: String = "",
+ val avatarPublicBaseUrl: String = "",
+ val schemaVersion: Int = 1,
+)
+
+data class SyncBackendState(
+ val selectedBackend: SyncBackendConfig = SyncBackendDefaults.hosted(),
+ val appliedRevision: String = "",
+ val isLoaded: Boolean = false,
+ val lastManifestError: String? = null,
+)
+
+sealed interface SyncBackendRefreshResult {
+ data object NotConfigured : SyncBackendRefreshResult
+ data object Unchanged : SyncBackendRefreshResult
+ data class Applied(
+ val backend: SyncBackendConfig,
+ val revision: String,
+ ) : SyncBackendRefreshResult
+ data class RequiresLogout(
+ val currentBackend: SyncBackendConfig,
+ val targetBackend: SyncBackendConfig,
+ val revision: String,
+ val forceLogout: Boolean,
+ ) : SyncBackendRefreshResult
+ data class Failed(val message: String) : SyncBackendRefreshResult
+}
+
+@Serializable
+internal data class StoredSyncBackendSelection(
+ val backend: SyncBackendConfig,
+ val appliedRevision: String = "",
+)
+
+object SyncBackendDefaults {
+ fun hosted(): SyncBackendConfig =
+ SyncBackendConfig(
+ id = SYNC_BACKEND_HOSTED_ID,
+ displayName = "Hosted",
+ supabaseUrl = SupabaseConfig.URL,
+ anonKey = SupabaseConfig.ANON_KEY,
+ avatarPublicBaseUrl = "${SupabaseConfig.URL.trim().trimEnd('/')}/storage/v1/object/public/avatars",
+ schemaVersion = 1,
+ ).normalized()
+
+ fun nuvio(): SyncBackendConfig =
+ SyncBackendConfig(
+ id = SYNC_BACKEND_NUVIO_ID,
+ displayName = "Nuvio",
+ supabaseUrl = "https://api.nuvio.tv",
+ anonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzgxNTIxMzQ2LCJleHAiOjE5MzkyMDEzNDZ9.tmQaj682pwzehpqlgCDMnySOqiUvpgRbrE43T4VJpDI",
+ avatarPublicBaseUrl = "https://api.nuvio.tv/storage/v1/object/public/avatars",
+ schemaVersion = 1,
+ ).normalized()
+
+ fun byId(id: String): SyncBackendConfig? =
+ when (id.trim().lowercase()) {
+ SYNC_BACKEND_HOSTED_ID -> hosted()
+ SYNC_BACKEND_NUVIO_ID -> nuvio()
+ else -> null
+ }
+}
+
+internal fun SyncBackendConfig.hasSameConnectionIdentity(other: SyncBackendConfig): Boolean =
+ id == other.id &&
+ normalizedSupabaseUrl == other.normalizedSupabaseUrl &&
+ anonKey.trim() == other.anonKey.trim() &&
+ schemaVersion == other.schemaVersion
+
+internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendConfig? {
+ if (version != 1) return null
+
+ val activeId = activeBackend.trim().lowercase()
+ val manifestBackend = backends.entries
+ .firstOrNull { (key, _) -> key.trim().lowercase() == activeId }
+ ?.value
+ ?: return null
+ val fallback = SyncBackendDefaults.byId(activeId) ?: return null
+
+ val supabaseUrl = manifestBackend.supabaseUrl.trim().ifBlank { fallback.supabaseUrl }
+ val anonKey = manifestBackend.anonKey.trim().ifBlank { fallback.anonKey }
+ val avatarPublicBaseUrl = manifestBackend.avatarPublicBaseUrl.trim().ifBlank {
+ "$supabaseUrl/storage/v1/object/public/avatars"
+ }
+
+ return SyncBackendConfig(
+ id = activeId,
+ displayName = manifestBackend.displayName.trim().ifBlank { fallback.displayName },
+ supabaseUrl = supabaseUrl,
+ anonKey = anonKey,
+ avatarPublicBaseUrl = avatarPublicBaseUrl,
+ schemaVersion = manifestBackend.schemaVersion.takeIf { it > 0 } ?: fallback.schemaVersion,
+ )
+ .normalized()
+ .takeIf { it.isUsableClientConfig() }
+}
+
+private fun SyncBackendConfig.isUsableClientConfig(): Boolean =
+ id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) &&
+ normalizedSupabaseUrl.startsWith("https://") &&
+ anonKey.isNotBlank() &&
+ !anonKey.startsWith("<") &&
+ normalizedAvatarPublicBaseUrl.startsWith("https://")
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt
new file mode 100644
index 000000000..d38b49410
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt
@@ -0,0 +1,121 @@
+package com.nuvio.app.core.network
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+object SyncBackendRepository {
+ private val log = Logger.withTag("SyncBackendRepository")
+ private val json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ private val _state = MutableStateFlow(SyncBackendState())
+ val state: StateFlow = _state.asStateFlow()
+
+ val selectedBackend: SyncBackendConfig
+ get() {
+ ensureLoaded()
+ return _state.value.selectedBackend
+ }
+
+ fun ensureLoaded() {
+ if (_state.value.isLoaded) return
+
+ val storedSelection = SyncBackendStorage.loadSelectionPayload()
+ ?.takeIf { it.isNotBlank() }
+ ?.let { payload ->
+ runCatching { json.decodeFromString(payload) }
+ .onFailure { error -> log.w(error) { "Failed to parse stored sync backend selection" } }
+ .getOrNull()
+ }
+
+ val backend = storedSelection
+ ?.backend
+ ?.normalized()
+ ?.takeIf { storedBackend -> SyncBackendDefaults.byId(storedBackend.id) != null }
+ ?: SyncBackendDefaults.hosted()
+
+ _state.value = SyncBackendState(
+ selectedBackend = backend,
+ appliedRevision = storedSelection?.appliedRevision.orEmpty(),
+ isLoaded = true,
+ )
+ }
+
+ suspend fun refreshFromManifest(): SyncBackendRefreshResult {
+ ensureLoaded()
+
+ val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim()
+ if (manifestUrl.isBlank()) {
+ return SyncBackendRefreshResult.NotConfigured
+ }
+
+ val manifest = runCatching {
+ json.decodeFromString(
+ fetchSyncBackendManifestText(manifestUrl),
+ )
+ }.onFailure { error ->
+ val message = error.message ?: "Failed to fetch sync backend manifest"
+ log.w(error) { message }
+ _state.value = _state.value.copy(lastManifestError = message)
+ }.getOrNull() ?: return SyncBackendRefreshResult.Failed(
+ _state.value.lastManifestError ?: "Failed to fetch sync backend manifest",
+ )
+
+ val targetBackend = manifest.backendConfigForActiveBackend()
+ ?: return SyncBackendRefreshResult.Failed("Sync backend manifest is invalid")
+ val revision = manifest.revision.trim()
+ val currentBackend = _state.value.selectedBackend
+
+ if (currentBackend.hasSameConnectionIdentity(targetBackend)) {
+ saveSelection(targetBackend, revision)
+ return SyncBackendRefreshResult.Unchanged
+ }
+
+ if (!manifest.forceLogoutOnChange) {
+ saveSelection(targetBackend, revision)
+ return SyncBackendRefreshResult.Applied(targetBackend, revision)
+ }
+
+ return SyncBackendRefreshResult.RequiresLogout(
+ currentBackend = currentBackend,
+ targetBackend = targetBackend,
+ revision = revision,
+ forceLogout = true,
+ )
+ }
+
+ fun applyBackendAfterLogout(
+ backend: SyncBackendConfig,
+ revision: String,
+ ): SyncBackendConfig {
+ val normalizedBackend = backend.normalized()
+ saveSelection(normalizedBackend, revision)
+ return normalizedBackend
+ }
+
+ private fun saveSelection(
+ backend: SyncBackendConfig,
+ revision: String,
+ ) {
+ val normalizedBackend = backend.normalized()
+ val payload = json.encodeToString(
+ StoredSyncBackendSelection(
+ backend = normalizedBackend,
+ appliedRevision = revision,
+ ),
+ )
+ SyncBackendStorage.saveSelectionPayload(payload)
+ _state.value = SyncBackendState(
+ selectedBackend = normalizedBackend,
+ appliedRevision = revision,
+ isLoaded = true,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt
new file mode 100644
index 000000000..748a8c18b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.kt
@@ -0,0 +1,8 @@
+package com.nuvio.app.core.network
+
+internal expect object SyncBackendStorage {
+ fun loadSelectionPayload(): String?
+ fun saveSelectionPayload(payload: String)
+}
+
+internal expect suspend fun fetchSyncBackendManifestText(url: String): String
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt
index ccca24b30..32f0dc00a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt
@@ -1,6 +1,7 @@
package com.nuvio.app.features.profiles
import androidx.compose.ui.graphics.Color
+import com.nuvio.app.core.network.SyncBackendRepository
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -77,7 +78,7 @@ val PROFILE_COLORS = listOf(
)
fun avatarStorageUrl(storagePath: String): String =
- "${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
+ SyncBackendRepository.selectedBackend.avatarStorageUrl(storagePath)
fun normalizedAvatarUrl(url: String?): String? =
url?.trim()?.takeIf { it.isValidAvatarUrl() }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
index e80c08225..205140e42 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt
@@ -15,12 +15,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
+import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard
@@ -36,6 +41,7 @@ import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_ti
import nuvio.composeapp.generated.resources.settings_account_status
import nuvio.composeapp.generated.resources.settings_account_status_anonymous
import nuvio.composeapp.generated.resources.settings_account_status_signed_in
+import nuvio.composeapp.generated.resources.settings_account_sync_backend
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.accountSettingsContent(
@@ -51,8 +57,10 @@ private fun AccountSettingsBody(
isTablet: Boolean,
) {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
+ val syncBackendState by SyncBackendRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var showSignOutConfirm by remember { mutableStateOf(false) }
+ val syncBackendLabel = syncBackendState.selectedBackend.displayName
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
NuvioSurfaceCard {
@@ -66,44 +74,21 @@ private fun AccountSettingsBody(
when (val state = authState) {
is AuthState.Authenticated -> {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(Res.string.settings_account_status),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- text = if (state.isAnonymous) {
- stringResource(Res.string.settings_account_status_anonymous)
- } else {
- stringResource(Res.string.settings_account_status_signed_in)
- },
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Medium,
- )
- }
- if (!state.isAnonymous && state.email != null) {
+ AccountInfoRow(
+ label = stringResource(Res.string.settings_account_status),
+ value = if (state.isAnonymous) {
+ stringResource(Res.string.settings_account_status_anonymous)
+ } else {
+ stringResource(Res.string.settings_account_status_signed_in)
+ },
+ valueColor = MaterialTheme.colorScheme.primary,
+ )
+ state.email?.takeUnless { state.isAnonymous }?.let { email ->
Spacer(modifier = Modifier.height(8.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(Res.string.settings_account_email),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- text = state.email,
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
- fontWeight = FontWeight.Medium,
- )
- }
+ AccountInfoRow(
+ label = stringResource(Res.string.settings_account_email),
+ value = email,
+ )
}
}
else -> {
@@ -114,6 +99,12 @@ private fun AccountSettingsBody(
)
}
}
+
+ Spacer(modifier = Modifier.height(8.dp))
+ AccountInfoRow(
+ label = stringResource(Res.string.settings_account_sync_backend),
+ value = syncBackendLabel,
+ )
}
NuvioPrimaryButton(
@@ -135,3 +126,32 @@ private fun AccountSettingsBody(
onDismiss = { showSignOutConfirm = false },
)
}
+
+@Composable
+private fun AccountInfoRow(
+ label: String,
+ value: String,
+ valueColor: Color = MaterialTheme.colorScheme.onSurface,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ modifier = Modifier.weight(1f),
+ text = value,
+ style = MaterialTheme.typography.bodyLarge,
+ color = valueColor,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.End,
+ )
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt
new file mode 100644
index 000000000..f9fde4450
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/network/SyncBackendStorage.ios.kt
@@ -0,0 +1,43 @@
+package com.nuvio.app.core.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.darwin.Darwin
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.request.accept
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.isSuccess
+import platform.Foundation.NSUserDefaults
+
+internal actual object SyncBackendStorage {
+ private const val KEY_SELECTION_PAYLOAD = "nuvio_sync_backend_selection_payload_v1"
+
+ actual fun loadSelectionPayload(): String? =
+ NSUserDefaults.standardUserDefaults.stringForKey(KEY_SELECTION_PAYLOAD)
+
+ actual fun saveSelectionPayload(payload: String) {
+ NSUserDefaults.standardUserDefaults.setObject(payload, forKey = KEY_SELECTION_PAYLOAD)
+ }
+}
+
+private val syncBackendHttpClient = HttpClient(Darwin) {
+ install(HttpTimeout) {
+ requestTimeoutMillis = 10_000
+ connectTimeoutMillis = 10_000
+ socketTimeoutMillis = 10_000
+ }
+ expectSuccess = false
+}
+
+internal actual suspend fun fetchSyncBackendManifestText(url: String): String {
+ val response = syncBackendHttpClient.get(url) {
+ accept(ContentType.Application.Json)
+ }
+ val payload = response.bodyAsText()
+ if (!response.status.isSuccess()) {
+ error("Sync backend manifest request failed with HTTP ${response.status.value}")
+ }
+ return payload.takeIf { it.isNotBlank() }
+ ?: error("Sync backend manifest response was empty")
+}