From 6b94582bc592ac88f20faee26e576e0feb1401bc Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Wed, 19 Feb 2025 20:42:34 +0100 Subject: [PATCH] Add button to report device profile to server for troubleshooting --- .../androidtv/telemetry/TelemetryService.kt | 32 ++--- .../ui/playback/PlaybackController.java | 13 +- .../screen/DeveloperPreferencesScreen.kt | 2 - .../PlaybackAdvancedPreferencesScreen.kt | 46 ++++++- .../androidtv/util/MarkdownBuilder.kt | 60 +++++++++ .../java/org/jellyfin/androidtv/util/Utils.kt | 31 ----- .../androidtv/util/profile/deviceProfile.kt | 19 +++ .../util/profile/deviceProfileReport.kt | 114 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + 9 files changed, 254 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/androidtv/util/MarkdownBuilder.kt create mode 100644 app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt diff --git a/app/src/main/java/org/jellyfin/androidtv/telemetry/TelemetryService.kt b/app/src/main/java/org/jellyfin/androidtv/telemetry/TelemetryService.kt index 12b8ae3e29..ed5f8ec2df 100644 --- a/app/src/main/java/org/jellyfin/androidtv/telemetry/TelemetryService.kt +++ b/app/src/main/java/org/jellyfin/androidtv/telemetry/TelemetryService.kt @@ -18,6 +18,11 @@ import org.acra.sender.ReportSenderFactory import org.jellyfin.androidtv.BuildConfig import org.jellyfin.androidtv.R import org.jellyfin.androidtv.preference.TelemetryPreferences +import org.jellyfin.androidtv.util.appendCodeBlock +import org.jellyfin.androidtv.util.appendItem +import org.jellyfin.androidtv.util.appendSection +import org.jellyfin.androidtv.util.appendValue +import org.jellyfin.androidtv.util.buildMarkdown import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder import java.net.HttpURLConnection import java.net.URL @@ -85,31 +90,7 @@ object TelemetryService { throw ReportSenderException("Unable to send crash report to server", e) } - private fun StringBuilder.appendSection(name: String, content: StringBuilder.() -> Unit) { - appendLine("### $name") - appendLine() - content() - appendLine() - } - - private fun StringBuilder.appendItem(name: String, value: StringBuilder.() -> Unit) { - append("***$name***: ") - value() - appendLine(" ") - } - - private fun StringBuilder.appendCodeBlock(language: String, code: String?) { - appendLine() - appendLine("```$language") - appendLine(code ?: "") - append("```") - } - - private fun StringBuilder.appendValue(value: String?) { - append("`", value ?: "", "`") - } - - private fun CrashReportData.toReport(): String = buildString { + private fun CrashReportData.toReport(): String = buildMarkdown { // Header appendLine("---") appendLine("client: Jellyfin for Android TV") @@ -118,6 +99,7 @@ object TelemetryService { appendLine("type: crash_report") appendLine("format: markdown") appendLine("---") + appendLine() // Content appendSection("Logs") { diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java b/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java index 6c237110bf..003047331b 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/PlaybackController.java @@ -19,7 +19,6 @@ import org.jellyfin.androidtv.data.model.DataRefreshService; import org.jellyfin.androidtv.preference.UserPreferences; import org.jellyfin.androidtv.preference.UserSettingPreferences; -import org.jellyfin.androidtv.preference.constant.AudioBehavior; import org.jellyfin.androidtv.preference.constant.NextUpBehavior; import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior; import org.jellyfin.androidtv.preference.constant.ZoomMode; @@ -475,9 +474,7 @@ public void onClick(DialogInterface dialog, int which) { // undo setting mSeekPosition for liveTV if (isLiveTv) mSeekPosition = -1; - int maxBitrate = Utils.getMaxBitrate(userPreferences.getValue()); - Timber.d("Max bitrate is: %d", maxBitrate); - VideoOptions internalOptions = buildExoPlayerOptions(forcedSubtitleIndex, item, maxBitrate); + VideoOptions internalOptions = buildExoPlayerOptions(forcedSubtitleIndex, item); playInternal(getCurrentlyPlayingItem(), position, internalOptions); mPlaybackState = PlaybackState.BUFFERING; @@ -493,7 +490,7 @@ public void onClick(DialogInterface dialog, int which) { } @NonNull - private VideoOptions buildExoPlayerOptions(@Nullable Integer forcedSubtitleIndex, BaseItemDto item, int maxBitrate) { + private VideoOptions buildExoPlayerOptions(@Nullable Integer forcedSubtitleIndex, BaseItemDto item) { VideoOptions internalOptions = new VideoOptions(); internalOptions.setItemId(item.getId()); internalOptions.setMediaSources(item.getMediaSources()); @@ -511,10 +508,8 @@ private VideoOptions buildExoPlayerOptions(@Nullable Integer forcedSubtitleIndex internalOptions.setMediaSourceId(currentMediaSource.getId()); } DeviceProfile internalProfile = DeviceProfileKt.createDeviceProfile( - maxBitrate, - !internalOptions.getEnableDirectStream(), - userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()), - userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO + userPreferences.getValue(), + !internalOptions.getEnableDirectStream() ); internalOptions.setProfile(internalProfile); return internalOptions; diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt b/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt index e1f42e9c45..b2344a3ad0 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt @@ -2,7 +2,6 @@ package org.jellyfin.androidtv.ui.preference.screen import android.text.format.Formatter import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi import org.jellyfin.androidtv.BuildConfig import org.jellyfin.androidtv.R import org.jellyfin.androidtv.preference.SystemPreferences @@ -64,7 +63,6 @@ class DeveloperPreferencesScreen : OptionsFragment() { bind(userPreferences, UserPreferences.preferExoPlayerFfmpeg) } - @OptIn(ExperimentalCoilApi::class) action { setTitle(R.string.clear_image_cache) content = getString(R.string.clear_image_cache_content, Formatter.formatFileSize(context, imageLoader.diskCache?.size ?: 0)) diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/PlaybackAdvancedPreferencesScreen.kt b/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/PlaybackAdvancedPreferencesScreen.kt index 9eaddc3baa..e536003339 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/PlaybackAdvancedPreferencesScreen.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/PlaybackAdvancedPreferencesScreen.kt @@ -1,6 +1,9 @@ package org.jellyfin.androidtv.ui.preference.screen import android.os.Build +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import org.jellyfin.androidtv.R import org.jellyfin.androidtv.constant.getQualityProfiles import org.jellyfin.androidtv.preference.UserPreferences @@ -8,16 +11,23 @@ import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior import org.jellyfin.androidtv.preference.constant.ZoomMode import org.jellyfin.androidtv.ui.preference.custom.DurationSeekBarPreference import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment +import org.jellyfin.androidtv.ui.preference.dsl.action import org.jellyfin.androidtv.ui.preference.dsl.checkbox import org.jellyfin.androidtv.ui.preference.dsl.enum import org.jellyfin.androidtv.ui.preference.dsl.list import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen import org.jellyfin.androidtv.ui.preference.dsl.seekbar import org.jellyfin.androidtv.util.TimeUtils +import org.jellyfin.androidtv.util.profile.createDeviceProfileReport +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.koin.android.ext.android.inject +import timber.log.Timber class PlaybackAdvancedPreferencesScreen : OptionsFragment() { + private val api: ApiClient by inject() private val userPreferences: UserPreferences by inject() + private var deviceProfileReported = false override val screen by optionsScreen { setTitle(R.string.pref_playback) @@ -84,7 +94,7 @@ class PlaybackAdvancedPreferencesScreen : OptionsFragment() { bind(userPreferences, UserPreferences.playerZoomMode) } - checkbox{ + checkbox { setTitle(R.string.pref_external_player) bind(userPreferences, UserPreferences.useExternalPlayer) } @@ -116,5 +126,39 @@ class PlaybackAdvancedPreferencesScreen : OptionsFragment() { bind(userPreferences, UserPreferences.ac3Enabled) } } + + category { + setTitle(R.string.pref_troubleshooting) + + action { + setTitle(R.string.pref_report_device_profile_title) + setContent(R.string.pref_report_device_profile_summary) + + depends { !deviceProfileReported } + + onActivate = { + deviceProfileReported = true + + lifecycleScope.launch { + runCatching { + api.clientLogApi.logFile(createDeviceProfileReport(context, userPreferences)).content + }.fold( + onSuccess = { result -> + Toast.makeText( + context, + getString(R.string.pref_report_device_profile_success, result.fileName), + Toast.LENGTH_LONG + ).show() + }, + onFailure = { error -> + Timber.e(error, "Failed to upload device profile") + Toast.makeText(context, R.string.pref_report_device_profile_failure, Toast.LENGTH_LONG).show() + deviceProfileReported = false + } + ) + } + } + } + } } } diff --git a/app/src/main/java/org/jellyfin/androidtv/util/MarkdownBuilder.kt b/app/src/main/java/org/jellyfin/androidtv/util/MarkdownBuilder.kt new file mode 100644 index 0000000000..970fba6e97 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/util/MarkdownBuilder.kt @@ -0,0 +1,60 @@ +package org.jellyfin.androidtv.util + +class MarkdownBuilder : Appendable, CharSequence { + private val stringBuilder = StringBuilder() + + override val length: Int get() = stringBuilder.length + override fun get(index: Int): Char = stringBuilder[index] + override fun subSequence(startIndex: Int, endIndex: Int) = stringBuilder.subSequence(startIndex, endIndex) + + override fun append(value: CharSequence?): MarkdownBuilder { + stringBuilder.append(value) + return this + } + + override fun append(value: CharSequence?, p1: Int, p2: Int): MarkdownBuilder { + stringBuilder.append(value) + return this + } + + override fun append(value: Char): MarkdownBuilder { + stringBuilder.append(value) + return this + } + + override fun toString(): String { + return stringBuilder.toString() + } +} + +inline fun buildMarkdown(builderAction: MarkdownBuilder.() -> Unit): String { + return MarkdownBuilder().apply(builderAction).toString() +} + +fun MarkdownBuilder.appendSection(name: String? = null, depth: Int = 3, content: MarkdownBuilder.() -> Unit) { + if (name != null) { + appendLine("${"#".repeat(depth)} $name") + } + + if (last().category != CharCategory.LINE_SEPARATOR) appendLine() + content() + appendLine() +} + +fun MarkdownBuilder.appendItem(name: String, value: MarkdownBuilder.() -> Unit) { + append("***$name***: ") + value() + appendLine(" ") +} + +fun MarkdownBuilder.appendCodeBlock(language: String, code: String?) { + if (last().category != CharCategory.LINE_SEPARATOR) appendLine() + + appendLine("```$language") + appendLine(code ?: "") + appendLine("```") +} + +fun MarkdownBuilder.appendValue(value: String?) { + append("`", value ?: "", "`") +} diff --git a/app/src/main/java/org/jellyfin/androidtv/util/Utils.kt b/app/src/main/java/org/jellyfin/androidtv/util/Utils.kt index 0c5156840b..34b99c4ac2 100644 --- a/app/src/main/java/org/jellyfin/androidtv/util/Utils.kt +++ b/app/src/main/java/org/jellyfin/androidtv/util/Utils.kt @@ -2,7 +2,6 @@ package org.jellyfin.androidtv.util import android.content.Context import android.widget.Toast -import org.jellyfin.androidtv.preference.UserPreferences import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.koin.core.component.KoinComponent @@ -49,42 +48,12 @@ object Utils : KoinComponent { @JvmStatic fun isTrue(value: Boolean?): Boolean = value == true - /** - * A null safe version of `String.equalsIgnoreCase`. - */ - @JvmStatic - fun equalsIgnoreCase(str1: String?, str2: String?): Boolean = when { - str1 == null && str2 == null -> true - str1 == null || str2 == null -> false - else -> str1.equals(str2, ignoreCase = true) - } - @JvmStatic fun getSafeValue(value: T?, defaultValue: T): T = value ?: defaultValue @JvmStatic fun isEmpty(value: String?): Boolean = value.isNullOrEmpty() - @JvmStatic - fun isNonEmpty(value: String?): Boolean = !value.isNullOrEmpty() - - @JvmStatic - fun join(separator: String, items: Iterable): String = items.joinToString(separator = separator) - - @JvmStatic - fun join(separator: String, vararg items: String?): String = join(separator, items.toList()) - - @JvmStatic - fun getMaxBitrate(userPreferences: UserPreferences): Int { - var maxRate = userPreferences[UserPreferences.maxBitrate] - - // Use default when value is what was previously "auto" - if (maxRate == "0") maxRate = UserPreferences.maxBitrate.defaultValue - - // Convert megabit to bit - return (maxRate.toFloat() * 1_000_000).toInt() - } - @JvmStatic fun getThemeColor(context: Context, resourceId: Int): Int { val styledAttributes = context.theme.obtainStyledAttributes(intArrayOf(resourceId)) diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt index f18021a525..83ab903f98 100644 --- a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt @@ -2,6 +2,8 @@ package org.jellyfin.androidtv.util.profile import androidx.media3.common.MimeTypes import org.jellyfin.androidtv.constant.Codec +import org.jellyfin.androidtv.preference.UserPreferences +import org.jellyfin.androidtv.preference.constant.AudioBehavior import org.jellyfin.sdk.model.api.CodecType import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.EncodingContext @@ -39,6 +41,23 @@ private val supportedAudioCodecs = arrayOf( Codec.Audio.VORBIS, ) +private fun UserPreferences.getMaxBitrate(): Int { + var maxBitrate = this[UserPreferences.maxBitrate].toIntOrNull() + + // The value "0" was used in an older release, make sure we prevent that from being used to avoid video not playing + if (maxBitrate == null || maxBitrate < 1) maxBitrate = UserPreferences.maxBitrate.defaultValue.toInt() + + // Convert megabit to bit + return maxBitrate * 1_000_000 +} + +fun createDeviceProfile(userPreferences: UserPreferences, disableDirectPlay: Boolean) = createDeviceProfile( + maxBitrate = userPreferences.getMaxBitrate(), + disableDirectPlay = disableDirectPlay, + isAC3Enabled = userPreferences[UserPreferences.ac3Enabled], + downMixAudio = userPreferences[UserPreferences.audioBehaviour] == AudioBehavior.DOWNMIX_TO_STEREO, +) + fun createDeviceProfile( maxBitrate: Int, disableDirectPlay: Boolean, diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt new file mode 100644 index 0000000000..826cbe007d --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt @@ -0,0 +1,114 @@ +package org.jellyfin.androidtv.util.profile + +import android.content.Context +import android.media.MediaCodecList +import android.os.Build +import kotlinx.serialization.json.Json +import org.jellyfin.androidtv.BuildConfig +import org.jellyfin.androidtv.preference.UserPreferences +import org.jellyfin.androidtv.util.appendCodeBlock +import org.jellyfin.androidtv.util.appendItem +import org.jellyfin.androidtv.util.appendSection +import org.jellyfin.androidtv.util.appendValue +import org.jellyfin.androidtv.util.buildMarkdown +import org.jellyfin.sdk.api.client.util.ApiSerializer + +private val prettyPrintJson = Json { prettyPrint = true } +private fun formatJson(json: String) = prettyPrintJson.encodeToString(prettyPrintJson.parseToJsonElement(json)) + +fun createDeviceProfileReport( + context: Context, + userPreferences: UserPreferences, +) = buildMarkdown { + // Header + appendLine("---") + appendLine("client: Jellyfin for Android TV") + appendLine("client_version: ${BuildConfig.VERSION_NAME}") + appendLine("client_repository: https://github.com/jellyfin/jellyfin-androidtv") + appendLine("type: media_capabilities_report") + appendLine("format: markdown") + appendLine("---") + appendLine() + + // Device profile send to server + appendSection("Generated device profile") { + appendCodeBlock( + language = "json", + code = createDeviceProfile(userPreferences, disableDirectPlay = false) + .let(ApiSerializer::encodeRequestBody) + ?.let(::formatJson) + ) + } + + // Device capabilities used to generate profile + appendSection("Device codec decoders") { + val isQ = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + val isS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + val codecs = MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos + .filter { !it.isEncoder } + .sortedBy { if (isQ) it.canonicalName else it.name } + + for (codec in codecs) { + if (isQ) appendLine("- **${codec.canonicalName} (${codec.name})**") + else appendLine("- **${codec.name}**") + + if (isQ) appendLine(" - isVendor: ${codec.isVendor}") + if (isQ) appendLine(" - isHardwareAccelerated: ${codec.isHardwareAccelerated}") + if (isQ) appendLine(" - isSoftwareOnly: ${codec.isSoftwareOnly}") + if (isQ) appendLine(" - isAlias: ${codec.isAlias}") + + for (type in codec.supportedTypes) { + val capabilities = codec.getCapabilitiesForType(type) + + appendLine(" - **$type**") + + capabilities.audioCapabilities?.let { audio -> + appendLine(" - bitrateRange: ${audio.bitrateRange}") + if (isS) appendLine(" - inputChannelCountRanges: ${audio.inputChannelCountRanges.joinToString(", ")}") + appendLine(" - maxInputChannelCount: ${audio.maxInputChannelCount}") + if (isS) appendLine(" - minInputChannelCount: ${audio.minInputChannelCount}") + appendLine(" - supportedSampleRateRanges: ${audio.supportedSampleRateRanges?.joinToString(", ")}") + appendLine(" - supportedSampleRates: ${audio.supportedSampleRates?.joinToString(", ")}") + } + + capabilities.videoCapabilities?.let { video -> + appendLine(" - bitrateRange: ${video.bitrateRange}") + appendLine(" - supportedFrameRates: ${video.supportedFrameRates}") + appendLine(" - widthAlignment: ${video.widthAlignment}") + appendLine(" - heightAlignment: ${video.heightAlignment}") + appendLine(" - supportedWidths: ${video.supportedWidths}") + appendLine(" - supportedHeights: ${video.supportedHeights}") + if (isQ) appendLine(" - supportedPerformancePoints: ${video.supportedPerformancePoints?.joinToString(", ")}") + } + } + + appendLine() + } + + appendSection("Known media types", depth = 4) { + codecs + .flatMap { codec -> codec.supportedTypes.asIterable() } + .distinct() + .sorted() + .forEach { type -> appendLine("- $type") } + } + } + + appendSection("App information") { + appendItem("App version") { + appendValue(BuildConfig.VERSION_NAME) + append(" (") + appendValue(BuildConfig.VERSION_CODE.toString()) + append(")") + } + appendItem("Package name") { appendValue(context.packageName) } + } + + appendSection("Device information") { + appendItem("Android version") { appendValue(Build.VERSION.RELEASE) } + appendItem("Device brand") { appendValue(Build.BRAND) } + appendItem("Device product") { appendValue(Build.PRODUCT) } + appendItem("Device model") { appendValue(Build.MODEL) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c046dceaa..df1de0b3cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -527,6 +527,11 @@ Skip forward length Enable trickplay in video player Subtitle background opacity + Troubleshooting + Report playback capabilities to server + Upload a troubleshooting document to the Jellyfin server with information about playback capabilities + Document submitted as %1$s + Failed to submit document. Your Jellyfin server may prohibit client logs. %1$s second %1$s seconds