home: add music loading error dialog

Add a dialog that shows the stack trace of a music loading error.

This is an MVP that is only available to music loading to resolve some
immediate issues.

Resolves #527.
This commit is contained in:
Alexander Capehart 2023-08-14 17:42:05 -06:00
parent ada29b2f7a
commit f5c7f25cdf
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
13 changed files with 294 additions and 85 deletions

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2023 Auxio Project
* ErrorDetailsDialog.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.openInBrowser
import org.oxycblt.auxio.util.showToast
/**
* A dialog that shows a stack trace for a music loading error.
*
* TODO: Extend to other errors
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
private val args: ErrorDetailsDialogArgs by navArgs()
private var clipboardManager: ClipboardManager? = null
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_error_info)
.setPositiveButton(R.string.lbl_report) { _, _ ->
requireContext().openInBrowser(LINK_ISSUES)
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onCreateBinding(inflater: LayoutInflater) =
DialogErrorDetailsBinding.inflate(inflater)
override fun onBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
clipboardManager = requireContext().getSystemServiceCompat(ClipboardManager::class)
// --- UI SETUP ---
binding.errorStackTrace.text = args.error.stackTraceToString().trimEnd('\n')
binding.errorCopy.setOnClickListener { copyStackTrace() }
}
override fun onDestroyBinding(binding: DialogErrorDetailsBinding) {
super.onDestroyBinding(binding)
clipboardManager = null
}
private fun copyStackTrace() {
requireNotNull(clipboardManager) { "Clipboard was unavailable" }
.setPrimaryClip(
ClipData.newPlainText("Exception Stack Trace", args.error.stackTraceToString()))
// A copy notice is shown by the system from Android 13 onwards
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
requireContext().showToast(R.string.lbl_copied)
}
}
private companion object {
/** The URL to the bug report issue form */
const val LINK_ISSUES =
"https://github.com/OxygenCobalt/Auxio/issues/new" +
"?assignees=OxygenCobalt&labels=bug&projects=&template=bug-crash-report.yml"
}
}

View file

@ -330,11 +330,12 @@ class HomeFragment :
} }
} }
private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) { private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) { if (error == null) {
logD("Received ok response") logD("Received ok response")
binding.homeFab.show() binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE binding.homeIndexingContainer.visibility = View.INVISIBLE
binding.homeIndexingError.visibility = View.INVISIBLE
return return
} }
@ -357,6 +358,7 @@ class HomeFragment :
.launch(PERMISSION_READ_AUDIO) .launch(PERMISSION_READ_AUDIO)
} }
} }
binding.homeIndexingError.visibility = View.INVISIBLE
} }
is NoMusicException -> { is NoMusicException -> {
logD("Showing no music error") logD("Showing no music error")
@ -367,6 +369,7 @@ class HomeFragment :
text = context.getString(R.string.lbl_retry) text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() } setOnClickListener { musicModel.refresh() }
} }
binding.homeIndexingError.visibility = View.INVISIBLE
} }
else -> { else -> {
logD("Showing generic error") logD("Showing generic error")
@ -377,6 +380,12 @@ class HomeFragment :
text = context.getString(R.string.lbl_retry) text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() } setOnClickListener { musicModel.rescan() }
} }
binding.homeIndexingError.apply {
visibility = View.VISIBLE
setOnClickListener {
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
}
}
} }
} }
} }

View file

