diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 1e658cae4..ac05f16ee 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -16,6 +16,7 @@ fun readXcconfigValue(file: File, key: String): String? {
plugins {
alias(libs.plugins.androidApplication)
+ alias(libs.plugins.sentry.android.gradle)
}
val localProps = Properties().apply {
@@ -27,6 +28,14 @@ val releaseStorePassword = localProps.getProperty("NUVIO_RELEASE_STORE_PASSWORD"
val releaseKeyAlias = localProps.getProperty("NUVIO_RELEASE_KEY_ALIAS")?.takeIf { it.isNotBlank() }
val releaseKeyPassword = localProps.getProperty("NUVIO_RELEASE_KEY_PASSWORD")?.takeIf { it.isNotBlank() }
val releaseKeystore = releaseStoreFile?.let(rootProject::file)
+fun envOrLocalProperty(key: String): String? =
+ providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
+ ?: localProps.getProperty(key)?.trim()?.takeIf { it.isNotBlank() }
+
+val sentryAuthToken = envOrLocalProperty("SENTRY_AUTH_TOKEN")
+val sentryOrg = envOrLocalProperty("SENTRY_ORG")
+val sentryProject = envOrLocalProperty("SENTRY_PROJECT")
+val sentryMappingUploadEnabled = sentryAuthToken != null && sentryOrg != null && sentryProject != null
val appVersionConfigFile = rootProject.file("iosApp/Configuration/Version.xcconfig")
val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_VERSION")
?: error("MARKETING_VERSION is missing from ${appVersionConfigFile.path}")
@@ -111,6 +120,28 @@ android {
}
}
+sentry {
+ includeProguardMapping.set(true)
+ autoUploadProguardMapping.set(sentryMappingUploadEnabled)
+ uploadNativeSymbols.set(false)
+ autoUploadNativeSymbols.set(false)
+ includeNativeSources.set(false)
+ includeSourceContext.set(false)
+ autoUploadSourceContext.set(false)
+ includeDependenciesReport.set(false)
+ telemetry.set(false)
+ sentryAuthToken?.let(authToken::set)
+ sentryOrg?.let(org::set)
+ sentryProject?.let(projectName::set)
+ ignoredBuildTypes.set(setOf("debug"))
+ autoInstallation {
+ enabled.set(false)
+ }
+ tracingInstrumentation {
+ enabled.set(false)
+ }
+}
+
dependencies {
implementation(project(":composeApp"))
coreLibraryDesugaring(libs.desugar.jdk.libs)
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index ca9cb0359..cbcbc3f97 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -16,6 +16,10 @@
android:localeConfig="@xml/locale_config"
android:supportsRtl="true"
android:theme="@style/Theme.Nuvio">
+
+
+ @get:Input
+ abstract val supabaseFallbackUrl: Property
+
+ @get:Input
+ abstract val sentryDsn: Property
+
+ @get:Input
+ abstract val sentryEnvironment: Property
+
+ @get:Input
+ abstract val realtimeSyncEnabled: Property
+
@TaskAction
fun generate() {
val props = Properties()
@@ -46,6 +58,34 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|object SupabaseConfig {
| const val URL = "${supabaseUrl.get()}"
| const val ANON_KEY = "${supabaseAnonKey.get()}"
+ | const val FALLBACK_URL = "${supabaseFallbackUrl.get()}"
+ |}
+ """.trimMargin()
+ )
+ }
+
+ outDir.resolve("com/nuvio/app/core/diagnostics").apply {
+ mkdirs()
+ resolve("SentryConfig.kt").writeText(
+ """
+ |package com.nuvio.app.core.diagnostics
+ |
+ |object SentryConfig {
+ | const val DSN = "${sentryDsn.get()}"
+ | const val ENVIRONMENT = "${sentryEnvironment.get()}"
+ |}
+ """.trimMargin()
+ )
+ }
+
+ outDir.resolve("com/nuvio/app/core/sync").apply {
+ mkdirs()
+ resolve("RealtimeSyncConfig.kt").writeText(
+ """
+ |package com.nuvio.app.core.sync
+ |
+ |object RealtimeSyncConfig {
+ | const val ENABLED = ${realtimeSyncEnabled.get()}
|}
""".trimMargin()
)
@@ -236,6 +276,13 @@ fun runtimeConfigValue(key: String, fallback: String = ""): String =
?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
?: fallback
+fun runtimeConfigBoolean(key: String, default: Boolean): Boolean =
+ when (runtimeConfigValue(key).lowercase()) {
+ "1", "true", "yes", "y", "on" -> true
+ "0", "false", "no", "n", "off" -> false
+ else -> default
+ }
+
val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir)
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
@@ -243,6 +290,16 @@ val generateRuntimeConfigs = tasks.register("generat
appVersionCode.set(releaseAppVersionCode)
supabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL"))
supabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY"))
+ supabaseFallbackUrl.set(runtimeConfigValue("NUVIO_SUPABASE_FALLBACK_URL"))
+ sentryDsn.set(runtimeConfigValue("SENTRY_DSN"))
+ sentryEnvironment.set(
+ when {
+ requestedGradleTasks.any { "benchmark" in it } -> "benchmark"
+ requestedGradleTasks.any { "debug" in it } -> "debug"
+ else -> "production"
+ }
+ )
+ realtimeSyncEnabled.set(runtimeConfigBoolean("NUVIO_REALTIME_SYNC_ENABLED", true))
}
tasks.withType>().configureEach {
@@ -321,6 +378,7 @@ kotlin {
implementation("com.google.code.gson:gson:2.11.0")
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
implementation(libs.ktor.client.okhttp)
+ implementation(libs.sentry.android)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.exoplayer.smoothstreaming)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index 0ce6bee49..1c54fd0d3 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -10,6 +10,7 @@ import androidx.activity.SystemBarStyle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage
+import com.nuvio.app.core.diagnostics.SentryInitializer
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.core.sync.SyncClientIdentityStorage
@@ -39,6 +40,7 @@ import com.nuvio.app.features.profiles.ProfilePinCacheStorage
import com.nuvio.app.features.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.search.SearchHistoryStorage
+import com.nuvio.app.features.settings.SentrySettingsStorage
import com.nuvio.app.features.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
@@ -65,6 +67,8 @@ class MainActivity : AppCompatActivity() {
),
)
ThemeSettingsStorage.initialize(applicationContext)
+ SentrySettingsStorage.initialize(applicationContext)
+ SentryInitializer.start(application)
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.nuvio_background)
SyncClientIdentityStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryInitializer.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryInitializer.kt
new file mode 100644
index 000000000..67af80322
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryInitializer.kt
@@ -0,0 +1,106 @@
+package com.nuvio.app.core.diagnostics
+
+import android.app.Application
+import com.nuvio.app.core.build.AppVersionConfig
+import com.nuvio.app.features.settings.SentrySettingsRepository
+import io.sentry.Sentry
+import io.sentry.SentryEvent
+import io.sentry.SentryOptions
+import io.sentry.android.core.SentryAndroid
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+object SentryInitializer {
+ private val droppedIssueText = listOf(
+ "Large HTTP payload",
+ "File IO on Main Thread",
+ )
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ @Volatile
+ private var started = false
+
+ @Volatile
+ private var active = false
+
+ fun start(application: Application) {
+ if (started || !SentrySettingsRepository.isSupported) return
+ started = true
+ SentrySettingsRepository.ensureLoaded()
+ applyEnabled(application, SentrySettingsRepository.enabled.value)
+ scope.launch {
+ SentrySettingsRepository.enabled.collect { enabled ->
+ applyEnabled(application, enabled)
+ }
+ }
+ }
+
+ private fun applyEnabled(application: Application, enabled: Boolean) {
+ if (!enabled) {
+ if (Sentry.isEnabled()) {
+ Sentry.close()
+ }
+ active = false
+ return
+ }
+
+ val dsn = SentryConfig.DSN.trim()
+ if (dsn.isBlank()) return
+ if (active && Sentry.isEnabled()) return
+
+ SentryAndroid.init(application) { options ->
+ options.dsn = dsn
+ options.release = "${application.packageName}@${AppVersionConfig.VERSION_NAME}+${AppVersionConfig.VERSION_CODE}"
+ options.environment = SentryConfig.ENVIRONMENT
+ options.isSendDefaultPii = false
+ options.isAttachScreenshot = false
+ options.isAttachViewHierarchy = false
+ options.tracesSampleRate = 0.0
+ options.setMaxBreadcrumbs(50)
+ options.setIgnoredErrors(droppedIssueText)
+ options.setIgnoredTransactions(droppedIssueText)
+ options.setTraceOptionsRequests(false)
+ options.setEnableAutoActivityLifecycleTracing(false)
+ options.setEnableTimeToFullDisplayTracing(false)
+ options.setEnableFramesTracking(false)
+ options.setEnablePerformanceV2(false)
+ options.setEnableNetworkEventBreadcrumbs(false)
+ options.setReportHistoricalAnrs(false)
+ options.setAttachAnrThreadDump(false)
+ options.beforeSend = SentryOptions.BeforeSendCallback { event, _ ->
+ event.request = null
+ event.user = null
+ if (shouldDrop(event)) null else event
+ }
+ }
+
+ Sentry.configureScope { scope ->
+ scope.setTag("app.package_name", application.packageName)
+ scope.setTag("app.version_name", AppVersionConfig.VERSION_NAME)
+ scope.setTag("app.version_code", AppVersionConfig.VERSION_CODE.toString())
+ }
+ active = true
+ }
+
+ private fun shouldDrop(event: SentryEvent): Boolean =
+ eventText(event).any { text ->
+ droppedIssueText.any { dropped ->
+ text.contains(dropped, ignoreCase = true)
+ }
+ }
+
+ private fun eventText(event: SentryEvent): List {
+ val values = mutableListOf()
+ event.message?.formatted?.let(values::add)
+ event.message?.message?.let(values::add)
+ event.logger?.let(values::add)
+ event.transaction?.let(values::add)
+ event.exceptions?.forEach { exception ->
+ exception.type?.let(values::add)
+ exception.value?.let(values::add)
+ }
+ return values
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryNetworkBreadcrumbInterceptor.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryNetworkBreadcrumbInterceptor.kt
new file mode 100644
index 000000000..2b60a45d8
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/diagnostics/SentryNetworkBreadcrumbInterceptor.kt
@@ -0,0 +1,91 @@
+package com.nuvio.app.core.diagnostics
+
+import io.sentry.Breadcrumb
+import io.sentry.Sentry
+import io.sentry.SentryLevel
+import okhttp3.HttpUrl
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.io.IOException
+
+class SentryNetworkBreadcrumbInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ if (!Sentry.isEnabled()) return chain.proceed(request)
+
+ val startedAtNs = System.nanoTime()
+ try {
+ val response = chain.proceed(request)
+ record(
+ url = request.url,
+ method = request.method,
+ statusCode = response.code,
+ elapsedMs = elapsedMs(startedAtNs),
+ error = null,
+ )
+ return response
+ } catch (error: IOException) {
+ record(
+ url = request.url,
+ method = request.method,
+ statusCode = null,
+ elapsedMs = elapsedMs(startedAtNs),
+ error = error,
+ )
+ throw error
+ } catch (error: RuntimeException) {
+ record(
+ url = request.url,
+ method = request.method,
+ statusCode = null,
+ elapsedMs = elapsedMs(startedAtNs),
+ error = error,
+ )
+ throw error
+ }
+ }
+
+ private fun record(
+ url: HttpUrl,
+ method: String,
+ statusCode: Int?,
+ elapsedMs: Long,
+ error: Throwable?,
+ ) {
+ val breadcrumb = if (statusCode == null) {
+ Breadcrumb.http(scrubbedUrl(url), method)
+ } else {
+ Breadcrumb.http(scrubbedUrl(url), method, statusCode)
+ }
+ breadcrumb.setCategory("http.client")
+ breadcrumb.setLevel(levelFor(statusCode, error))
+ breadcrumb.setData("host", url.host)
+ breadcrumb.setData("path", url.encodedPath)
+ breadcrumb.setData("elapsed_ms", elapsedMs)
+ if (error != null) {
+ breadcrumb.setData("error_type", error.javaClass.name)
+ error.message?.let { breadcrumb.setData("error_message", it.take(240)) }
+ }
+ Sentry.addBreadcrumb(breadcrumb)
+ }
+
+ private fun levelFor(statusCode: Int?, error: Throwable?): SentryLevel {
+ if (error != null) return SentryLevel.ERROR
+ val code = statusCode ?: return SentryLevel.INFO
+ return when {
+ code >= 500 -> SentryLevel.ERROR
+ code >= 400 -> SentryLevel.WARNING
+ else -> SentryLevel.INFO
+ }
+ }
+
+ private fun scrubbedUrl(url: HttpUrl): String =
+ url.newBuilder()
+ .query(null)
+ .fragment(null)
+ .build()
+ .toString()
+
+ private fun elapsedMs(startedAtNs: Long): Long =
+ (System.nanoTime() - startedAtNs) / 1_000_000L
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt
index 01afe4ef3..8d60a4300 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt
@@ -2,6 +2,7 @@ package com.nuvio.app.features.addons
import android.content.Context
import android.content.SharedPreferences
+import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
import com.nuvio.app.core.network.IPv4FirstDns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@@ -84,6 +85,7 @@ private val addonHttpClient = OkHttpClient.Builder()
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
+ .addInterceptor(SentryNetworkBreadcrumbInterceptor())
.proxy(Proxy.NO_PROXY)
.build()
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt
index 09cc98668..075ce0e4b 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
+import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
import com.nuvio.app.core.network.IPv4FirstDns
import okhttp3.OkHttpClient
import java.net.HttpURLConnection
@@ -55,6 +56,7 @@ internal object PlayerPlaybackNetworking {
.followRedirects(true)
.followSslRedirects(true)
.retryOnConnectionFailure(true)
+ .addInterceptor(SentryNetworkBreadcrumbInterceptor())
.build()
}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt
index 3fa7106da..c78a52f59 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
+import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -19,7 +20,11 @@ object SubtitleFileCache {
private const val SUBTITLE_CACHE_DIR = "subtitles"
private var appContext: Context? = null
- private val okHttpClient: OkHttpClient by lazy { OkHttpClient() }
+ private val okHttpClient: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ .addInterceptor(SentryNetworkBreadcrumbInterceptor())
+ .build()
+ }
fun initialize(context: Context) {
appContext = context.applicationContext
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.android.kt
new file mode 100644
index 000000000..6117cf4e7
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.android.kt
@@ -0,0 +1,31 @@
+package com.nuvio.app.features.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+
+internal actual object SentrySettingsPlatform {
+ actual val crashReportsSupported: Boolean = true
+}
+
+internal actual object SentrySettingsStorage {
+ private const val preferencesName = "nuvio_sentry_settings"
+ private const val enabledKey = "enabled"
+
+ private var preferences: SharedPreferences? = null
+
+ fun initialize(context: Context) {
+ preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+ }
+
+ actual fun loadEnabled(): Boolean? =
+ preferences?.let { prefs ->
+ if (prefs.contains(enabledKey)) prefs.getBoolean(enabledKey, true) else null
+ }
+
+ actual fun saveEnabled(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(enabledKey, enabled)
+ ?.apply()
+ }
+}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index ac613767a..de0260e8e 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -455,9 +455,25 @@
Search settings...
RESULTS
STARTUP
+ DIAGNOSTICS
CACHE
Remember Last Profile
Remember last selected profile at startup
+ Sentry Crash Reports
+ Send crash and ANR reports with safe context. On by default.
+ Turn on crash reports?
+ Crash reports help identify device-specific failures without asking you to reproduce the issue with ADB logs.
+ Turn off crash reports?
+ Future crashes and current ANRs from this device will not be reported. This can make device-specific bugs much harder to fix.
+ How it helps
+ Reports are grouped by app version, device model, Android version, and stack trace so the most common real crashes can be fixed first.
+ Sent
+ Crashes, current ANRs, stack traces, app version, build type, flavor, device and OS metadata, plus safe network breadcrumbs with method, host, path, status, duration, and exception type.
+ Not sent
+ Passwords, access tokens, refresh tokens, request bodies, response bodies, headers, cookies, screenshots, view hierarchy, session replay, raw diagnostics, and stream URL query or fragment values.
+ Keep on
+ Turn off
+ Turn on
Clear Continue Watching Cache
Remove cached Continue Watching data and refresh watch progress
Cache cleared
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 21226314a..4ff6f2f68 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -85,6 +85,7 @@ import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.sync.AppForegroundMonitor
import com.nuvio.app.core.sync.ProfileSettingsSync
+import com.nuvio.app.core.sync.RealtimeSyncConfig
import com.nuvio.app.core.sync.RealtimeSyncInvalidationService
import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.ui.NuvioNavigationBar
@@ -909,6 +910,11 @@ private fun MainAppContent(
}
LaunchedEffect(authState, profileState.activeProfile?.profileIndex) {
+ if (!RealtimeSyncConfig.ENABLED) {
+ RealtimeSyncInvalidationService.stop()
+ return@LaunchedEffect
+ }
+
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
if (authenticatedState.isAnonymous) return@LaunchedEffect
@@ -921,7 +927,12 @@ private fun MainAppContent(
DisposableEffect(authState, profileState.activeProfile?.profileIndex) {
val authenticatedState = authState as? AuthState.Authenticated
- if (authenticatedState == null || authenticatedState.isAnonymous || profileState.activeProfile == null) {
+ if (
+ !RealtimeSyncConfig.ENABLED ||
+ authenticatedState == null ||
+ authenticatedState.isAnonymous ||
+ profileState.activeProfile == null
+ ) {
RealtimeSyncInvalidationService.stop()
}
onDispose {
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..bb270c98d 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,10 +136,12 @@ object NetworkStatusRepository {
return NetworkCondition.NoInternet
}
- val supabaseReachable = probeReachable(
- url = "${SupabaseConfig.URL.trimEnd('/')}/rest/v1/",
- headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
- )
+ val supabaseReachable = SupabaseEndpointConfig.restEndpointUrls().any { url ->
+ probeReachable(
+ url = url,
+ headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
+ )
+ }
if (!supabaseReachable) {
return NetworkCondition.ServersUnreachable
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseEndpointConfig.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseEndpointConfig.kt
new file mode 100644
index 000000000..8c9e26eca
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseEndpointConfig.kt
@@ -0,0 +1,64 @@
+package com.nuvio.app.core.network
+
+internal object SupabaseEndpointConfig {
+ private val primaryBaseUrl: String = SupabaseConfig.URL.normalizedBaseUrl()
+ private val fallbackBaseUrl: String = SupabaseConfig.FALLBACK_URL.normalizedBaseUrl()
+
+ val hasFallback: Boolean
+ get() = fallbackBaseUrl.isNotBlank() && !fallbackBaseUrl.equals(primaryBaseUrl, ignoreCase = true)
+
+ fun restEndpointUrls(): List =
+ endpointUrls("/rest/v1/")
+
+ fun fallbackUrlFor(requestUrl: String): String? {
+ if (!hasFallback || primaryBaseUrl.isBlank()) return null
+ val trimmedUrl = requestUrl.trim()
+ if (!trimmedUrl.startsWith(primaryBaseUrl, ignoreCase = true)) return null
+ return fallbackBaseUrl + trimmedUrl.substring(primaryBaseUrl.length)
+ }
+
+ fun shouldRetryWithFallback(requestUrl: String, cause: Throwable): Boolean {
+ return fallbackUrlFor(requestUrl) != null && cause.isOriginFailure()
+ }
+
+ fun shouldRetryWithFallback(requestUrl: String, statusCode: Int): Boolean {
+ return fallbackUrlFor(requestUrl) != null && statusCode.isOriginFailureStatus()
+ }
+
+ private fun endpointUrls(path: String): List =
+ buildList {
+ if (primaryBaseUrl.isNotBlank()) add(primaryBaseUrl + path)
+ if (hasFallback) add(fallbackBaseUrl + path)
+ }
+
+ private fun String.normalizedBaseUrl(): String =
+ trim().trimEnd('/')
+
+ private fun Int.isOriginFailureStatus(): Boolean =
+ this in 520..526 || this == 502 || this == 503 || this == 504
+
+ private fun Throwable.isOriginFailure(): Boolean {
+ val text = generateSequence(this) { it.cause }
+ .joinToString(separator = " ") { error ->
+ listOfNotNull(error::class.simpleName, error.message).joinToString(separator = " ")
+ }
+ .lowercase()
+
+ return originFailureMarkers.any(text::contains)
+ }
+
+ private val originFailureMarkers = listOf(
+ "ssl",
+ "tls",
+ "certificate",
+ "certpath",
+ "handshake",
+ "timeout",
+ "timed out",
+ "connection reset",
+ "failed to connect",
+ "unable to resolve host",
+ "network is unreachable",
+ "closed",
+ )
+}
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 5a1a1b581..36478de8e 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
@@ -7,8 +7,10 @@ import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.functions.Functions
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.realtime.Realtime
+import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.defaultRequest
import io.ktor.http.HttpHeaders
+import io.ktor.http.takeFrom
object SupabaseProvider {
@OptIn(SupabaseInternal::class)
@@ -19,6 +21,28 @@ object SupabaseProvider {
supabaseKey = SupabaseConfig.ANON_KEY,
) {
httpConfig {
+ if (SupabaseEndpointConfig.hasFallback) {
+ install(HttpRequestRetry) {
+ retryOnExceptionIf(maxRetries = 1) { request, cause ->
+ SupabaseEndpointConfig.shouldRetryWithFallback(
+ requestUrl = request.url.buildString(),
+ cause = cause,
+ )
+ }
+ retryIf(maxRetries = 1) { request, response ->
+ SupabaseEndpointConfig.shouldRetryWithFallback(
+ requestUrl = request.url.toString(),
+ statusCode = response.status.value,
+ )
+ }
+ modifyRequest { request ->
+ SupabaseEndpointConfig.fallbackUrlFor(request.url.buildString())?.let { fallbackUrl ->
+ request.url.takeFrom(fallbackUrl)
+ }
+ }
+ constantDelay(millis = 100)
+ }
+ }
defaultRequest {
headers.append(HttpHeaders.UserAgent, userAgent)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
index e5967b23a..d4e6ab18d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt
@@ -1,23 +1,62 @@
package com.nuvio.app.features.settings
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.nuvio.app.core.ui.NuvioTokens
+import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache
import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_done
import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_subtitle
import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile
import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile_description
import nuvio.composeapp.generated.resources.settings_advanced_section_cache
+import nuvio.composeapp.generated.resources.settings_advanced_section_diagnostics
import nuvio.composeapp.generated.resources.settings_advanced_section_startup
+import nuvio.composeapp.generated.resources.settings_advanced_sentry_reports
+import nuvio.composeapp.generated.resources.settings_advanced_sentry_reports_subtitle
+import nuvio.composeapp.generated.resources.sentry_disable_dialog_subtitle
+import nuvio.composeapp.generated.resources.sentry_disable_dialog_title
+import nuvio.composeapp.generated.resources.sentry_enable_dialog_subtitle
+import nuvio.composeapp.generated.resources.sentry_enable_dialog_title
+import nuvio.composeapp.generated.resources.sentry_help_body
+import nuvio.composeapp.generated.resources.sentry_help_title
+import nuvio.composeapp.generated.resources.sentry_keep_enabled
+import nuvio.composeapp.generated.resources.sentry_not_sent_body
+import nuvio.composeapp.generated.resources.sentry_not_sent_title
+import nuvio.composeapp.generated.resources.sentry_sent_body
+import nuvio.composeapp.generated.resources.sentry_sent_title
+import nuvio.composeapp.generated.resources.sentry_turn_off
+import nuvio.composeapp.generated.resources.sentry_turn_on
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.advancedSettingsContent(
@@ -40,6 +79,43 @@ internal fun LazyListScope.advancedSettingsContent(
}
}
}
+ if (SentrySettingsRepository.isSupported) {
+ item {
+ val sentryEnabledFlow = remember {
+ SentrySettingsRepository.ensureLoaded()
+ SentrySettingsRepository.enabled
+ }
+ val sentryEnabled by sentryEnabledFlow.collectAsStateWithLifecycle()
+ var showSentryDialog by rememberSaveable { mutableStateOf(false) }
+
+ SettingsSection(
+ title = stringResource(Res.string.settings_advanced_section_diagnostics),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_advanced_sentry_reports),
+ description = stringResource(Res.string.settings_advanced_sentry_reports_subtitle),
+ checked = sentryEnabled,
+ isTablet = isTablet,
+ onCheckedChange = { showSentryDialog = true },
+ )
+ }
+ }
+
+ if (showSentryDialog) {
+ SentrySettingsDialog(
+ enabled = sentryEnabled,
+ onConfirm = {
+ SentrySettingsRepository.setEnabled(!sentryEnabled)
+ },
+ onDismiss = {
+ showSentryDialog = false
+ },
+ )
+ }
+ }
+ }
item {
SettingsSection(
title = stringResource(Res.string.settings_advanced_section_cache),
@@ -72,3 +148,132 @@ internal fun LazyListScope.advancedSettingsContent(
}
}
}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun SentrySettingsDialog(
+ enabled: Boolean,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val tokens = MaterialTheme.nuvio
+ BasicAlertDialog(
+ onDismissRequest = onDismiss,
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = tokens.colors.surfaceDialog,
+ shape = tokens.shapes.dialog,
+ ) {
+ Column(
+ modifier = Modifier.padding(tokens.spacing.dialogPadding),
+ ) {
+ Text(
+ text = stringResource(
+ if (enabled) {
+ Res.string.sentry_disable_dialog_title
+ } else {
+ Res.string.sentry_enable_dialog_title
+ },
+ ),
+ style = MaterialTheme.typography.titleLarge,
+ color = tokens.colors.textPrimary,
+ )
+ Spacer(modifier = Modifier.height(tokens.spacing.controlGap))
+ Text(
+ text = stringResource(
+ if (enabled) {
+ Res.string.sentry_disable_dialog_subtitle
+ } else {
+ Res.string.sentry_enable_dialog_subtitle
+ },
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ color = tokens.colors.textMuted,
+ )
+ Spacer(modifier = Modifier.height(NuvioTokens.Space.s18))
+ Column(
+ verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s12),
+ ) {
+ SentryInfoSection(
+ title = stringResource(Res.string.sentry_help_title),
+ body = stringResource(Res.string.sentry_help_body),
+ )
+ SentryInfoSection(
+ title = stringResource(Res.string.sentry_sent_title),
+ body = stringResource(Res.string.sentry_sent_body),
+ )
+ SentryInfoSection(
+ title = stringResource(Res.string.sentry_not_sent_title),
+ body = stringResource(Res.string.sentry_not_sent_body),
+ )
+ }
+ Spacer(modifier = Modifier.height(NuvioTokens.Space.s18))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Button(
+ onClick = onDismiss,
+ shape = tokens.shapes.button,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = tokens.colors.surfaceCard,
+ contentColor = tokens.colors.textPrimary,
+ ),
+ ) {
+ Text(
+ text = stringResource(
+ if (enabled) {
+ Res.string.sentry_keep_enabled
+ } else {
+ Res.string.action_cancel
+ },
+ ),
+ )
+ }
+ Spacer(modifier = Modifier.width(NuvioTokens.Space.s10))
+ Button(
+ onClick = {
+ onConfirm()
+ onDismiss()
+ },
+ shape = tokens.shapes.button,
+ ) {
+ Text(
+ text = stringResource(
+ if (enabled) {
+ Res.string.sentry_turn_off
+ } else {
+ Res.string.sentry_turn_on
+ },
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SentryInfoSection(
+ title: String,
+ body: String,
+) {
+ val tokens = MaterialTheme.nuvio
+ Column(
+ verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ color = tokens.colors.textPrimary,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = tokens.colors.textMuted,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsRepository.kt
new file mode 100644
index 000000000..31961b30c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsRepository.kt
@@ -0,0 +1,32 @@
+package com.nuvio.app.features.settings
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+internal object SentrySettingsRepository {
+ val isSupported: Boolean
+ get() = SentrySettingsPlatform.crashReportsSupported
+
+ private val _enabled = MutableStateFlow(true)
+ val enabled: StateFlow = _enabled.asStateFlow()
+
+ private var hasLoaded = false
+
+ fun ensureLoaded() {
+ if (hasLoaded) return
+ loadFromDisk()
+ }
+
+ private fun loadFromDisk() {
+ hasLoaded = true
+ _enabled.value = SentrySettingsStorage.loadEnabled() ?: true
+ }
+
+ fun setEnabled(enabled: Boolean) {
+ ensureLoaded()
+ if (_enabled.value == enabled) return
+ _enabled.value = enabled
+ SentrySettingsStorage.saveEnabled(enabled)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.kt
new file mode 100644
index 000000000..d10301ed6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.kt
@@ -0,0 +1,10 @@
+package com.nuvio.app.features.settings
+
+internal expect object SentrySettingsPlatform {
+ val crashReportsSupported: Boolean
+}
+
+internal expect object SentrySettingsStorage {
+ fun loadEnabled(): Boolean?
+ fun saveEnabled(enabled: Boolean)
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index ea697167c..95ade90ce 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -406,6 +406,18 @@ internal fun settingsSearchEntries(
category = advancedCategory,
icon = Icons.Rounded.Tune,
)
+ if (SentrySettingsRepository.isSupported) {
+ addRow(
+ page = SettingsPage.Advanced,
+ key = "sentry-crash-reports",
+ title = stringResource(Res.string.settings_advanced_sentry_reports),
+ description = stringResource(Res.string.settings_advanced_sentry_reports_subtitle),
+ pageLabel = advancedPage,
+ section = stringResource(Res.string.settings_advanced_section_diagnostics),
+ category = advancedCategory,
+ icon = Icons.Rounded.Tune,
+ )
+ }
addRow(
page = SettingsPage.Advanced,
key = "clear-cw-cache",
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.ios.kt
new file mode 100644
index 000000000..f80fa1583
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/SentrySettingsStorage.ios.kt
@@ -0,0 +1,24 @@
+package com.nuvio.app.features.settings
+
+import platform.Foundation.NSUserDefaults
+
+internal actual object SentrySettingsPlatform {
+ actual val crashReportsSupported: Boolean = false
+}
+
+internal actual object SentrySettingsStorage {
+ private const val enabledKey = "sentry_enabled"
+
+ actual fun loadEnabled(): Boolean? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ return if (defaults.objectForKey(enabledKey) != null) {
+ defaults.boolForKey(enabledKey)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveEnabled(enabled: Boolean) {
+ NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = enabledKey)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5a1a9bba3..949d47262 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -30,6 +30,8 @@ quickjsKt = "1.0.5"
ksoup = "0.2.6"
reorderable = "3.0.0"
desugarJdkLibs = "2.1.5"
+sentry = "8.47.0"
+sentryGradle = "6.14.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -84,6 +86,7 @@ quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt"
ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
+sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
@@ -93,3 +96,4 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+sentry-android-gradle = { id = "io.sentry.android.gradle", version.ref = "sentryGradle" }