mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +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 {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue