From bdcecee3e9be0a7b2153aa54aafd7faa7977f0b0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 11 Jan 2026 09:48:51 -0500 Subject: [PATCH] WIP Connection error dialog --- .../main/java/io/heckel/ntfy/db/Database.kt | 15 +++ .../main/java/io/heckel/ntfy/db/Repository.kt | 33 ++++++ .../io/heckel/ntfy/service/JsonConnection.kt | 5 +- .../heckel/ntfy/service/SubscriberService.kt | 18 ++- .../io/heckel/ntfy/service/WsConnection.kt | 2 + .../heckel/ntfy/ui/ConnectionErrorFragment.kt | 112 ++++++++++++++++++ .../java/io/heckel/ntfy/ui/MainActivity.kt | 25 ++++ .../java/io/heckel/ntfy/ui/MainAdapter.kt | 3 + .../res/drawable/ic_warning_gray_24dp.xml | 9 ++ .../res/drawable/ic_warning_white_24dp.xml | 9 ++ .../fragment_connection_error_dialog.xml | 111 +++++++++++++++++ .../main/res/layout/fragment_main_item.xml | 10 +- .../main/res/menu/menu_main_action_bar.xml | 6 + app/src/main/res/values/strings.xml | 10 ++ 14 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt create mode 100644 app/src/main/res/drawable/ic_warning_gray_24dp.xml create mode 100644 app/src/main/res/drawable/ic_warning_white_24dp.xml create mode 100644 app/src/main/res/layout/fragment_connection_error_dialog.xml diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 6110639f..b87dcb2e 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -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, diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 6a7ce048..83a9452e 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -28,6 +28,9 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) private val connectionStates = ConcurrentHashMap() private val connectionStatesLiveData = MutableLiveData(connectionStates) + private val connectionErrors = ConcurrentHashMap() + private val connectionErrorsLiveData = MutableLiveData>(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> { + return connectionErrorsLiveData + } + + fun getConnectionErrors(): Map { + 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" diff --git a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index 8bca6883..90ec577b 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -17,6 +17,7 @@ class JsonConnection( private val sinceId: String?, private val stateChangeListener: (Collection, 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) } } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 68ada1aa..07b344f3 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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, 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) { diff --git a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index b228b5df..481a2f40 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -42,6 +42,7 @@ class WsConnection( private val sinceId: String?, private val stateChangeListener: (Collection, 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() diff --git a/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt new file mode 100644 index 00000000..e0b00a2c --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt @@ -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 = 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" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index f364e16c..badeafb3 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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(R.id.main_banner_battery) // Banner visibility is toggled in onResume() val dontAskAgainButton = findViewById