mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
feat: sentry diagnostics
This commit is contained in:
parent
0e78513031
commit
b3c96bd7b3
22 changed files with 745 additions and 6 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue