WIP Connection error dialog

This commit is contained in:
Philipp Heckel 2026-01-11 09:48:51 -05:00
parent 1fa72d9bfd
commit bdcecee3e9
14 changed files with 364 additions and 4 deletions

View file

@ -72,6 +72,21 @@ enum class ConnectionState {
NOT_APPLICABLE, CONNECTING, CONNECTED
}
/**
* Represents a connection error for a specific baseUrl.
* This is not persisted to the database, but kept in memory.
*/
data class ConnectionError(
val baseUrl: String,
val message: String,
val throwable: Throwable?,
val timestamp: Long = System.currentTimeMillis()
) {
fun getStackTraceString(): String {
return throwable?.stackTraceToString() ?: ""
}
}
data class SubscriptionWithMetadata(
val id: Long,
val baseUrl: String,

View file

@ -28,6 +28,9 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
private val connectionStatesLiveData = MutableLiveData(connectionStates)
private val connectionErrors = ConcurrentHashMap<String, ConnectionError>()
private val connectionErrorsLiveData = MutableLiveData<Map<String, ConnectionError>>(emptyMap())
// TODO Move these into an ApplicationState singleton
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
val mediaPlayer = MediaPlayer()
@ -569,6 +572,36 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
}
fun getConnectionErrorsLiveData(): LiveData<Map<String, ConnectionError>> {
return connectionErrorsLiveData
}
fun getConnectionErrors(): Map<String, ConnectionError> {
return connectionErrors.toMap()
}
fun updateConnectionError(baseUrl: String, message: String, throwable: Throwable?) {
val error = ConnectionError(baseUrl, message, throwable)
connectionErrors[baseUrl] = error
connectionErrorsLiveData.postValue(connectionErrors.toMap())
Log.d(TAG, "Connection error updated for $baseUrl: $message")
}
fun clearConnectionError(baseUrl: String) {
if (connectionErrors.remove(baseUrl) != null) {
connectionErrorsLiveData.postValue(connectionErrors.toMap())
Log.d(TAG, "Connection error cleared for $baseUrl")
}
}
fun clearAllConnectionErrors() {
if (connectionErrors.isNotEmpty()) {
connectionErrors.clear()
connectionErrorsLiveData.postValue(emptyMap())
Log.d(TAG, "All connection errors cleared")
}
}
companion object {
const val SHARED_PREFS_ID = "MainPreferences"
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"

View file

@ -17,6 +17,7 @@ class JsonConnection(
private val sinceId: String?,
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit,
private val errorListener: (String, Throwable) -> Unit,
private val serviceActive: () -> Boolean
) : Connection {
private val baseUrl = connectionId.baseUrl
@ -46,10 +47,11 @@ class JsonConnection(
notificationListener(subscription, notificationWithSubscriptionId)
}
val failed = AtomicBoolean(false)
val fail = { _: Exception ->
val fail = { e: Exception ->
failed.set(true)
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
errorListener(baseUrl, e)
}
}
@ -66,6 +68,7 @@ class JsonConnection(
Log.e(TAG, "[$url] Connection failed: ${e.message}", e)
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
errorListener(baseUrl, e)
}
}

View file

@ -262,9 +262,9 @@ class SubscriberService : Service() {
val connection = if (connectionId.connectionProtocol == Repository.CONNECTION_PROTOCOL_WS) {
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
val httpClient = HttpUtil.wsClient(this, connectionId.baseUrl)
WsConnection(connectionId, repository, httpClient, user, customHeaders, since, ::onStateChanged, ::onNotificationReceived, alarmManager)
WsConnection(connectionId, repository, httpClient, user, customHeaders, since, ::onStateChanged, ::onNotificationReceived, ::onConnectionError, alarmManager)
} else {
JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive)
JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, ::onConnectionError, serviceActive)
}
connections[connectionId] = connection
connection.start()
@ -307,6 +307,20 @@ class SubscriberService : Service() {
private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
repository.updateState(subscriptionIds, state)
// Clear connection error when successfully connected
if (state == ConnectionState.CONNECTED) {
subscriptionIds.firstOrNull()?.let { subscriptionId ->
val subscription = repository.getSubscription(subscriptionId)
if (subscription != null) {
repository.clearConnectionError(subscription.baseUrl)
}
}
}
}
private fun onConnectionError(baseUrl: String, throwable: Throwable) {
val message = throwable.message ?: "Unknown error"
repository.updateConnectionError(baseUrl, message, throwable)
}
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {

View file

@ -42,6 +42,7 @@ class WsConnection(
private val sinceId: String?,
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit,
private val errorListener: (String, Throwable) -> Unit,
private val alarmManager: AlarmManager
) : Connection {
private val parser = NotificationParser()
@ -183,6 +184,7 @@ class WsConnection(
return@synchronize
}
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
errorListener(baseUrl, t)
state = State.Disconnected
errorCount++
val retrySeconds = RETRY_SECONDS.getOrNull(errorCount) ?: RETRY_SECONDS.last()

View file

@ -0,0 +1,112 @@
package io.heckel.ntfy.ui
import android.app.Dialog
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.ScrollView
import android.widget.Spinner
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.heckel.ntfy.R
import io.heckel.ntfy.db.ConnectionError
import io.heckel.ntfy.db.Repository
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 lateinit var serverSpinner: Spinner
private lateinit var errorTextView: TextView
private lateinit var showDetailsTextView: TextView
private lateinit var detailsScrollView: ScrollView
private lateinit var stackTraceTextView: TextView
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (activity == null) {
throw IllegalStateException("Activity cannot be null")
}
// Dependencies
repository = Repository.getInstance(requireContext())
connectionErrors = repository.getConnectionErrors()
// Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_connection_error_dialog, null)
// Get view references
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)
detailsScrollView = view.findViewById(R.id.connection_error_dialog_details_scroll)
stackTraceTextView = view.findViewById(R.id.connection_error_dialog_stack_trace)
// Setup server spinner if multiple errors
val baseUrls = connectionErrors.keys.toList()
if (baseUrls.size > 1) {
serverSpinner.visibility = View.VISIBLE
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, baseUrls)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
serverSpinner.adapter = adapter
serverSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedBaseUrl = baseUrls[position]
updateErrorDisplay()
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
} else {
serverSpinner.visibility = View.GONE
}
// Select first error by default
selectedBaseUrl = baseUrls.firstOrNull()
updateErrorDisplay()
// Toggle details visibility
showDetailsTextView.setOnClickListener {
detailsVisible = !detailsVisible
updateDetailsVisibility()
}
return MaterialAlertDialogBuilder(requireContext())
.setView(view)
.setPositiveButton(R.string.connection_error_dialog_dismiss) { dialog, _ ->
dialog.dismiss()
}
.create()
}
private fun updateErrorDisplay() {
val error = selectedBaseUrl?.let { connectionErrors[it] }
if (error != null) {
errorTextView.text = error.message
stackTraceTextView.text = error.getStackTraceString().ifEmpty {
getString(R.string.connection_error_dialog_no_stack_trace)
}
} else {
errorTextView.text = getString(R.string.connection_error_dialog_no_error)
stackTraceTextView.text = ""
}
updateDetailsVisibility()
}
private fun updateDetailsVisibility() {
if (detailsVisible) {
detailsScrollView.visibility = View.VISIBLE
showDetailsTextView.text = getString(R.string.connection_error_dialog_hide_details)
} else {
detailsScrollView.visibility = View.GONE
showDetailsTextView.text = getString(R.string.connection_error_dialog_show_details)
}
}
companion object {
const val TAG = "NtfyConnectionErrorFragment"
}
}

