Error dialog refinements

This commit is contained in:
Philipp Heckel 2026-01-11 15:08:41 -05:00
parent bdcecee3e9
commit feb2907cd4
7 changed files with 272 additions and 106 deletions

View file

@ -1,30 +1,37 @@
package io.heckel.ntfy.ui
import android.app.Dialog
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.ScrollView
import android.widget.HorizontalScrollView
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.color.MaterialColors
import io.heckel.ntfy.R
import io.heckel.ntfy.db.ConnectionError
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.util.copyToClipboard
class ConnectionErrorFragment : DialogFragment() {
private lateinit var repository: Repository
private var connectionErrors: Map<String, ConnectionError> = emptyMap()
private var selectedBaseUrl: String? = null
private var detailsVisible = false
private var filterBaseUrl: String? = null
private lateinit var toolbar: MaterialToolbar
private lateinit var serverLabel: TextView
private lateinit var serverSpinner: Spinner
private lateinit var errorTextView: TextView
private lateinit var showDetailsTextView: TextView
private lateinit var detailsScrollView: ScrollView
private lateinit var detailsScrollView: HorizontalScrollView
private lateinit var stackTraceTextView: TextView
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@ -32,14 +39,43 @@ class ConnectionErrorFragment : DialogFragment() {
throw IllegalStateException("Activity cannot be null")
}
// Get optional baseUrl filter from arguments
filterBaseUrl = arguments?.getString(ARG_BASE_URL)
// Dependencies
repository = Repository.getInstance(requireContext())
connectionErrors = repository.getConnectionErrors()
// Get connection errors, optionally filtered by baseUrl
val allErrors = repository.getConnectionErrors()
connectionErrors = if (filterBaseUrl != null) {
allErrors.filterKeys { it == filterBaseUrl }
} else {
allErrors
}
// Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_connection_error_dialog, null)
// Setup toolbar
toolbar = view.findViewById(R.id.connection_error_dialog_toolbar)
toolbar.setNavigationOnClickListener { dismiss() }
toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.connection_error_dialog_action_copy -> {
copyErrorToClipboard()
true
}
else -> false
}
}
// Tint menu icons to match toolbar text color
val iconColor = MaterialColors.getColor(requireContext(), R.attr.colorOnSurface, Color.BLACK)
val copyMenuItem = toolbar.menu.findItem(R.id.connection_error_dialog_action_copy)
copyMenuItem?.icon?.setTint(iconColor)
// Get view references
serverLabel = view.findViewById(R.id.connection_error_dialog_server_label)
serverSpinner = view.findViewById(R.id.connection_error_dialog_server_spinner)
errorTextView = view.findViewById(R.id.connection_error_dialog_error_text)
showDetailsTextView = view.findViewById(R.id.connection_error_dialog_show_details)
@ -49,6 +85,7 @@ class ConnectionErrorFragment : DialogFragment() {
// Setup server spinner if multiple errors
val baseUrls = connectionErrors.keys.toList()
if (baseUrls.size > 1) {
serverLabel.visibility = View.VISIBLE
serverSpinner.visibility = View.VISIBLE
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, baseUrls)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
@ -61,6 +98,7 @@ class ConnectionErrorFragment : DialogFragment() {
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
} else {
serverLabel.visibility = View.GONE
serverSpinner.visibility = View.GONE
}
@ -74,12 +112,21 @@ class ConnectionErrorFragment : DialogFragment() {
updateDetailsVisibility()
}
return MaterialAlertDialogBuilder(requireContext())
.setView(view)
.setPositiveButton(R.string.connection_error_dialog_dismiss) { dialog, _ ->
dialog.dismiss()
}
.create()
// Build dialog
val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog)
dialog.setContentView(view)
return dialog
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
private fun updateErrorDisplay() {
@ -106,7 +153,31 @@ class ConnectionErrorFragment : DialogFragment() {
}
}
private fun copyErrorToClipboard() {
val error = selectedBaseUrl?.let { connectionErrors[it] } ?: return
val text = buildString {
appendLine("Server: ${error.baseUrl}")
appendLine("Error: ${error.message}")
appendLine()
appendLine("Stack trace:")
append(error.getStackTraceString().ifEmpty { "No stack trace available" })
}
copyToClipboard(requireContext(), "connection error", text)
Toast.makeText(context, R.string.connection_error_dialog_copied, Toast.LENGTH_SHORT).show()
}
companion object {
const val TAG = "NtfyConnectionErrorFragment"
private const val ARG_BASE_URL = "base_url"
fun newInstance(baseUrl: String? = null): ConnectionErrorFragment {
val fragment = ConnectionErrorFragment()
if (baseUrl != null) {
val args = Bundle()
args.putString(ARG_BASE_URL, baseUrl)
fragment.arguments = args
}
return fragment
}
}
}

