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!
This commit is contained in:
damontecres 2025-10-26 07:35:42 -04:00 committed by GitHub
parent 05f3811149
commit 2667090485
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 278 additions and 41 deletions

View file

@ -231,6 +231,11 @@ dependencies {
implementation(libs.multiplatform.markdown.renderer) implementation(libs.multiplatform.markdown.renderer)
implementation(libs.multiplatform.markdown.renderer.m3) implementation(libs.multiplatform.markdown.renderer.m3)
implementation(libs.programguide) 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(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -1,10 +1,20 @@
package com.github.damontecres.wholphin package com.github.damontecres.wholphin
import android.app.Application import android.app.Application
import android.os.Build
import android.util.Log 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 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 import timber.log.Timber
@OptIn(ExperimentalComposeRuntimeApi::class)
@HiltAndroidApp @HiltAndroidApp
class WholphinApplication : Application() { class WholphinApplication : Application() {
init { 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 { companion object {

View file

@ -1,11 +1,15 @@
package com.github.damontecres.wholphin.data package com.github.damontecres.wholphin.data
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
@ -27,11 +31,14 @@ import javax.inject.Singleton
class ServerRepository class ServerRepository
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
val jellyfin: Jellyfin, val jellyfin: Jellyfin,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val apiClient: ApiClient, val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>, val userPreferencesDataStore: DataStore<AppPreferences>,
) { ) {
private val sharedPreferences = getServerSharedPreferences(context)
private var _currentServer by mutableStateOf<JellyfinServer?>(null) private var _currentServer by mutableStateOf<JellyfinServer?>(null)
val currentServer get() = _currentServer val currentServer get() = _currentServer
private var _currentUser by mutableStateOf<JellyfinUser?>(null) private var _currentUser by mutableStateOf<JellyfinUser?>(null)
@ -102,6 +109,10 @@ class ServerRepository
_currentServer = updatedServer _currentServer = updatedServer
_currentUser = updatedUser _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) 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"
}
} }

View file

@ -3,7 +3,10 @@ package com.github.damontecres.wholphin.preferences
import android.content.Context import android.content.Context
import androidx.annotation.ArrayRes import androidx.annotation.ArrayRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R 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.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -533,6 +536,34 @@ sealed interface AppPreference<T> {
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, 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, title = R.string.more,
preferences = preferences =
listOf( listOf(
AppPreference.SendAppLogs,
AppPreference.SendCrashReports,
AppPreference.ClearImageCache, AppPreference.ClearImageCache,
AppPreference.OssLicenseInfo, AppPreference.OssLicenseInfo,
), ),

View file

@ -68,46 +68,48 @@ class DebugViewModel
} }
} }
fun getLogCatLines(): List<LogcatLine> { companion object {
val lineCount = 500 fun getLogCatLines(): List<LogcatLine> {
val args = val lineCount = 500
buildList { val args =
add("logcat") buildList {
add("-d") add("logcat")
add("-t") add("-d")
add(lineCount.toString()) add("-t")
addAll(THIRD_PARTY_TAGS) add(lineCount.toString())
add("*:V") addAll(THIRD_PARTY_TAGS)
} add("*:V")
val process = ProcessBuilder().command(args).redirectErrorStream(true).start()
val logLines = mutableListOf<LogcatLine>()
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++ val process = ProcessBuilder().command(args).redirectErrorStream(true).start()
val logLines = mutableListOf<LogcatLine>()
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 { return logLines
process.destroy()
} }
return logLines
} }
} }

View file

@ -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 -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.preferences package com.github.damontecres.wholphin.ui.preferences
import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel 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.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences 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.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain 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.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PreferencesViewModel class PreferencesViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
val preferenceDataStore: DataStore<AppPreferences>, val preferenceDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val rememberTabManager: RememberTabManager, private val rememberTabManager: RememberTabManager,
val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
val serverPreferencesDao: ServerPreferencesDao, private val serverPreferencesDao: ServerPreferencesDao,
private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo,
) : ViewModel(), ) : ViewModel(),
RememberTabManager by rememberTabManager { RememberTabManager by rememberTabManager {
private lateinit var allNavDrawerItems: List<NavDrawerItem> private lateinit var allNavDrawerItems: List<NavDrawerItem>
@ -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}")
}
}
} }

View file

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

View file

@ -77,4 +77,5 @@ message AppPreferences {
bool auto_check_for_updates = 6; bool auto_check_for_updates = 6;
string update_url = 7; string update_url = 7;
bool send_crash_reports = 8;
} }

View file

@ -92,5 +92,9 @@
<string name="profile_specific_settings">User Profile Settings</string> <string name="profile_specific_settings">User Profile Settings</string>
<string name="nav_drawer_pins_summary">Choose the default items to show, others will be hidden</string> <string name="nav_drawer_pins_summary">Choose the default items to show, others will be hidden</string>
<string name="combine_continue_next_summary">Applies to TV Series only</string> <string name="combine_continue_next_summary">Applies to TV Series only</string>
<string name="send_crash_reports">Send Crash Reports</string>
<string name="send_crash_reports_summary">Will try to send to last connected server</string>
<string name="send_app_logs">Send app logs to current server</string>
<string name="send_app_logs_summary">Useful for debugging</string>
</resources> </resources>

View file

@ -1,6 +1,9 @@
[versions] [versions]
aboutLibraries = "12.2.4" aboutLibraries = "12.2.4"
acra = "5.13.1"
agp = "8.13.0" agp = "8.13.0"
auto-service = "1.1.1"
autoServiceKsp = "1.2.0"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
kotlin = "2.2.20" kotlin = "2.2.20"
@ -36,6 +39,9 @@ roomTesting = "2.8.2"
[libraries] [libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", 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-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } 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" } 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 = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }