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