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