Show notification when connection lost for 15+ minutes
Add a connection alert notification that triggers when a server connection has been in error state for 15+ minutes. The notification uses the high priority channel and includes dismiss, snooze (1h), and never-show-again action buttons. Auto-dismisses when all connections recover.
This commit is contained in:
parent
2122851ee6
commit
9084cd98cd
7 changed files with 163 additions and 5 deletions
|
|
@ -131,6 +131,18 @@
|
|||
android:enabled="true"
|
||||
android:exported="false"/>
|
||||
|
||||
<!-- Broadcast receiver for connection alert notification actions (dismiss, snooze, never show again) -->
|
||||
<receiver
|
||||
android:name=".service.SubscriberService$ConnectionAlertBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="io.heckel.ntfy.CONNECTION_ALERT_DISMISS"/>
|
||||
<action android:name="io.heckel.ntfy.CONNECTION_ALERT_SNOOZE"/>
|
||||
<action android:name="io.heckel.ntfy.CONNECTION_ALERT_NEVER"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Broadcast receiver to send messages via intents -->
|
||||
<receiver
|
||||
android:name=".msg.BroadcastService$BroadcastReceiver"
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ enum class ConnectionState {
|
|||
data class ConnectionDetails(
|
||||
val state: ConnectionState = ConnectionState.NOT_APPLICABLE,
|
||||
val error: Throwable? = null,
|
||||
val nextRetryTime: Long = 0L
|
||||
val nextRetryTime: Long = 0L,
|
||||
val firstErrorTime: Long = 0L
|
||||
) {
|
||||
fun getStackTraceString(): String {
|
||||
return error?.stackTraceToString() ?: ""
|
||||
|
|
|
|||
|
|
@ -440,6 +440,16 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
|
|||
}
|
||||
}
|
||||
|
||||
fun getConnectionAlertSnoozeUntil(): Long {
|
||||
return sharedPrefs.getLong(SHARED_PREFS_CONNECTION_ALERT_SNOOZE_UNTIL, CONNECTION_ALERT_SNOOZE_UNTIL_DEFAULT)
|
||||
}
|
||||
|
||||
fun setConnectionAlertSnoozeUntil(timeMillis: Long) {
|
||||
sharedPrefs.edit {
|
||||
putLong(SHARED_PREFS_CONNECTION_ALERT_SNOOZE_UNTIL, timeMillis)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultBaseUrl(): String? {
|
||||
return sharedPrefs.getString(SHARED_PREFS_DEFAULT_BASE_URL, null) ?:
|
||||
sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) // Fall back to UP URL, removed when default is set!
|
||||
|
|
@ -571,8 +581,14 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
|
|||
}
|
||||
|
||||
fun updateConnectionDetails(baseUrl: String, state: ConnectionState, error: Throwable? = null, nextRetryTime: Long = 0L) {
|
||||
val details = ConnectionDetails(state, error, nextRetryTime)
|
||||
val current = connectionDetails[baseUrl]
|
||||
val firstErrorTime = when {
|
||||
error == null -> 0L
|
||||
state == ConnectionState.CONNECTED -> 0L
|
||||
current?.firstErrorTime != null && current.firstErrorTime > 0L -> current.firstErrorTime
|
||||
else -> System.currentTimeMillis()
|
||||
}
|
||||
val details = ConnectionDetails(state, error, nextRetryTime, firstErrorTime)
|
||||
if (current != details) {
|
||||
if (state == ConnectionState.NOT_APPLICABLE && error == null) {
|
||||
connectionDetails.remove(baseUrl)
|
||||
|
|
@ -623,6 +639,9 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
|
|||
const val SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME = "WebSocketReconnectRemindTime"
|
||||
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL
|
||||
const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL"
|
||||
const val SHARED_PREFS_CONNECTION_ALERT_SNOOZE_UNTIL = "ConnectionAlertSnoozeUntil"
|
||||
const val CONNECTION_ALERT_SNOOZE_UNTIL_DEFAULT = 0L
|
||||
const val CONNECTION_ALERT_NEVER_SHOW = Long.MAX_VALUE
|
||||
const val SHARED_PREFS_LAST_TOPICS = "LastTopics"
|
||||
|
||||
private const val LAST_TOPICS_COUNT = 3
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ class NotificationService(val context: Context) {
|
|||
notificationManager.deleteNotificationChannelGroup(id)
|
||||
}
|
||||
|
||||
private fun toChannelId(groupId: String, priority: Int): String {
|
||||
fun toChannelId(groupId: String, priority: Int): String {
|
||||
return when (priority) {
|
||||
PRIORITY_MIN -> groupId + GROUP_SUFFIX_PRIORITY_MIN
|
||||
PRIORITY_LOW -> groupId + GROUP_SUFFIX_PRIORITY_LOW
|
||||
|
|
@ -535,7 +535,7 @@ class NotificationService(val context: Context) {
|
|||
|
||||
private const val TAG = "NtfyNotifService"
|
||||
|
||||
private const val DEFAULT_GROUP = "ntfy"
|
||||
const val DEFAULT_GROUP = "ntfy"
|
||||
private const val SUBSCRIPTION_GROUP_PREFIX = "ntfy-subscription-"
|
||||
private const val GROUP_SUFFIX_PRIORITY_MIN = "-min"
|
||||
private const val GROUP_SUFFIX_PRIORITY_LOW = "-low"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import io.heckel.ntfy.db.Repository
|
|||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.util.PRIORITY_HIGH
|
||||
import io.heckel.ntfy.ui.Colors
|
||||
import io.heckel.ntfy.ui.MainActivity
|
||||
import io.heckel.ntfy.util.HttpUtil
|
||||
|
|
@ -275,7 +277,6 @@ class SubscriberService : Service() {
|
|||
val connection = connections.remove(connectionId)
|
||||
connection?.close()
|
||||
}
|
||||
|
||||
// Update foreground service notification popup
|
||||
if (connections.isNotEmpty()) {
|
||||
val title = getString(R.string.channel_subscriber_notification_title)
|
||||
|
|
@ -307,6 +308,88 @@ class SubscriberService : Service() {
|
|||
|
||||
private fun onConnectionDetailsChanged(baseUrl: String, state: ConnectionState, throwable: Throwable?, nextRetryTime: Long) {
|
||||
repository.updateConnectionDetails(baseUrl, state, throwable, nextRetryTime)
|
||||
if (state == ConnectionState.CONNECTED) {
|
||||
maybeAutoDismissConnectionAlert()
|
||||
} else if (throwable != null) {
|
||||
maybeShowConnectionAlert()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeShowConnectionAlert() {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// Check snooze / never-show-again
|
||||
val snoozeUntil = repository.getConnectionAlertSnoozeUntil()
|
||||
if (snoozeUntil == Repository.CONNECTION_ALERT_NEVER_SHOW) return
|
||||
if (snoozeUntil > now) return
|
||||
|
||||
// Check if any connection has been in error for 15+ minutes
|
||||
val allDetails = repository.getConnectionDetails()
|
||||
val disconnectedUrls = allDetails.filter { (_, details) ->
|
||||
details.hasError() && details.firstErrorTime > 0L &&
|
||||
(now - details.firstErrorTime) >= CONNECTION_ALERT_THRESHOLD_MILLIS
|
||||
}.keys
|
||||
|
||||
if (disconnectedUrls.isNotEmpty()) {
|
||||
showConnectionAlertNotification(disconnectedUrls)
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAutoDismissConnectionAlert() {
|
||||
val allDetails = repository.getConnectionDetails()
|
||||
val anyStillDisconnected = allDetails.any { (_, details) ->
|
||||
details.hasError() && details.firstErrorTime > 0L
|
||||
}
|
||||
if (!anyStillDisconnected) {
|
||||
notificationManager?.cancel(NOTIFICATION_CONNECTION_ALERT_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showConnectionAlertNotification(disconnectedUrls: Set<String>) {
|
||||
val text = if (disconnectedUrls.size == 1) {
|
||||
getString(R.string.connection_alert_text_one, disconnectedUrls.first(), CONNECTION_ALERT_THRESHOLD_MINUTES)
|
||||
} else {
|
||||
getString(R.string.connection_alert_text_multiple, disconnectedUrls.size, CONNECTION_ALERT_THRESHOLD_MINUTES)
|
||||
}
|
||||
|
||||
val dismissIntent = Intent(this, ConnectionAlertBroadcastReceiver::class.java).apply {
|
||||
action = CONNECTION_ALERT_ACTION_DISMISS
|
||||
}
|
||||
val dismissPendingIntent = PendingIntent.getBroadcast(this, 0, dismissIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val snoozeIntent = Intent(this, ConnectionAlertBroadcastReceiver::class.java).apply {
|
||||
action = CONNECTION_ALERT_ACTION_SNOOZE
|
||||
}
|
||||
val snoozePendingIntent = PendingIntent.getBroadcast(this, 1, snoozeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val neverIntent = Intent(this, ConnectionAlertBroadcastReceiver::class.java).apply {
|
||||
action = CONNECTION_ALERT_ACTION_NEVER
|
||||
}
|
||||
val neverPendingIntent = PendingIntent.getBroadcast(this, 2, neverIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val contentIntent = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val channelId = NotificationService(this).toChannelId(NotificationService.DEFAULT_GROUP, PRIORITY_HIGH)
|
||||
val notification = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(Colors.notificationIcon(this))
|
||||
.setContentTitle(getString(R.string.connection_alert_title))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(contentIntent)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(NotificationCompat.Action.Builder(0, getString(R.string.connection_alert_action_dismiss), dismissPendingIntent).build())
|
||||
.addAction(NotificationCompat.Action.Builder(0, getString(R.string.connection_alert_action_snooze), snoozePendingIntent).build())
|
||||
.addAction(NotificationCompat.Action.Builder(0, getString(R.string.connection_alert_action_never), neverPendingIntent).build())
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Showing connection alert notification")
|
||||
notificationManager?.notify(NOTIFICATION_CONNECTION_ALERT_ID, notification)
|
||||
}
|
||||
|
||||
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {
|
||||
|
|
@ -412,6 +495,30 @@ class SubscriberService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
class ConnectionAlertBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "ConnectionAlertBroadcastReceiver: action=${intent.action}")
|
||||
val repository = Repository.getInstance(context)
|
||||
val notificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
when (intent.action) {
|
||||
CONNECTION_ALERT_ACTION_DISMISS -> {
|
||||
notificationManager.cancel(NOTIFICATION_CONNECTION_ALERT_ID)
|
||||
}
|
||||
CONNECTION_ALERT_ACTION_SNOOZE -> {
|
||||
repository.setConnectionAlertSnoozeUntil(
|
||||
System.currentTimeMillis() + CONNECTION_ALERT_SNOOZE_DURATION_MILLIS
|
||||
)
|
||||
notificationManager.cancel(NOTIFICATION_CONNECTION_ALERT_ID)
|
||||
}
|
||||
CONNECTION_ALERT_ACTION_NEVER -> {
|
||||
repository.setConnectionAlertSnoozeUntil(Repository.CONNECTION_ALERT_NEVER_SHOW)
|
||||
notificationManager.cancel(NOTIFICATION_CONNECTION_ALERT_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Action {
|
||||
START,
|
||||
STOP
|
||||
|
|
@ -427,5 +534,13 @@ class SubscriberService : Service() {
|
|||
private const val NOTIFICATION_GROUP_ID = "io.heckel.ntfy.NOTIFICATION_GROUP_SERVICE"
|
||||
private const val NOTIFICATION_SERVICE_ID = 2586
|
||||
private const val NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS = 10 * 60 * 1000L /*10 minutes*/
|
||||
|
||||
private const val NOTIFICATION_CONNECTION_ALERT_ID = 2587
|
||||
private const val CONNECTION_ALERT_THRESHOLD_MINUTES = 15
|
||||
private const val CONNECTION_ALERT_THRESHOLD_MILLIS = CONNECTION_ALERT_THRESHOLD_MINUTES * 60 * 1000L
|
||||
private const val CONNECTION_ALERT_SNOOZE_DURATION_MILLIS = 60 * 60 * 1000L /*1 hour*/
|
||||
private const val CONNECTION_ALERT_ACTION_DISMISS = "io.heckel.ntfy.CONNECTION_ALERT_DISMISS"
|
||||
private const val CONNECTION_ALERT_ACTION_SNOOZE = "io.heckel.ntfy.CONNECTION_ALERT_SNOOZE"
|
||||
private const val CONNECTION_ALERT_ACTION_NEVER = "io.heckel.ntfy.CONNECTION_ALERT_NEVER"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@
|
|||
<string name="channel_subscriber_notification_noinstant_text_six">Subscribed to six topics</string>
|
||||
<string name="channel_subscriber_notification_noinstant_text_more">Subscribed to %1$d topics</string>
|
||||
|
||||
<!-- Connection alert notification -->
|
||||
<string name="connection_alert_title">Connection lost</string>
|
||||
<string name="connection_alert_text_one">Unable to connect to %1$s for more than %2$d minutes</string>
|
||||
<string name="connection_alert_text_multiple">Unable to connect to %1$d servers for more than %2$d minutes</string>
|
||||
<string name="connection_alert_action_dismiss">Dismiss</string>
|
||||
<string name="connection_alert_action_snooze">Snooze 1h</string>
|
||||
<string name="connection_alert_action_never">Never show</string>
|
||||
|
||||
<!-- Common refresh toasts -->
|
||||
<string name="refresh_message_result">%1$d notification(s) received</string>
|
||||
<string name="refresh_message_no_results">Everything is up to date</string>
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
Features:
|
||||
* Show notification when connection to server has been lost for 15+ minutes, with dismiss, snooze and never-show-again actions
|
||||
|
||||
Bug fixes + maintenance:
|
||||
* Fix crash in settings when fragment is detached during backup/restore or log operations
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue