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..964b76961 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -26,6 +26,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:Input abstract val appVersionCode: Property + @get:Input + abstract val supabaseUrl: Property + + @get:Input + abstract val supabaseAnonKey: Property + + @get:Input + abstract val nuvioSupabaseUrl: Property + + @get:Input + abstract val nuvioSupabaseAnonKey: Property + + @get:Input + abstract val syncBackendManifestUrl: Property + @TaskAction fun generate() { val props = Properties() @@ -39,8 +54,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.core.network | |object SupabaseConfig { - | const val URL = "${props.getProperty("SUPABASE_URL", "")}" - | const val ANON_KEY = "${props.getProperty("SUPABASE_ANON_KEY", "")}" + | const val URL = "${supabaseUrl.get()}" + | const val ANON_KEY = "${supabaseAnonKey.get()}" + | const val NUVIO_URL = "${nuvioSupabaseUrl.get()}" + | const val NUVIO_ANON_KEY = "${nuvioSupabaseAnonKey.get()}" + |} + """.trimMargin() + ) + resolve("SyncBackendBootstrapConfig.kt").writeText( + """ + |package com.nuvio.app.core.network + | + |object SyncBackendBootstrapConfig { + | const val SWITCH_MANIFEST_URL = "${syncBackendManifestUrl.get()}" |} """.trimMargin() ) @@ -199,12 +225,28 @@ val isAndroidAppBundleBuild = requestedGradleTasks.any { taskName -> taskName.startsWith("bundlefull") || taskName.endsWith("bundle") } +val runtimeLocalProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use(::load) + } +} + +fun runtimeConfigValue(key: String, fallback: String = ""): String = + runtimeLocalProperties.getProperty(key)?.trim()?.takeIf { it.isNotBlank() } + ?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() } + ?: fallback val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") { outputDir.set(generatedRuntimeConfigDir) localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) appVersionName.set(releaseAppVersionName) appVersionCode.set(releaseAppVersionCode) + supabaseUrl.set(runtimeConfigValue("SUPABASE_URL")) + supabaseAnonKey.set(runtimeConfigValue("SUPABASE_ANON_KEY")) + nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL")) + nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY")) + syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL")) } tasks.withType>().configureEach { 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..ca45da22e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendConfig.kt @@ -0,0 +1,121 @@ +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, +) + +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? = null, + val backendId: String = "", + 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 = SupabaseConfig.NUVIO_URL, + anonKey = SupabaseConfig.NUVIO_ANON_KEY, + avatarPublicBaseUrl = "${SupabaseConfig.NUVIO_URL.trim().trimEnd('/')}/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() + return SyncBackendDefaults.byId(activeId) + ?.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..db3065293 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SyncBackendRepository.kt @@ -0,0 +1,122 @@ +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 + ?.let { selection -> + selection.backendId.ifBlank { selection.backend?.id.orEmpty() } + } + ?.let(SyncBackendDefaults::byId) + ?: 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( + backendId = normalizedBackend.id, + 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") +}