@ -47,7 +47,7 @@ sealed interface IndexingState {
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it * @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null. * will be null.
*/ */
data class Completed(val error: Throwable?) : IndexingState data class Completed(val error: Exception?) : IndexingState
} }
/** /**

View file

@ -371,6 +371,7 @@ constructor(
// parallel. // parallel.
logD("Starting MediaStore query") logD("Starting MediaStore query")
emitIndexingProgress(IndexingProgress.Indeterminate) emitIndexingProgress(IndexingProgress.Indeterminate)
val mediaStoreQueryJob = val mediaStoreQueryJob =
worker.scope.async { worker.scope.async {
val query = val query =

View file

@ -18,13 +18,8 @@
package org.oxycblt.auxio.settings package org.oxycblt.auxio.settings
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.core.net.toUri
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -37,8 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.openInBrowser
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
@ -69,10 +63,10 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
} }
binding.aboutVersion.text = BuildConfig.VERSION_NAME binding.aboutVersion.text = BuildConfig.VERSION_NAME
binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_SOURCE) } binding.aboutCode.setOnClickListener { requireContext().openInBrowser(LINK_SOURCE) }
binding.aboutWiki.setOnClickListener { openLinkInBrowser(LINK_WIKI) } binding.aboutWiki.setOnClickListener { requireContext().openInBrowser(LINK_WIKI) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) } binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) }
binding.aboutAuthor.setOnClickListener { openLinkInBrowser(LINK_AUTHOR) } binding.aboutAuthor.setOnClickListener { requireContext().openInBrowser(LINK_AUTHOR) }
// VIEWMODEL SETUP // VIEWMODEL SETUP
collectImmediately(musicModel.statistics, ::updateStatistics) collectImmediately(musicModel.statistics, ::updateStatistics)
@ -93,74 +87,6 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
(statistics?.durationMs ?: 0).formatDurationMs(false)) (statistics?.durationMs ?: 0).formatDurationMs(false))
} }
/**
* Open the given URI in a web browser.
*
* @param uri The URL to open.
*/
private fun openLinkInBrowser(uri: String) {
logD("Opening $uri")
val context = requireContext()
val browserIntent =
Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11 seems to now handle the app chooser situations on its own now
// [along with adding a new permission that breaks the old manual code], so
// we just do a typical activity launch.
logD("Using API 30+ chooser")
try {
context.startActivity(browserIntent)
} catch (e: ActivityNotFoundException) {
// No app installed to open the link
context.showToast(R.string.err_no_app)
}
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// On older versions of android, opening links from an ACTION_VIEW intent might
// not work in all cases, especially when no default app was set. If that is the
// case, we will try to manually handle these cases before we try to launch the
// browser.
logD("Resolving browser activity for chooser")
val pkgName =
context.packageManager
.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
?.run { activityInfo.packageName }
if (pkgName != null) {
if (pkgName == "android") {
// No default browser [Must open app chooser, may not be supported]
logD("No default browser found")
openAppChooser(browserIntent)
} else logD("Opening browser intent")
try {
browserIntent.setPackage(pkgName)
startActivity(browserIntent)
} catch (e: ActivityNotFoundException) {
// Not a browser but an app chooser
browserIntent.setPackage(null)
openAppChooser(browserIntent)
}
} else {
// No app installed to open the link
context.showToast(R.string.err_no_app)
}
}
}
/**
* Open an app chooser for a given [Intent].
*
* @param intent The [Intent] to show an app chooser for.
*/
private fun openAppChooser(intent: Intent) {
logD("Opening app chooser for ${intent.action}")
val chooserIntent =
Intent(Intent.ACTION_CHOOSER)
.putExtra(Intent.EXTRA_INTENT, intent)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(chooserIntent)
}
private companion object { private companion object {
/** The URL to the source code. */ /** The URL to the source code. */
const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio" const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"

View file

@ -18,7 +18,10 @@
package org.oxycblt.auxio.util package org.oxycblt.auxio.util
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.PointF import android.graphics.PointF
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
@ -33,6 +36,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.net.toUri
import androidx.core.view.children import androidx.core.view.children
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
@ -41,6 +45,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
@ -322,3 +327,65 @@ fun Context.share(songs: Collection<Song>) {
builder.setType(mimeTypes.singleOrNull() ?: "audio/*").startChooser() builder.setType(mimeTypes.singleOrNull() ?: "audio/*").startChooser()
} }
/**
* Open the given URI in a web browser.
*
* @param uri The URL to open.
*/
fun Context.openInBrowser(uri: String) {
fun openAppChooser(intent: Intent) {
logD("Opening app chooser for ${intent.action}")
val chooserIntent =
Intent(Intent.ACTION_CHOOSER)
.putExtra(Intent.EXTRA_INTENT, intent)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(chooserIntent)
}
logD("Opening $uri")
val browserIntent =
Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11 seems to now handle the app chooser situations on its own now
// [along with adding a new permission that breaks the old manual code], so
// we just do a typical activity launch.
logD("Using API 30+ chooser")
try {
startActivity(browserIntent)
} catch (e: ActivityNotFoundException) {
// No app installed to open the link
showToast(R.string.err_no_app)
}
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// On older versions of android, opening links from an ACTION_VIEW intent might
// not work in all cases, especially when no default app was set. If that is the
// case, we will try to manually handle these cases before we try to launch the
// browser.
logD("Resolving browser activity for chooser")
val pkgName =
packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)?.run {
activityInfo.packageName
}
if (pkgName != null) {
if (pkgName == "android") {
// No default browser [Must open app chooser, may not be supported]
logD("No default browser found")
openAppChooser(browserIntent)
} else logD("Opening browser intent")
try {
browserIntent.setPackage(pkgName)
startActivity(browserIntent)
} catch (e: ActivityNotFoundException) {
// Not a browser but an app chooser
browserIntent.setPackage(null)
openAppChooser(browserIntent)
}
} else {
// No app installed to open the link
showToast(R.string.err_no_app)
}
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640L360,640Z"/>
</vector>

View file

@ -69,8 +69,8 @@
android:id="@+id/playback_seek_bar" android:id="@+id/playback_seek_bar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small" android:layout_marginStart="@dimen/spacing_tiny"
android:layout_marginEnd="@dimen/spacing_small" android:layout_marginEnd="@dimen/spacing_tiny"
app:layout_constraintBottom_toTopOf="@+id/playback_controls_container" app:layout_constraintBottom_toTopOf="@+id/playback_controls_container"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_large"
android:paddingStart="@dimen/spacing_large"
tools:context=".MainActivity">
<com.google.android.material.card.MaterialCardView
android:id="@+id/error_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
style="@style/Widget.Material3.CardView.Filled"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none">
<TextView
android:id="@+id/error_stack_trace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="@dimen/spacing_medium"
android:paddingStart="@dimen/spacing_medium"
android:paddingBottom="@dimen/spacing_medium"
android:paddingEnd="@dimen/size_copy_button"
android:breakStrategy="simple"
android:hyphenationFrequency="none"
android:typeface="monospace"
tools:text="Stack trace here" />
</HorizontalScrollView>
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/error_copy"
style="@style/Widget.Auxio.Button.Icon.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
app:icon="@drawable/ic_copy_24"
android:layout_margin="@dimen/spacing_small"
app:backgroundTint="?attr/colorPrimaryContainer"
android:src="@drawable/ic_code_24" />
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -108,16 +108,33 @@
<org.oxycblt.auxio.ui.RippleFixMaterialButton <org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/home_indexing_action" android:id="@+id/home_indexing_action"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_medium" android:layout_marginStart="@dimen/spacing_medium"
android:layout_marginEnd="@dimen/spacing_medium" android:layout_marginEnd="@dimen/spacing_small"
android:layout_marginBottom="@dimen/spacing_medium" android:layout_marginBottom="@dimen/spacing_medium"
android:text="@string/lbl_retry" android:text="@string/lbl_retry"
style="@style/Widget.Auxio.Button.Secondary"
android:visibility="invisible" android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/home_indexing_error"
app:layout_constraintTop_toBottomOf="@+id/home_indexing_status" /> app:layout_constraintTop_toBottomOf="@+id/home_indexing_status" />
<org.oxycblt.auxio.ui.RippleFixMaterialButton
android:id="@+id/home_indexing_error"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small"
android:layout_marginEnd="@dimen/spacing_medium"
android:text="@string/lbl_show_error_info"
style="@style/Widget.Auxio.Button.Primary"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@+id/home_indexing_action"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/home_indexing_action"
app:layout_constraintTop_toTopOf="@+id/home_indexing_action" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

View file

@ -81,8 +81,21 @@
<action <action
android:id="@+id/play_from_genre" android:id="@+id/play_from_genre"
app:destination="@id/play_from_genre_dialog" /> app:destination="@id/play_from_genre_dialog" />
<action
android:id="@+id/report_error"
app:destination="@id/error_details_dialog" />
</fragment> </fragment>
<dialog
android:id="@+id/error_details_dialog"
android:name="org.oxycblt.auxio.home.ErrorDetailsDialog"
android:label="error_details_dialog"
tools:layout="@layout/dialog_error_details">
<argument
android:name="error"
app:argType="java.lang.Exception" />
</dialog>
<dialog <dialog
android:id="@+id/song_sort_dialog" android:id="@+id/song_sort_dialog"
android:name="org.oxycblt.auxio.home.sort.SongSortDialog" android:name="org.oxycblt.auxio.home.sort.SongSortDialog"

View file

@ -23,6 +23,7 @@
<dimen name="size_btn">48dp</dimen> <dimen name="size_btn">48dp</dimen>
<dimen name="size_accent_item">56dp</dimen> <dimen name="size_accent_item">56dp</dimen>
<dimen name="size_bottom_sheet_bar">64dp</dimen> <dimen name="size_bottom_sheet_bar">64dp</dimen>
<dimen name="size_copy_button">64dp</dimen>
<dimen name="size_play_pause_button">72dp</dimen> <dimen name="size_play_pause_button">72dp</dimen>
<dimen name="size_icon_small">24dp</dimen> <dimen name="size_icon_small">24dp</dimen>

View file

@ -16,6 +16,8 @@
<string name="lbl_observing">Monitoring music library</string> <string name="lbl_observing">Monitoring music library</string>
<!-- As in to retry loading music --> <!-- As in to retry loading music -->
<string name="lbl_retry">Retry</string> <string name="lbl_retry">Retry</string>
<!-- As in to show additional information about a music loading error -->
<string name="lbl_show_error_info">More</string>
<!-- As in grant permission --> <!-- As in grant permission -->
<string name="lbl_grant">Grant</string> <string name="lbl_grant">Grant</string>
@ -168,6 +170,12 @@
<string name="lbl_selection">Selection</string> <string name="lbl_selection">Selection</string>
<string name="lbl_error_info">Error information</string>
<!-- As in copied to the system clipboard -->
<string name="lbl_copied">Copied</string>
<!-- As in to report an error -->
<string name="lbl_report">Report</string>
<!-- Long Namespace | Longer Descriptions --> <!-- Long Namespace | Longer Descriptions -->
<eat-comment /> <eat-comment />