Add retry counter and button

This commit is contained in:
Philipp Heckel 2026-01-11 16:35:44 -05:00
parent 4359019dae
commit 9f530ca623
9 changed files with 528 additions and 13 deletions

View file

@ -0,0 +1,423 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "3466bc18a5e477081c1cbd2defcb449f",
"entities": [
{
"tableName": "Subscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `dedicatedChannels` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instant",
"columnName": "instant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mutedUntil",
"columnName": "mutedUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minPriority",
"columnName": "minPriority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "autoDelete",
"columnName": "autoDelete",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "insistent",
"columnName": "insistent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT"
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT"
},
{
"fieldPath": "upAppId",
"columnName": "upAppId",
"affinity": "TEXT"
},
{
"fieldPath": "upConnectorToken",
"columnName": "upConnectorToken",
"affinity": "TEXT"
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "dedicatedChannels",
"columnName": "dedicatedChannels",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Subscription_baseUrl_topic",
"unique": true,
"columnNames": [
"baseUrl",
"topic"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
},
{
"name": "index_Subscription_upConnectorToken",
"unique": true,
"columnNames": [
"upConnectorToken"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
}
]
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `contentType` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscriptionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentType",
"columnName": "contentType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encoding",
"columnName": "encoding",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationId",
"columnName": "notificationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "click",
"columnName": "click",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actions",
"columnName": "actions",
"affinity": "TEXT"
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "icon.url",
"columnName": "icon_url",
"affinity": "TEXT"
},
{
"fieldPath": "icon.contentUri",
"columnName": "icon_contentUri",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.name",
"columnName": "attachment_name",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.type",
"columnName": "attachment_type",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.size",
"columnName": "attachment_size",
"affinity": "INTEGER"
},
{
"fieldPath": "attachment.expires",
"columnName": "attachment_expires",
"affinity": "INTEGER"
},
{
"fieldPath": "attachment.url",
"columnName": "attachment_url",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.contentUri",
"columnName": "attachment_contentUri",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.progress",
"columnName": "attachment_progress",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"subscriptionId"
]
}
},
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
},
{
"tableName": "Log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "exception",
"columnName": "exception",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "CustomHeader",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`baseUrl`, `name`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl",
"name"
]
}
},
{
"tableName": "TrustedCertificate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pem",
"columnName": "pem",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
},
{
"tableName": "ClientCertificate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `p12Base64` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "p12Base64",
"columnName": "p12Base64",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3466bc18a5e477081c1cbd2defcb449f')"
]
}
}

View file

@ -80,7 +80,8 @@ data class ConnectionError(
val baseUrl: String,
val message: String,
val throwable: Throwable?,
val timestamp: Long = System.currentTimeMillis()
val timestamp: Long = System.currentTimeMillis(),
val nextRetryTime: Long = 0L
) {
fun getStackTraceString(): String {
return throwable?.stackTraceToString() ?: ""

View file

@ -580,11 +580,11 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
return connectionErrors.toMap()
}
fun updateConnectionError(baseUrl: String, message: String, throwable: Throwable?) {
val error = ConnectionError(baseUrl, message, throwable)
fun updateConnectionError(baseUrl: String, message: String, throwable: Throwable?, nextRetryTime: Long = 0L) {
val error = ConnectionError(baseUrl, message, throwable, System.currentTimeMillis(), nextRetryTime)
connectionErrors[baseUrl] = error
connectionErrorsLiveData.postValue(connectionErrors.toMap())
Log.d(TAG, "Connection error updated for $baseUrl: $message")
Log.d(TAG, "Connection error updated for $baseUrl: $message (next retry at $nextRetryTime)")
}
fun clearConnectionError(baseUrl: String) {

View file

@ -17,7 +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 errorListener: (String, Throwable, Long) -> Unit,
private val serviceActive: () -> Boolean
) : Connection {
private val baseUrl = connectionId.baseUrl
@ -47,11 +47,12 @@ class JsonConnection(
notificationListener(subscription, notificationWithSubscriptionId)
}
val failed = AtomicBoolean(false)
var lastError: Exception? = null
val fail = { e: Exception ->
failed.set(true)
lastError = e
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
errorListener(baseUrl, e)
}
}
@ -66,15 +67,17 @@ class JsonConnection(
}
} catch (e: Exception) {
Log.e(TAG, "[$url] Connection failed: ${e.message}", e)
lastError = e
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
errorListener(baseUrl, e)
}
}
// If we're not cancelled yet, wait little before retrying (incremental back-off)
if (isActive && serviceActive()) {
retryMillis = nextRetryMillis(retryMillis, startTime)
val nextRetryTime = System.currentTimeMillis() + retryMillis
lastError?.let { errorListener(baseUrl, it, nextRetryTime) }
Log.d(TAG, "[$url] Connection failed, retrying connection in ${retryMillis / 1000}s ...")
delay(retryMillis)
}

View file

@ -318,9 +318,9 @@ class SubscriberService : Service() {
}
}
private fun onConnectionError(baseUrl: String, throwable: Throwable) {
private fun onConnectionError(baseUrl: String, throwable: Throwable, nextRetryTime: Long) {
val message = throwable.message ?: "Unknown error"
repository.updateConnectionError(baseUrl, message, throwable)
repository.updateConnectionError(baseUrl, message, throwable, nextRetryTime)
}
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {

View file

@ -42,7 +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 errorListener: (String, Throwable, Long) -> Unit,
private val alarmManager: AlarmManager
) : Connection {
private val parser = NotificationParser()
@ -184,10 +184,11 @@ class WsConnection(
return@synchronize
}
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
errorListener(baseUrl, t)
state = State.Disconnected
errorCount++
val retrySeconds = RETRY_SECONDS.getOrNull(errorCount) ?: RETRY_SECONDS.last()
val nextRetryTime = System.currentTimeMillis() + (retrySeconds * 1000L)
errorListener(baseUrl, t, nextRetryTime)
scheduleReconnect(retrySeconds)
}
}

