feat: local db switch for development

This commit is contained in:
tapframe 2026-06-23 19:43:27 +05:30
parent 611b934000
commit 69f0dab65e
13 changed files with 309 additions and 14 deletions

View file

@ -40,6 +40,9 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
@get:Input
abstract val syncBackendManifestUrl: Property<String>
@get:Input
abstract val debugBuild: Property<Boolean>
@TaskAction
fun generate() {
val props = Properties()
@ -140,6 +143,15 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|}
""".trimMargin()
)
resolve("AppBuildConfig.kt").writeText(
"""
|package com.nuvio.app.core.build
|
|object AppBuildConfig {
| const val IS_DEBUG_BUILD = ${debugBuild.get()}
|}
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/features/settings").apply {
@ -256,6 +268,35 @@ fun runtimeConfigValue(key: String, fallback: String = ""): String =
?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
?: fallback
fun booleanConfigValue(key: String): Boolean? {
val rawValue = runtimeLocalProperties.getProperty(key)
?: providers.environmentVariable(key).orNull
?: providers.gradleProperty(key).orNull
return rawValue
?.trim()
?.lowercase()
?.let { value ->
when (value) {
"1", "true", "yes", "y", "debug" -> true
"0", "false", "no", "n", "release" -> false
else -> null
}
}
}
val xcodeConfiguration = providers.environmentVariable("CONFIGURATION").orNull
?.trim()
?.lowercase()
val kotlinFrameworkBuildType = providers.environmentVariable("KOTLIN_FRAMEWORK_BUILD_TYPE").orNull
?.trim()
?.lowercase()
val inferredDebugBuild = requestedGradleTasks.any { "debug" in it } ||
xcodeConfiguration == "debug" ||
kotlinFrameworkBuildType == "debug"
val isDebugBuild = booleanConfigValue("NUVIO_DEBUG_BUILD")
?: booleanConfigValue("nuvio.debugBuild")
?: inferredDebugBuild
val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir)
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
@ -266,6 +307,7 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL"))
nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY"))
syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL"))
debugBuild.set(isDebugBuild)
}
tasks.withType<KotlinCompilationTask<*>>().configureEach {

View file

@ -7,4 +7,5 @@ actual object AppFeaturePolicy {
actual val heroTrailerPlaybackSupported: Boolean = true
actual val inAppUpdaterEnabled: Boolean = true
actual val imdbRatingLogoEnabled: Boolean = true
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -7,4 +7,5 @@ actual object AppFeaturePolicy {
actual val heroTrailerPlaybackSupported: Boolean = false
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = false
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -19,6 +19,7 @@
<string name="action_retry">Retry</string>
<string name="action_save">Save</string>
<string name="action_saving">Saving…</string>
<string name="action_switch">Switch</string>
<string name="action_validate">Validate</string>
<string name="addon_installing">Installing</string>
<string name="addon_title">Addons</string>
@ -533,6 +534,8 @@
<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="debug_backend_switch_confirm_title">Switch backend?</string>
<string name="debug_backend_switch_confirm_message">Switch to %1$s and sign out? You can sign in again on the selected 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>

View file

@ -12,4 +12,5 @@ expect object AppFeaturePolicy {
val heroTrailerPlaybackSupported: Boolean
val inAppUpdaterEnabled: Boolean
val imdbRatingLogoEnabled: Boolean
val debugBackendSwitcherEnabled: Boolean
}

View file

@ -45,6 +45,7 @@ data class SyncBackendState(
val appliedRevision: String = "",
val isLoaded: Boolean = false,
val lastManifestError: String? = null,
val isManualDebugOverride: Boolean = false,
)
sealed interface SyncBackendRefreshResult {
@ -68,6 +69,7 @@ internal data class StoredSyncBackendSelection(
val backend: SyncBackendConfig? = null,
val backendId: String = "",
val appliedRevision: String = "",
val manualDebugOverride: Boolean = false,
)
object SyncBackendDefaults {
@ -113,7 +115,7 @@ internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendCon
?.takeIf { it.isUsableClientConfig() }
}
private fun SyncBackendConfig.isUsableClientConfig(): Boolean =
internal fun SyncBackendConfig.isUsableClientConfig(): Boolean =
id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) &&
normalizedSupabaseUrl.startsWith("https://") &&
anonKey.isNotBlank() &&

View file

@ -1,6 +1,7 @@
package com.nuvio.app.core.network
import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -35,23 +36,37 @@ object SyncBackendRepository {
.getOrNull()
}
val backend = storedSelection
?.let { selection ->
selection.backendId.ifBlank { selection.backend?.id.orEmpty() }
}
?.let(SyncBackendDefaults::byId)
?: SyncBackendDefaults.hosted()
val storedManualDebugOverride = storedSelection?.manualDebugOverride == true
val backend = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) {
SyncBackendDefaults.hosted()
} else {
storedSelection
?.let { selection ->
selection.backendId.ifBlank { selection.backend?.id.orEmpty() }
}
?.let(SyncBackendDefaults::byId)
?: SyncBackendDefaults.hosted()
}
_state.value = SyncBackendState(
selectedBackend = backend,
appliedRevision = storedSelection?.appliedRevision.orEmpty(),
appliedRevision = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) {
""
} else {
storedSelection?.appliedRevision.orEmpty()
},
isLoaded = true,
isManualDebugOverride = storedManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled,
)
}
suspend fun refreshFromManifest(): SyncBackendRefreshResult {
ensureLoaded()
if (_state.value.isManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled) {
return SyncBackendRefreshResult.Unchanged
}
val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim()
if (manifestUrl.isBlank()) {
return SyncBackendRefreshResult.NotConfigured
@ -97,19 +112,40 @@ object SyncBackendRepository {
revision: String,
): SyncBackendConfig {
val normalizedBackend = backend.normalized()
saveSelection(normalizedBackend, revision)
saveSelection(normalizedBackend, revision, manualDebugOverride = false)
return normalizedBackend
}
fun debugSelectableBackends(): List<SyncBackendConfig> =
listOf(SyncBackendDefaults.hosted(), SyncBackendDefaults.nuvio())
.filter { backend -> backend.isUsableClientConfig() }
fun applyDebugBackendAfterLogout(backend: SyncBackendConfig): SyncBackendConfig? {
if (!AppFeaturePolicy.debugBackendSwitcherEnabled) return null
val normalizedBackend = backend.normalized()
.takeIf { it.isUsableClientConfig() }
?: return null
saveSelection(
backend = normalizedBackend,
revision = DEBUG_MANUAL_REVISION,
manualDebugOverride = true,
)
return normalizedBackend
}
private fun saveSelection(
backend: SyncBackendConfig,
revision: String,
manualDebugOverride: Boolean = false,
) {
val normalizedBackend = backend.normalized()
val payload = json.encodeToString(
StoredSyncBackendSelection(
backendId = normalizedBackend.id,
appliedRevision = revision,
manualDebugOverride = manualDebugOverride,
),
)
SyncBackendStorage.saveSelectionPayload(payload)
@ -117,6 +153,9 @@ object SyncBackendRepository {
selectedBackend = normalizedBackend,
appliedRevision = revision,
isLoaded = true,
isManualDebugOverride = manualDebugOverride,
)
}
private const val DEBUG_MANUAL_REVISION = "debug-manual"
}

View file

@ -69,6 +69,8 @@ import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.ui.nuvioOverlayGradientBrush
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.dev.DebugSyncBackendSwitch
import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.app_logo_wordmark
@ -157,6 +159,15 @@ fun AuthScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (shouldShowDebugSyncBackendSwitch()) {
Spacer(modifier = Modifier.height(24.dp))
DebugSyncBackendSwitch(
modifier = Modifier.fillMaxWidth(),
requireConfirmation = false,
container = true,
)
}
Spacer(modifier = Modifier.height(48.dp))
NuvioSurfaceCard {

View file

@ -0,0 +1,182 @@
package com.nuvio.app.features.dev
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
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.build.AppFeaturePolicy
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.network.SYNC_BACKEND_HOSTED_ID
import com.nuvio.app.core.network.SYNC_BACKEND_NUVIO_ID
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.network.SyncBackendConfig
import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.network.hasSameConnectionIdentity
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_switch
import nuvio.composeapp.generated.resources.debug_backend_switch_confirm_message
import nuvio.composeapp.generated.resources.debug_backend_switch_confirm_title
import nuvio.composeapp.generated.resources.settings_account_sync_backend
import org.jetbrains.compose.resources.stringResource
internal fun shouldShowDebugSyncBackendSwitch(): Boolean =
AppFeaturePolicy.debugBackendSwitcherEnabled &&
SyncBackendRepository.debugSelectableBackends().size >= 2
@Composable
internal fun DebugSyncBackendSwitch(
modifier: Modifier = Modifier,
requireConfirmation: Boolean,
container: Boolean = false,
) {
if (!shouldShowDebugSyncBackendSwitch()) return
val backendState by SyncBackendRepository.state.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
val selectableBackends = remember { SyncBackendRepository.debugSelectableBackends() }
val hostedBackend = selectableBackends.firstOrNull { backend -> backend.id == SYNC_BACKEND_HOSTED_ID }
?: return
val nuvioBackend = selectableBackends.firstOrNull { backend -> backend.id == SYNC_BACKEND_NUVIO_ID }
?: return
val selectedBackend = backendState.selectedBackend
val nuvioSelected = selectedBackend.id == SYNC_BACKEND_NUVIO_ID
val targetBackend = if (nuvioSelected) hostedBackend else nuvioBackend
val tokens = MaterialTheme.nuvio
var pendingBackend by remember { mutableStateOf<SyncBackendConfig?>(null) }
var isSwitching by remember { mutableStateOf(false) }
fun switchToBackend(backend: SyncBackendConfig) {
if (isSwitching || selectedBackend.hasSameConnectionIdentity(backend)) return
isSwitching = true
coroutineScope.launch {
AuthRepository.resetForSyncBackendChange()
.onSuccess {
val appliedBackend = SyncBackendRepository.applyDebugBackendAfterLogout(backend)
if (appliedBackend != null) {
SupabaseProvider.rebuildClient()
NetworkStatusRepository.requestRefresh(force = true)
}
}
pendingBackend = null
isSwitching = false
}
}
fun requestBackendSwitch(backend: SyncBackendConfig) {
if (selectedBackend.hasSameConnectionIdentity(backend)) return
if (requireConfirmation) {
pendingBackend = backend
} else {
switchToBackend(backend)
}
}
val content: @Composable () -> Unit = {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isSwitching) { requestBackendSwitch(targetBackend) }
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = stringResource(Res.string.settings_account_sync_backend),
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
)
Text(
text = selectedBackend.displayName,
style = MaterialTheme.typography.bodyLarge,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (isSwitching) {
CircularProgressIndicator(
color = tokens.colors.accent,
strokeWidth = NuvioTokens.Border.medium,
)
} else {
Switch(
checked = nuvioSelected,
onCheckedChange = { checked ->
requestBackendSwitch(if (checked) nuvioBackend else hostedBackend)
},
colors = SwitchDefaults.colors(
checkedThumbColor = tokens.colors.onAccent,
checkedTrackColor = tokens.colors.accent,
uncheckedThumbColor = tokens.colors.textMuted,
uncheckedTrackColor = tokens.colors.borderDefault,
),
)
}
}
}
if (container) {
Surface(
modifier = modifier.fillMaxWidth(),
color = tokens.colors.surface,
shape = tokens.shapes.compactCard,
border = BorderStroke(tokens.borders.hairline, tokens.colors.borderSubtle),
) {
content()
}
} else {
Row(modifier = modifier.fillMaxWidth()) {
content()
}
}
pendingBackend?.let { backend ->
NuvioStatusModal(
title = stringResource(Res.string.debug_backend_switch_confirm_title),
message = stringResource(
Res.string.debug_backend_switch_confirm_message,
backend.displayName,
),
isVisible = true,
isBusy = isSwitching,
confirmText = stringResource(Res.string.action_switch),
dismissText = stringResource(Res.string.action_cancel),
onConfirm = { switchToBackend(backend) },
onDismiss = { pendingBackend = null },
)
}
}