View file

@ -253,6 +253,11 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
SubscriberServiceManager.refresh(this)
}
// Observe connection errors and update menu item visibility
repository.getConnectionErrorsLiveData().observe(this) { errors ->
showHideConnectionErrorMenuItem(errors)
}
// Battery banner
val batteryBanner = findViewById<View>(R.id.main_banner_battery) // Banner visibility is toggled in onResume()
val dontAskAgainButton = findViewById<Button>(R.id.main_banner_battery_dontaskagain)
@ -550,6 +555,16 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
}
private fun showHideConnectionErrorMenuItem(errors: Map<String, io.heckel.ntfy.db.ConnectionError>) {
if (!this::menu.isInitialized) {
return
}
runOnUiThread {
val connectionErrorItem = menu.findItem(R.id.main_menu_connection_error)
connectionErrorItem?.isVisible = errors.isNotEmpty()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.main_menu_notifications_enabled -> {
@ -564,6 +579,10 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
onNotificationSettingsClick(enable = true)
true
}
R.id.main_menu_connection_error -> {
onConnectionErrorClick()
true
}
R.id.main_menu_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
true
@ -607,6 +626,12 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
}
private fun onConnectionErrorClick() {
Log.d(TAG, "Showing connection error dialog")
val connectionErrorFragment = ConnectionErrorFragment()
connectionErrorFragment.show(supportFragmentManager, ConnectionErrorFragment.TAG)
}
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
repository.setGlobalMutedUntil(mutedUntilTimestamp)
showHideNotificationMenuItems()

View file

@ -75,6 +75,7 @@ class MainAdapter(
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
private val connectionErrorImageView: View = itemView.findViewById(R.id.main_item_connection_error_image)
private val notificationDisabledUntilImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_until_image)
private val notificationDisabledForeverImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_forever_image)
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
@ -119,6 +120,8 @@ class MainAdapter(
statusView.text = statusMessage
dateView.text = dateText
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
val showConnectionError = subscription.instant && subscription.state == ConnectionState.CONNECTING
connectionErrorImageView.visibility = if (showConnectionError) View.VISIBLE else View.GONE
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
instantImageView.visibility = if (subscription.instant && BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#757575"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View file

@ -0,0 +1,111 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@+id/connection_error_dialog_title"
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" />
<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" />
<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" />
<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">
<TextView
android:id="@+id/connection_error_dialog_stack_trace"
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" />
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -22,7 +22,7 @@
android:layout_marginStart="12dp" app:layout_constraintStart_toEndOf="@+id/main_item_image"
app:layout_constraintVertical_bias="0.0" android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textColor="?android:attr/textColorPrimary" android:layout_marginTop="10dp"
app:layout_constraintEnd_toStartOf="@id/main_item_notification_disabled_until_image"/>
app:layout_constraintEnd_toStartOf="@id/main_item_connection_error_image"/>
<TextView
android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush"
android:layout_width="0dp"
@ -31,6 +31,14 @@
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="10dp" app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/>
<ImageView
android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_warning_amber_24dp"
android:id="@+id/main_item_connection_error_image"
app:layout_constraintTop_toTopOf="@+id/main_item_text"
app:layout_constraintEnd_toStartOf="@+id/main_item_notification_disabled_until_image"
android:paddingTop="3dp" android:layout_marginEnd="3dp"
android:visibility="gone"/>
<ImageView
android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp"

View file

@ -16,6 +16,12 @@
android:icon="@drawable/ic_notifications_off_white_outline_24dp"
android:title="@string/detail_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" />
<item
android:id="@+id/main_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/main_menu_settings"
android:title="@string/main_menu_settings_title" />

View file

@ -60,6 +60,7 @@
<string name="main_menu_report_bug_title">Report a bug</string>
<string name="main_menu_docs_title">Read the docs</string>
<string name="main_menu_rate_title">Rate the app ⭐</string>
<string name="main_menu_connection_error">Connection error</string>
<!-- Main activity: Action mode -->
<string name="main_action_mode_menu_unsubscribe">Unsubscribe</string>
@ -288,6 +289,15 @@
<string name="notification_dialog_tomorrow">Until tomorrow</string>
<string name="notification_dialog_forever">Until resumed</string>
<!-- 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_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>
<!-- Notification popup -->
<string name="notification_popup_action_open">Open</string>
<string name="notification_popup_action_browse">Browse</string>