View file

@ -3,6 +3,8 @@ package io.heckel.ntfy.ui
import android.app.Dialog
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
@ -17,6 +19,7 @@ import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
import io.heckel.ntfy.db.ConnectionError
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.copyToClipboard
class ConnectionErrorFragment : DialogFragment() {
@ -29,10 +32,20 @@ class ConnectionErrorFragment : DialogFragment() {
private lateinit var serverLayout: TextInputLayout
private lateinit var serverDropdown: AutoCompleteTextView
private lateinit var errorTextView: TextView
private lateinit var countdownTextView: TextView
private lateinit var retryChip: Chip
private lateinit var detailsChip: Chip
private lateinit var detailsScrollView: HorizontalScrollView
private lateinit var stackTraceTextView: TextView
private val handler = Handler(Looper.getMainLooper())
private val countdownRunnable = object : Runnable {
override fun run() {
updateCountdown()
handler.postDelayed(this, 1000)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (activity == null) {
throw IllegalStateException("Activity cannot be null")
@ -77,6 +90,8 @@ class ConnectionErrorFragment : DialogFragment() {
serverLayout = view.findViewById(R.id.connection_error_dialog_server_layout)
serverDropdown = view.findViewById(R.id.connection_error_dialog_server_dropdown)
errorTextView = view.findViewById(R.id.connection_error_dialog_error_text)
countdownTextView = view.findViewById(R.id.connection_error_dialog_countdown)
retryChip = view.findViewById(R.id.connection_error_dialog_retry_chip)
detailsChip = view.findViewById(R.id.connection_error_dialog_details_chip)
detailsScrollView = view.findViewById(R.id.connection_error_dialog_details_scroll)
stackTraceTextView = view.findViewById(R.id.connection_error_dialog_stack_trace)
@ -105,6 +120,22 @@ class ConnectionErrorFragment : DialogFragment() {
updateDetailsVisibility(isChecked)
}
// Retry now button
retryChip.setOnClickListener {
SubscriberServiceManager.refresh(requireContext())
dismiss()
}
// Observe connection errors to update countdown when it changes
repository.getConnectionErrorsLiveData().observe(this) { errors ->
connectionErrors = if (filterBaseUrl != null) {
errors.filterKeys { it == filterBaseUrl }
} else {
errors
}
updateErrorDisplay()
}
// Build dialog
val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog)
dialog.setContentView(view)
@ -120,6 +151,14 @@ class ConnectionErrorFragment : DialogFragment() {
ViewGroup.LayoutParams.MATCH_PARENT
)
}
// Start countdown timer
handler.post(countdownRunnable)
}
override fun onStop() {
super.onStop()
// Stop countdown timer
handler.removeCallbacks(countdownRunnable)
}
private fun updateErrorDisplay() {
@ -134,6 +173,24 @@ class ConnectionErrorFragment : DialogFragment() {
stackTraceTextView.text = ""
}
updateDetailsVisibility(detailsChip.isChecked)
updateCountdown()
}
private fun updateCountdown() {
val error = selectedBaseUrl?.let { connectionErrors[it] }
if (error != null && error.nextRetryTime > 0) {
val remainingMillis = error.nextRetryTime - System.currentTimeMillis()
if (remainingMillis > 0) {
val remainingSeconds = (remainingMillis / 1000).toInt()
countdownTextView.text = getString(R.string.connection_error_dialog_retry_countdown, remainingSeconds)
countdownTextView.visibility = View.VISIBLE
} else {
countdownTextView.text = getString(R.string.connection_error_dialog_retrying)
countdownTextView.visibility = View.VISIBLE
}
} else {
countdownTextView.visibility = View.GONE
}
}
private fun updateDetailsVisibility(visible: Boolean) {

View file

@ -99,6 +99,33 @@
app:layout_constraintStart_toEndOf="@id/connection_error_dialog_error_icon"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_server_layout" />
<!-- Countdown text -->
<TextView
android:id="@+id/connection_error_dialog_countdown"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_error_text" />
<!-- Retry now chip (left aligned) -->
<com.google.android.material.chip.Chip
android:id="@+id/connection_error_dialog_retry_chip"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/connection_error_dialog_retry_now"
app:chipBackgroundColor="@color/chip_background_state"
app:chipStrokeWidth="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_countdown" />
<!-- Details chip (right aligned) -->
<com.google.android.material.chip.Chip
android:id="@+id/connection_error_dialog_details_chip"
@ -111,7 +138,7 @@
app:chipStrokeWidth="0dp"
app:checkedIconVisible="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_error_text" />
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_countdown" />
<!-- Stack trace (scrollable horizontally, no word wrap) -->
<HorizontalScrollView
@ -123,7 +150,7 @@
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_details_chip">
app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_retry_chip">
<ScrollView
android:layout_width="wrap_content"

View file

@ -298,6 +298,9 @@
<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_copy">Copy</string>
<string name="connection_error_dialog_retry_now">Retry now</string>
<string name="connection_error_dialog_retry_countdown">Retrying in %1$ds…</string>
<string name="connection_error_dialog_retrying">Retrying…</string>
<!-- Notification popup -->
<string name="notification_popup_action_open">Open</string>