View file

@ -29,6 +29,8 @@ 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
import com.nuvio.app.features.dev.DebugSyncBackendSwitch
import com.nuvio.app.features.dev.shouldShowDebugSyncBackendSwitch
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
@ -100,11 +102,19 @@ private fun AccountSettingsBody(
}
}
Spacer(modifier = Modifier.height(8.dp))
AccountInfoRow(
label = stringResource(Res.string.settings_account_sync_backend),
value = syncBackendLabel,
)
if (shouldShowDebugSyncBackendSwitch()) {
Spacer(modifier = Modifier.height(8.dp))
DebugSyncBackendSwitch(
modifier = Modifier.fillMaxWidth(),
requireConfirmation = true,
)
} else {
Spacer(modifier = Modifier.height(8.dp))
AccountInfoRow(
label = stringResource(Res.string.settings_account_sync_backend),
value = syncBackendLabel,
)
}
}
NuvioPrimaryButton(

View file

@ -7,4 +7,5 @@ actual object AppFeaturePolicy {
actual val heroTrailerPlaybackSupported: Boolean = false
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = true
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -7,4 +7,5 @@ actual object AppFeaturePolicy {
actual val heroTrailerPlaybackSupported: Boolean = false
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = false
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}

View file

@ -7,4 +7,5 @@ actual object AppFeaturePolicy {
actual val heroTrailerPlaybackSupported: Boolean = false
actual val inAppUpdaterEnabled: Boolean = false
actual val imdbRatingLogoEnabled: Boolean = true
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
}