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) {
logD("Received ok response")
binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE
binding.homeIndexingError.visibility = View.INVISIBLE
return
}
@ -357,6 +358,7 @@ class HomeFragment :
.launch(PERMISSION_READ_AUDIO)
}
}
binding.homeIndexingError.visibility = View.INVISIBLE
}
is NoMusicException -> {
logD("Showing no music error")
@ -367,6 +369,7 @@ class HomeFragment :
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
binding.homeIndexingError.visibility = View.INVISIBLE
}
else -> {
logD("Showing generic error")
@ -377,6 +380,12 @@ class HomeFragment :
text = context.getString(R.string.lbl_retry)
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
* 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.
logD("Starting MediaStore query")
emitIndexingProgress(IndexingProgress.Indeterminate)
val mediaStoreQueryJob =
worker.scope.async {
val query =

View file

@ -18,13 +18,8 @@
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.view.LayoutInflater
import androidx.core.net.toUri
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
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.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.openInBrowser
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
@ -69,10 +63,10 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
}
binding.aboutVersion.text = BuildConfig.VERSION_NAME
binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_SOURCE) }
binding.aboutWiki.setOnClickListener { openLinkInBrowser(LINK_WIKI) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
binding.aboutAuthor.setOnClickListener { openLinkInBrowser(LINK_AUTHOR) }
binding.aboutCode.setOnClickListener { requireContext().openInBrowser(LINK_SOURCE) }
binding.aboutWiki.setOnClickListener { requireContext().openInBrowser(LINK_WIKI) }
binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) }
binding.aboutAuthor.setOnClickListener { requireContext().openInBrowser(LINK_AUTHOR) }
// VIEWMODEL SETUP
collectImmediately(musicModel.statistics, ::updateStatistics)
@ -93,74 +87,6 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
(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 {
/** The URL to the source code. */
const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"

View file

@ -18,7 +18,10 @@
package org.oxycblt.auxio.util
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.PointF
import android.graphics.drawable.Drawable
import android.os.Build
@ -33,6 +36,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ShareCompat
import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.net.toUri
import androidx.core.view.children
import androidx.navigation.NavController
import androidx.navigation.NavDirections
@ -41,6 +45,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.google.android.material.appbar.MaterialToolbar
import java.lang.IllegalArgumentException
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
@ -322,3 +327,65 @@ fun Context.share(songs: Collection<Song>) {
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:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small"
android:layout_marginEnd="@dimen/spacing_small"
android:layout_marginStart="@dimen/spacing_tiny"
android:layout_marginEnd="@dimen/spacing_tiny"
app:layout_constraintBottom_toTopOf="@+id/playback_controls_container"
app:layout_constraintEnd_toEndOf="parent"
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
android:id="@+id/home_indexing_action"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
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:text="@string/lbl_retry"
style="@style/Widget.Auxio.Button.Secondary"
android:visibility="invisible"
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" />
<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>
</com.google.android.material.card.MaterialCardView>

View file

@ -81,8 +81,21 @@
<action
android:id="@+id/play_from_genre"
app:destination="@id/play_from_genre_dialog" />
<action
android:id="@+id/report_error"
app:destination="@id/error_details_dialog" />
</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
android:id="@+id/song_sort_dialog"
android:name="org.oxycblt.auxio.home.sort.SongSortDialog"

View file

@ -23,6 +23,7 @@
<dimen name="size_btn">48dp</dimen>
<dimen name="size_accent_item">56dp</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_icon_small">24dp</dimen>

View file

@ -16,6 +16,8 @@
<string name="lbl_observing">Monitoring music library</string>
<!-- As in to retry loading music -->
<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 -->
<string name="lbl_grant">Grant</string>
@ -168,6 +170,12 @@
<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 -->
<eat-comment />