diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c930da0f..6ca9d3c0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -131,6 +131,18 @@ android:enabled="true" android:exported="false"/> + + + + + + + + + 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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index ad489dde..35d97674 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -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" 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 eeb11a9f..56d3233c 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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) { + 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" } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c558744..986a33e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,14 @@ Subscribed to six topics Subscribed to %1$d topics + + Connection lost + Unable to connect to %1$s for more than %2$d minutes + Unable to connect to %1$d servers for more than %2$d minutes + Dismiss + Snooze 1h + Never show + %1$d notification(s) received Everything is up to date diff --git a/fastlane/metadata/android/en-US/changelog/NEXT.txt b/fastlane/metadata/android/en-US/changelog/NEXT.txt index 85d903d9..2eb6020f 100644 --- a/fastlane/metadata/android/en-US/changelog/NEXT.txt +++ b/fastlane/metadata/android/en-US/changelog/NEXT.txt @@ -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