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 {
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)

View file

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

View file

@ -7,4 +7,5 @@ plugins {
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) 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
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
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<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir)
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
@ -243,6 +290,16 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("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<KotlinCompilationTask<*>>().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)

View file

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

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.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()

View file

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

View file

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

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_results_section">RESULTS</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_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_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_subtitle">Remove cached Continue Watching data and refresh watch progress</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.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 {

View file

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

View file

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

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,
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",

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