View file

@ -361,6 +361,11 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
SubscriberServiceManager.refresh(this)
}
// Observe connection errors and update menu item visibility
repository.getConnectionErrorsLiveData().observe(this) { errors ->
showHideConnectionErrorMenuItem(errors)
}
// Mark this subscription as "open" so we don't receive notifications for it
repository.detailViewSubscriptionId.set(subscriptionId)
@ -508,6 +513,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscription.baseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
updateTitle(subscriptionDisplayName)
}
}
@ -550,6 +556,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscriptionBaseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
// Regularly check if "notification muted" time has passed
// NOTE: This is done here, because then we know that we've initialized the menu items.
@ -603,6 +610,10 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
onInstantEnableClick(enable = false)
true
}
R.id.detail_menu_connection_error -> {
onConnectionErrorClick()
true
}
R.id.detail_menu_copy_url -> {
onCopyUrlClick()
true
@ -668,6 +679,12 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
}
}
private fun onConnectionErrorClick() {
Log.d(TAG, "Showing connection error dialog for ${subscriptionBaseUrl}")
val connectionErrorFragment = ConnectionErrorFragment.newInstance(subscriptionBaseUrl)
connectionErrorFragment.show(supportFragmentManager, ConnectionErrorFragment.TAG)
}
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Setting subscription 'muted until' to $mutedUntilTimestamp")
@ -800,6 +817,17 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
}
}
private fun showHideConnectionErrorMenuItem(errors: Map<String, io.heckel.ntfy.db.ConnectionError>) {
if (!this::menu.isInitialized) {
return
}
runOnUiThread {
val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error)
// Only show if there's an error for this subscription's base URL
connectionErrorItem?.isVisible = errors.containsKey(subscriptionBaseUrl)
}
}
private fun updateTitle(subscriptionDisplayName: String) {
runOnUiThread {
title = subscriptionDisplayName

View file

@ -375,6 +375,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
override fun onResume() {
super.onResume()
showHideNotificationMenuItems()
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
redrawList()
}
@ -492,6 +493,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
showHideNotificationMenuItems()
showHideConnectionErrorMenuItem(repository.getConnectionErrors())
checkSubscriptionsMuted() // This is done here, because then we know that we've initialized the menu
return true
}
@ -628,7 +630,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
private fun onConnectionErrorClick() {
Log.d(TAG, "Showing connection error dialog")
val connectionErrorFragment = ConnectionErrorFragment()
val connectionErrorFragment = ConnectionErrorFragment.newInstance()
connectionErrorFragment.show(supportFragmentManager, ConnectionErrorFragment.TAG)
}

View file

@ -1,111 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingBottom="16dp">
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<TextView
android:id="@+id/connection_error_dialog_title"
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/connection_error_dialog_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="8dp"
android:text="@string/connection_error_dialog_title"
android:textAlignment="viewStart"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
android:background="?attr/colorSurface"
app:elevation="0dp"
app:liftOnScroll="false">
<Spinner
android:id="@+id/connection_error_dialog_server_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/connection_error_dialog_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
android:paddingStart="0dp"
android:paddingEnd="12dp"
app:navigationIcon="@drawable/ic_close_white_24dp"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/connection_error_dialog_title"
app:titleTextColor="?attr/colorOnSurface"
app:menu="@menu/menu_connection_error_dialog" />
<ImageView
android:id="@+id/connection_error_dialog_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:src="@drawable/ic_warning_amber_24dp"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_server_spinner"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/connection_error_dialog_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/connection_error_dialog_message"
android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/connection_error_dialog_error_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_message"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/connection_error_dialog_show_details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/connection_error_dialog_show_details"
android:textAlignment="center"
android:textColor="?android:attr/colorAccent"
android:textStyle="bold"
android:padding="8dp"
android:background="?android:attr/selectableItemBackground"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_error_text"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:id="@+id/connection_error_dialog_details_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxHeight="200dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_show_details"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
android:layout_height="match_parent"
android:paddingHorizontal="?dialogPreferredPadding"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<TextView
android:id="@+id/connection_error_dialog_stack_trace"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:background="@android:color/darker_gray"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorPrimary"
android:fontFamily="monospace"
android:textSize="10sp" />
android:orientation="vertical"
android:paddingBottom="16dp">
<!-- Server selector (only visible when multiple servers have errors) -->
<TextView
android:id="@+id/connection_error_dialog_server_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/connection_error_dialog_server_label"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary"
android:paddingTop="16dp"
android:paddingBottom="4dp"
android:visibility="gone" />
<Spinner
android:id="@+id/connection_error_dialog_server_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
<!-- Warning icon and message -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingTop="24dp"
android:paddingBottom="16dp">
<ImageView
android:id="@+id/connection_error_dialog_icon"
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_warning_amber_24dp"
android:contentDescription="@string/connection_error_dialog_title" />
<TextView
android:id="@+id/connection_error_dialog_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/connection_error_dialog_message"
android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>
<!-- Error message -->
<TextView
android:id="@+id/connection_error_dialog_error_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/connection_error_dialog_error_label"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary"
android:textStyle="bold"
android:paddingTop="8dp"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/connection_error_dialog_error_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorPrimary"
android:padding="8dp" />
<!-- Show/Hide details toggle -->
<TextView
android:id="@+id/connection_error_dialog_show_details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/connection_error_dialog_show_details"
android:textColor="?android:attr/colorAccent"
android:textStyle="bold"
android:padding="8dp"
android:background="?android:attr/selectableItemBackground" />
<!-- Stack trace (scrollable horizontally, no word wrap) -->
<HorizontalScrollView
android:id="@+id/connection_error_dialog_details_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone"
android:fillViewport="true">
<ScrollView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxHeight="300dp">
<TextView
android:id="@+id/connection_error_dialog_stack_trace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:background="?attr/colorSurfaceVariant"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorPrimary"
android:fontFamily="monospace"
android:textSize="10sp"
android:scrollHorizontally="true"
android:singleLine="false" />
</ScrollView>
</HorizontalScrollView>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/connection_error_dialog_action_copy"
android:icon="@drawable/ic_content_copy_white_24dp"
android:title="@string/connection_error_dialog_copy"
app:showAsAction="always" />
</menu>

View file

@ -26,6 +26,12 @@
android:icon="@drawable/ic_bolt_white_24dp"
android:title="@string/detail_menu_disable_instant"
app:showAsAction="ifRoom" />
<item
android:id="@+id/detail_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
android:title="@string/main_menu_connection_error"
android:visible="false"
app:showAsAction="ifRoom" />
<item
android:id="@+id/detail_menu_settings"
android:title="@string/detail_menu_settings" />

View file

@ -292,11 +292,14 @@
<!-- Connection error dialog -->
<string name="connection_error_dialog_title">Connection Error</string>
<string name="connection_error_dialog_message">There was a problem connecting to the server. The app will keep trying to reconnect.</string>
<string name="connection_error_dialog_server_label">Server</string>
<string name="connection_error_dialog_error_label">Error message</string>
<string name="connection_error_dialog_show_details">Show details</string>
<string name="connection_error_dialog_hide_details">Hide details</string>
<string name="connection_error_dialog_no_stack_trace">No additional details available</string>
<string name="connection_error_dialog_no_error">No error</string>
<string name="connection_error_dialog_dismiss">Dismiss</string>
<string name="connection_error_dialog_copy">Copy</string>
<string name="connection_error_dialog_copied">Error details copied to clipboard</string>
<!-- Notification popup -->
<string name="notification_popup_action_open">Open</string>