diff --git a/app/schemas/io.heckel.ntfy.db.Database/17.json b/app/schemas/io.heckel.ntfy.db.Database/17.json new file mode 100644 index 00000000..5597670f --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/17.json @@ -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')" + ] + } +} \ No newline at end of file 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 b87dcb2e..27bd14cc 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -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() ?: "" 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 83a9452e..d9d168e9 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -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) { 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 90ec577b..2fb1fb93 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -17,7 +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 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) } 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 07b344f3..223691f7 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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) { 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 481a2f40..d0ad9658 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -42,7 +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 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) } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt index 287d6bd9..b888b7fc 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/ConnectionErrorFragment.kt @@ -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) { diff --git a/app/src/main/res/layout/fragment_connection_error_dialog.xml b/app/src/main/res/layout/fragment_connection_error_dialog.xml index 727b9856..5ca44f6e 100644 --- a/app/src/main/res/layout/fragment_connection_error_dialog.xml +++ b/app/src/main/res/layout/fragment_connection_error_dialog.xml @@ -99,6 +99,33 @@ app:layout_constraintStart_toEndOf="@id/connection_error_dialog_error_icon" app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_server_layout" /> + + + + + + + app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_countdown" /> + app:layout_constraintTop_toBottomOf="@id/connection_error_dialog_retry_chip"> No additional details available No error Copy + Retry now + Retrying in %1$ds… + Retrying… Open