From 2667090485bd442631e1be79327e76ba85cea586 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 26 Oct 2025 07:35:42 -0400 Subject: [PATCH] Send crash reports to the server (#65) Adds ability to send app crash reports to the last connected server. The app prompts you before sending. There is an advanced settings to disable this as well. Additionally, there is an advanced settings button to send the app logs to the current server. The resulting reports can be found on the server's web UI under Dashboard->Logs If sharing the logs, make sure there's no private information present! --- app/build.gradle.kts | 5 ++ .../wholphin/WholphinApplication.kt | 52 +++++++++++++ .../wholphin/data/ServerRepository.kt | 22 ++++++ .../wholphin/preferences/AppPreference.kt | 33 ++++++++ .../wholphin/ui/detail/DebugPage.kt | 76 ++++++++++--------- .../ui/preferences/PreferencesContent.kt | 13 ++++ .../ui/preferences/PreferencesViewModel.kt | 35 ++++++++- .../wholphin/util/CrashReportSenderFactory.kt | 70 +++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 4 + gradle/libs.versions.toml | 8 ++ 11 files changed, 278 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df0c5200..3ca895ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -231,6 +231,11 @@ dependencies { implementation(libs.multiplatform.markdown.renderer) implementation(libs.multiplatform.markdown.renderer.m3) implementation(libs.programguide) + implementation(libs.acra.http) + implementation(libs.acra.dialog) + implementation(libs.acra.limiter) + compileOnly(libs.auto.service.annotations) + ksp(libs.auto.service.ksp) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 93c8e496..81f9265d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -1,10 +1,20 @@ package com.github.damontecres.wholphin import android.app.Application +import android.os.Build import android.util.Log +import androidx.compose.runtime.Composer +import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.compose.runtime.tooling.ComposeStackTraceMode import dagger.hilt.android.HiltAndroidApp +import org.acra.ACRA +import org.acra.ReportField +import org.acra.config.dialog +import org.acra.data.StringFormat +import org.acra.ktx.initAcra import timber.log.Timber +@OptIn(ExperimentalComposeRuntimeApi::class) @HiltAndroidApp class WholphinApplication : Application() { init { @@ -31,6 +41,48 @@ class WholphinApplication : Application() { }, ) } + + Composer.setDiagnosticStackTraceMode( + if (BuildConfig.DEBUG) ComposeStackTraceMode.SourceInformation else ComposeStackTraceMode.GroupKeys, + ) + } + + override fun onCreate() { + super.onCreate() + initAcra { + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.JSON + excludeMatchingSharedPreferencesKeys = listOf() + reportContent = + listOf( + ReportField.ANDROID_VERSION, + ReportField.APP_VERSION_CODE, + ReportField.APP_VERSION_NAME, + ReportField.BRAND, + // ReportField.BUILD_CONFIG, + // ReportField.BUILD, + ReportField.CUSTOM_DATA, + ReportField.LOGCAT, + ReportField.PHONE_MODEL, + ReportField.PRODUCT, + ReportField.REPORT_ID, + ReportField.SHARED_PREFERENCES, + ReportField.STACK_TRACE, + ReportField.USER_COMMENT, + ReportField.USER_CRASH_DATE, + ) + dialog { + text = + "Wholphin has crashed! Would you like to attempt to " + + "send a crash report to your Jellyfin server?" + title = "Wholphin Crash Report" + positiveButtonText = "Send" + negativeButtonText = "Do not send" + } + reportSendFailureToast = "Crash report failed to send" + reportSendSuccessToast = "Sent crash report!" + } + ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString()) } companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index e9c812fa..13bb3106 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -1,11 +1,15 @@ package com.github.damontecres.wholphin.data +import android.content.Context +import android.content.SharedPreferences import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.content.edit import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.ui.toServerString +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.Jellyfin @@ -27,11 +31,14 @@ import javax.inject.Singleton class ServerRepository @Inject constructor( + @param:ApplicationContext private val context: Context, val jellyfin: Jellyfin, val serverDao: JellyfinServerDao, val apiClient: ApiClient, val userPreferencesDataStore: DataStore, ) { + private val sharedPreferences = getServerSharedPreferences(context) + private var _currentServer by mutableStateOf(null) val currentServer get() = _currentServer private var _currentUser by mutableStateOf(null) @@ -102,6 +109,10 @@ class ServerRepository _currentServer = updatedServer _currentUser = updatedUser } + sharedPreferences.edit(true) { + putString(SERVER_URL_KEY, updatedServer.url) + putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) + } } /** @@ -200,4 +211,15 @@ class ServerRepository serverDao.deleteServer(server.id) } } + + companion object { + fun getServerSharedPreferences(context: Context): SharedPreferences = + context.getSharedPreferences( + "${context.packageName}_server", + Context.MODE_PRIVATE, + ) + + const val SERVER_URL_KEY = "current.server" + const val ACCESS_TOKEN_KEY = "current.accessToken" + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 3e275ad3..c8d035cd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -3,7 +3,10 @@ package com.github.damontecres.wholphin.preferences import android.content.Context import androidx.annotation.ArrayRes import androidx.annotation.StringRes +import androidx.core.content.edit +import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption @@ -533,6 +536,34 @@ sealed interface AppPreference { getter = { }, setter = { prefs, _ -> prefs }, ) + + val SendCrashReports = + AppSwitchPreference( + title = R.string.send_crash_reports, + defaultValue = true, + getter = { + PreferenceManager + .getDefaultSharedPreferences(WholphinApplication.instance) + .getBoolean("acra.enable", true) + }, + setter = { prefs, value -> + PreferenceManager + .getDefaultSharedPreferences(WholphinApplication.instance) + .edit { + putBoolean("acra.enable", value) + } + prefs.update { sendCrashReports = value } + }, + summary = R.string.send_crash_reports_summary, + ) + + val SendAppLogs = + AppClickablePreference( + title = R.string.send_app_logs, + summary = R.string.send_app_logs_summary, + getter = { }, + setter = { prefs, _ -> prefs }, + ) } } @@ -626,6 +657,8 @@ val advancedPreferences = title = R.string.more, preferences = listOf( + AppPreference.SendAppLogs, + AppPreference.SendCrashReports, AppPreference.ClearImageCache, AppPreference.OssLicenseInfo, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index db016b74..463d2410 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -68,46 +68,48 @@ class DebugViewModel } } - fun getLogCatLines(): List { - val lineCount = 500 - val args = - buildList { - add("logcat") - add("-d") - add("-t") - add(lineCount.toString()) - addAll(THIRD_PARTY_TAGS) - add("*:V") - } - val process = ProcessBuilder().command(args).redirectErrorStream(true).start() - val logLines = mutableListOf() - try { - val reader = BufferedReader(InputStreamReader(process.inputStream)) - var count = 0 - - while (count < lineCount) { - val line = reader.readLine() - if (line != null) { - val level = line.split(" ").getOrNull(4) - val logLevel = - when (level?.uppercase()) { - "V" -> Log.VERBOSE - "D" -> Log.DEBUG - "I" -> Log.INFO - "W" -> Log.WARN - "E" -> Log.ERROR - else -> Log.VERBOSE - } - logLines.add(LogcatLine(logLevel, line)) - } else { - break + companion object { + fun getLogCatLines(): List { + val lineCount = 500 + val args = + buildList { + add("logcat") + add("-d") + add("-t") + add(lineCount.toString()) + addAll(THIRD_PARTY_TAGS) + add("*:V") } - count++ + val process = ProcessBuilder().command(args).redirectErrorStream(true).start() + val logLines = mutableListOf() + try { + val reader = BufferedReader(InputStreamReader(process.inputStream)) + var count = 0 + + while (count < lineCount) { + val line = reader.readLine() + if (line != null) { + val level = line.split(" ").getOrNull(4) + val logLevel = + when (level?.uppercase()) { + "V" -> Log.VERBOSE + "D" -> Log.DEBUG + "I" -> Log.INFO + "W" -> Log.WARN + "E" -> Log.ERROR + else -> Log.VERBOSE + } + logLines.add(LogcatLine(logLevel, line)) + } else { + break + } + count++ + } + } finally { + process.destroy() } - } finally { - process.destroy() + return logLines } - return logLines } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 71d949ae..62b21a97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -283,6 +283,19 @@ fun PreferencesContent( } } + AppPreference.SendAppLogs -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.sendAppLogs() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 4cb9de9f..ad4b65ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.preferences +import android.content.Context import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -11,25 +12,36 @@ import com.github.damontecres.wholphin.data.isPinned import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.detail.DebugViewModel import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.clientLogApi +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Inject @HiltViewModel class PreferencesViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, val preferenceDataStore: DataStore, val navigationManager: NavigationManager, - val rememberTabManager: RememberTabManager, - val serverRepository: ServerRepository, - val navDrawerItemRepository: NavDrawerItemRepository, - val serverPreferencesDao: ServerPreferencesDao, + private val rememberTabManager: RememberTabManager, + private val serverRepository: ServerRepository, + private val navDrawerItemRepository: NavDrawerItemRepository, + private val serverPreferencesDao: ServerPreferencesDao, + private val deviceInfo: DeviceInfo, + private val clientInfo: ClientInfo, ) : ViewModel(), RememberTabManager by rememberTabManager { private lateinit var allNavDrawerItems: List @@ -77,4 +89,19 @@ class PreferencesViewModel } } } + + fun sendAppLogs() { + viewModelScope.launchIO(ExceptionHandler(true)) { + val logcat = DebugViewModel.getLogCatLines().joinToString("\n") { it.text } + val body = + """ + Send App Logs + $clientInfo + $deviceInfo + + """.trimIndent() + logcat + val response by api.clientLogApi.logFile(body) + showToast(context, "Sent! Filename=${response.fileName}") + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt new file mode 100644 index 00000000..420d718e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -0,0 +1,70 @@ +package com.github.damontecres.wholphin.util + +import android.content.Context +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.hilt.AppModule +import com.google.auto.service.AutoService +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.acra.config.CoreConfiguration +import org.acra.data.CrashReportData +import org.acra.sender.ReportSender +import org.acra.sender.ReportSenderException +import org.acra.sender.ReportSenderFactory +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.extensions.clientLogApi +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.createJellyfin +import timber.log.Timber + +@AutoService(ReportSenderFactory::class) +class CrashReportSenderFactory : ReportSenderFactory { + override fun create( + context: Context, + config: CoreConfiguration, + ): ReportSender = CrashReportSender() + + override fun enabled(config: CoreConfiguration): Boolean = true +} + +class CrashReportSender : ReportSender { + override fun send( + context: Context, + errorContent: CrashReportData, + ) { + Timber.v("Attempting to send crash report") + val prefs = ServerRepository.getServerSharedPreferences(context) + val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null) + val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null) + if (serverUrl != null && accessToken != null) { + try { + val okHttpClient = + OkHttpClient + .Builder() + .build() + val okHttpFactory = OkHttpFactory(okHttpClient) + val api = + createJellyfin { + this.context = context + clientInfo = AppModule.clientInfo() + deviceInfo = AppModule.deviceInfo(context) + apiClientFactory = okHttpFactory + socketConnectionFactory = okHttpFactory + minimumServerVersion = Jellyfin.minimumVersion + }.createApi(baseUrl = serverUrl, accessToken = accessToken) + + runBlocking { + val filename = + api.clientLogApi + .logFile(errorContent.toJSON()) + .content.fileName + Timber.i("Sent report to $serverUrl, filename=$filename") + } + } catch (ex: Exception) { + throw ReportSenderException("Exception while sending crash report", ex) + } + } else { + throw ReportSenderException("Could not find valid server and/or credentials to use") + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 2f8613a9..0b3ff8b0 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -77,4 +77,5 @@ message AppPreferences { bool auto_check_for_updates = 6; string update_url = 7; + bool send_crash_reports = 8; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a65599d8..1a64c98d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,5 +92,9 @@ User Profile Settings Choose the default items to show, others will be hidden Applies to TV Series only + Send Crash Reports + Will try to send to last connected server + Send app logs to current server + Useful for debugging diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5fca3a60..9e71e2e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,9 @@ [versions] aboutLibraries = "12.2.4" +acra = "5.13.1" agp = "8.13.0" +auto-service = "1.1.1" +autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.3.0" kotlin = "2.2.20" @@ -36,6 +39,9 @@ roomTesting = "2.8.2" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } +acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } +acra-http = { module = "ch.acra:acra-http", version.ref = "acra" } +acra-limiter = { module = "ch.acra:acra-limiter", version.ref = "acra" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -55,6 +61,8 @@ androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.re androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } +auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }