feat: sentry diagnostics

This commit is contained in:
tapframe 2026-07-04 14:29:45 +05:30
parent 0e78513031
commit b3c96bd7b3
22 changed files with 745 additions and 6 deletions

View file

@ -16,6 +16,7 @@ fun readXcconfigValue(file: File, key: String): String? {
plugins { plugins {
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
alias(libs.plugins.sentry.android.gradle)
} }
val localProps = Properties().apply { 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 releaseKeyAlias = localProps.getProperty("NUVIO_RELEASE_KEY_ALIAS")?.takeIf { it.isNotBlank() }
val releaseKeyPassword = localProps.getProperty("NUVIO_RELEASE_KEY_PASSWORD")?.takeIf { it.isNotBlank() } val releaseKeyPassword = localProps.getProperty("NUVIO_RELEASE_KEY_PASSWORD")?.takeIf { it.isNotBlank() }
val releaseKeystore = releaseStoreFile?.let(rootProject::file) 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 appVersionConfigFile = rootProject.file("iosApp/Configuration/Version.xcconfig")
val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_VERSION") val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_VERSION")
?: error("MARKETING_VERSION is missing from ${appVersionConfigFile.path}") ?: 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 { dependencies {
implementation(project(":composeApp")) implementation(project(":composeApp"))
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)

View file

@ -16,6 +16,10 @@
android:localeConfig="@xml/locale_config" android:localeConfig="@xml/locale_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Nuvio"> android:theme="@style/Theme.Nuvio">
<meta-data
android:name="io.sentry.auto-init"
android:value="false" />
<activity <activity
android:exported="true" android:exported="true"
android:name="com.nuvio.app.MainActivity" android:name="com.nuvio.app.MainActivity"

View file

@ -7,4 +7,5 @@ plugins {
alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.sentry.android.gradle) apply false
} }

View file

@ -31,6 +31,18 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
@get:Input @get:Input
abstract val supabaseAnonKey: Property<String> abstract val supabaseAnonKey: Property<String>
@get:Input
abstract val supabaseFallbackUrl: Property<String>
@get:Input
abstract val sentryDsn: Property<String>
@get:Input
abstract val sentryEnvironment: Property<String>
@get:Input
abstract val realtimeSyncEnabled: Property<Boolean>
@TaskAction @TaskAction
fun generate() { fun generate() {
val props = Properties() val props = Properties()
@ -46,6 +58,34 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|object SupabaseConfig { |object SupabaseConfig {
| const val URL = "${supabaseUrl.get()}" | const val URL = "${supabaseUrl.get()}"
| const val ANON_KEY = "${supabaseAnonKey.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() """.trimMargin()
) )
@ -236,6 +276,13 @@ fun runtimeConfigValue(key: String, fallback: String = ""): String =
?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() } ?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
?: fallback ?: 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<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") { val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir) outputDir.set(generatedRuntimeConfigDir)
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
@ -243,6 +290,16 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
appVersionCode.set(releaseAppVersionCode) appVersionCode.set(releaseAppVersionCode)
supabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL")) supabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL"))
supabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY")) 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<KotlinCompilationTask<*>>().configureEach { tasks.withType<KotlinCompilationTask<*>>().configureEach {
@ -321,6 +378,7 @@ kotlin {
implementation("com.google.code.gson:gson:2.11.0") implementation("com.google.code.gson:gson:2.11.0")
implementation("io.github.peerless2012:ass-media:0.4.0-beta01") implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.sentry.android)
implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.exoplayer.smoothstreaming) implementation(libs.androidx.media3.exoplayer.smoothstreaming)

View file

@ -10,6 +10,7 @@ import androidx.activity.SystemBarStyle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage 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.deeplink.handleAppUrl
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.core.sync.SyncClientIdentityStorage 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.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.search.SearchHistoryStorage 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.settings.ThemeSettingsStorage
import com.nuvio.app.features.trakt.TraktAuthStorage import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
@ -65,6 +67,8 @@ class MainActivity : AppCompatActivity() {
), ),
) )
ThemeSettingsStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext)
SentrySettingsStorage.initialize(applicationContext)
SentryInitializer.start(application)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.nuvio_background) window.setBackgroundDrawableResource(R.color.nuvio_background)
SyncClientIdentityStorage.initialize(applicationContext) SyncClientIdentityStorage.initialize(applicationContext)

View file

@ -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<String> {
val values = mutableListOf<String>()
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
}
}

View file

@ -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
}

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.addons
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
import com.nuvio.app.core.network.IPv4FirstDns import com.nuvio.app.core.network.IPv4FirstDns
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -84,6 +85,7 @@ private val addonHttpClient = OkHttpClient.Builder()
.writeTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(true) .followRedirects(true)
.followSslRedirects(true) .followSslRedirects(true)
.addInterceptor(SentryNetworkBreadcrumbInterceptor())
.proxy(Proxy.NO_PROXY) .proxy(Proxy.NO_PROXY)
.build() .build()

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
import com.nuvio.app.core.network.IPv4FirstDns import com.nuvio.app.core.network.IPv4FirstDns
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.net.HttpURLConnection import java.net.HttpURLConnection
@ -55,6 +56,7 @@ internal object PlayerPlaybackNetworking {
.followRedirects(true) .followRedirects(true)
.followSslRedirects(true) .followSslRedirects(true)
.retryOnConnectionFailure(true) .retryOnConnectionFailure(true)
.addInterceptor(SentryNetworkBreadcrumbInterceptor())
.build() .build()
} }

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -19,7 +20,11 @@ object SubtitleFileCache {
private const val SUBTITLE_CACHE_DIR = "subtitles" private const val SUBTITLE_CACHE_DIR = "subtitles"
private var appContext: Context? = null 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) { fun initialize(context: Context) {
appContext = context.applicationContext appContext = context.applicationContext

View file

@ -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()
}
}

View file

@ -455,9 +455,25 @@
<string name="settings_search_placeholder">Search settings...</string> <string name="settings_search_placeholder">Search settings...</string>
<string name="settings_search_results_section">RESULTS</string> <string name="settings_search_results_section">RESULTS</string>
<string name="settings_advanced_section_startup">STARTUP</string> <string name="settings_advanced_section_startup">STARTUP</string>
<string name="settings_advanced_section_diagnostics">DIAGNOSTICS</string>
<string name="settings_advanced_section_cache">CACHE</string> <string name="settings_advanced_section_cache">CACHE</string>
<string name="settings_advanced_remember_last_profile">Remember Last Profile</string> <string name="settings_advanced_remember_last_profile">Remember Last Profile</string>
<string name="settings_advanced_remember_last_profile_description">Remember last selected profile at startup</string> <string name="settings_advanced_remember_last_profile_description">Remember last selected profile at startup</string>
<string name="settings_advanced_sentry_reports">Sentry Crash Reports</string>
<string name="settings_advanced_sentry_reports_subtitle">Send crash and ANR reports with safe context. On by default.</string>
<string name="sentry_enable_dialog_title">Turn on crash reports?</string>
<string name="sentry_enable_dialog_subtitle">Crash reports help identify device-specific failures without asking you to reproduce the issue with ADB logs.</string>
<string name="sentry_disable_dialog_title">Turn off crash reports?</string>
<string name="sentry_disable_dialog_subtitle">Future crashes and current ANRs from this device will not be reported. This can make device-specific bugs much harder to fix.</string>
<string name="sentry_help_title">How it helps</string>
<string name="sentry_help_body">Reports are grouped by app version, device model, Android version, and stack trace so the most common real crashes can be fixed first.</string>
<string name="sentry_sent_title">Sent</string>
<string name="sentry_sent_body">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.</string>
<string name="sentry_not_sent_title">Not sent</string>
<string name="sentry_not_sent_body">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.</string>
<string name="sentry_keep_enabled">Keep on</string>
<string name="sentry_turn_off">Turn off</string>
<string name="sentry_turn_on">Turn on</string>
<string name="settings_advanced_clear_cw_cache">Clear Continue Watching Cache</string> <string name="settings_advanced_clear_cw_cache">Clear Continue Watching Cache</string>
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached Continue Watching data and refresh watch progress</string> <string name="settings_advanced_clear_cw_cache_subtitle">Remove cached Continue Watching data and refresh watch progress</string>
<string name="settings_advanced_clear_cw_cache_done">Cache cleared</string> <string name="settings_advanced_clear_cw_cache_done">Cache cleared</string>

View file

@ -85,6 +85,7 @@ import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.sync.AppForegroundMonitor import com.nuvio.app.core.sync.AppForegroundMonitor
import com.nuvio.app.core.sync.ProfileSettingsSync 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.RealtimeSyncInvalidationService
import com.nuvio.app.core.sync.SyncManager import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.ui.NuvioNavigationBar import com.nuvio.app.core.ui.NuvioNavigationBar
@ -909,6 +910,11 @@ private fun MainAppContent(
} }
LaunchedEffect(authState, profileState.activeProfile?.profileIndex) { LaunchedEffect(authState, profileState.activeProfile?.profileIndex) {
if (!RealtimeSyncConfig.ENABLED) {
RealtimeSyncInvalidationService.stop()
return@LaunchedEffect
}
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
if (authenticatedState.isAnonymous) return@LaunchedEffect if (authenticatedState.isAnonymous) return@LaunchedEffect
@ -921,7 +927,12 @@ private fun MainAppContent(
DisposableEffect(authState, profileState.activeProfile?.profileIndex) { DisposableEffect(authState, profileState.activeProfile?.profileIndex) {
val authenticatedState = authState as? AuthState.Authenticated 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() RealtimeSyncInvalidationService.stop()
} }
onDispose { onDispose {

View file

@ -136,10 +136,12 @@ object NetworkStatusRepository {
return NetworkCondition.NoInternet return NetworkCondition.NoInternet
} }
val supabaseReachable = probeReachable( val supabaseReachable = SupabaseEndpointConfig.restEndpointUrls().any { url ->
url = "${SupabaseConfig.URL.trimEnd('/')}/rest/v1/", probeReachable(
url = url,
headers = mapOf("apikey" to SupabaseConfig.ANON_KEY), headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
) )
}
if (!supabaseReachable) { if (!supabaseReachable) {
return NetworkCondition.ServersUnreachable return NetworkCondition.ServersUnreachable
} }

View file

@ -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<String> =
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<String> =
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",
)
}

View file

@ -7,8 +7,10 @@ import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.functions.Functions import io.github.jan.supabase.functions.Functions
import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.realtime.Realtime import io.github.jan.supabase.realtime.Realtime
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.defaultRequest
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.takeFrom
object SupabaseProvider { object SupabaseProvider {
@OptIn(SupabaseInternal::class) @OptIn(SupabaseInternal::class)
@ -19,6 +21,28 @@ object SupabaseProvider {
supabaseKey = SupabaseConfig.ANON_KEY, supabaseKey = SupabaseConfig.ANON_KEY,
) { ) {
httpConfig { 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 { defaultRequest {
headers.append(HttpHeaders.UserAgent, userAgent) headers.append(HttpHeaders.UserAgent, userAgent)
} }

View file

@ -1,23 +1,62 @@
package com.nuvio.app.features.settings 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.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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.profiles.ProfileRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res 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
import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_done 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_clear_cw_cache_subtitle
import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile 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_remember_last_profile_description
import nuvio.composeapp.generated.resources.settings_advanced_section_cache 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_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 import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.advancedSettingsContent( 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 { item {
SettingsSection( SettingsSection(
title = stringResource(Res.string.settings_advanced_section_cache), 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,
)
}
}

View file

@ -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<Boolean> = _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)
}
}

View file

@ -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)
}

View file

@ -406,6 +406,18 @@ internal fun settingsSearchEntries(
category = advancedCategory, category = advancedCategory,
icon = Icons.Rounded.Tune, 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( addRow(
page = SettingsPage.Advanced, page = SettingsPage.Advanced,
key = "clear-cw-cache", key = "clear-cw-cache",

View file

@ -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)
}
}

View file

@ -30,6 +30,8 @@ quickjsKt = "1.0.5"
ksoup = "0.2.6" ksoup = "0.2.6"
reorderable = "3.0.0" reorderable = "3.0.0"
desugarJdkLibs = "2.1.5" desugarJdkLibs = "2.1.5"
sentry = "8.47.0"
sentryGradle = "6.14.0"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 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" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } 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" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
sentry-android-gradle = { id = "io.sentry.android.gradle", version.ref = "sentryGradle" }