mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 06:52:18 +00:00
Merge branch 'switchdb' into cmp-rewrite
This commit is contained in:
commit
e883d89031
15 changed files with 584 additions and 74 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,3 +28,4 @@ asset
|
|||
scripts/scrape_android_compose_animation_docs.py
|
||||
tools
|
||||
AGENTS.md
|
||||
server
|
||||
|
|
@ -26,6 +26,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
@get:Input
|
||||
abstract val appVersionCode: Property<Int>
|
||||
|
||||
@get:Input
|
||||
abstract val supabaseUrl: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val supabaseAnonKey: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val nuvioSupabaseUrl: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val nuvioSupabaseAnonKey: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val syncBackendManifestUrl: Property<String>
|
||||
|
||||
@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<GenerateRuntimeConfigsTask>("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<KotlinCompilationTask<*>>().configureEach {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -532,6 +532,7 @@
|
|||
<string name="settings_account_status">Status</string>
|
||||
<string name="settings_account_status_anonymous">Anonymous</string>
|
||||
<string name="settings_account_status_signed_in">Signed in</string>
|
||||
<string name="settings_account_sync_backend">Sync backend</string>
|
||||
<string name="settings_appearance_amoled_black">AMOLED Black</string>
|
||||
<string name="settings_appearance_amoled_description">Use pure black backgrounds for OLED screens.</string>
|
||||
<string name="settings_appearance_app_language">App Language</string>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<Unit> = 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<Unit> = runCatching {
|
||||
_error.value = null
|
||||
SupabaseProvider.client.functions.invoke("delete-account")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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://")
|
||||
|
|
@ -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<SyncBackendState> = _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<StoredSyncBackendSelection>(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<SyncBackendManifest>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
Loading…
Reference in a new issue