diff --git a/app/schemas/io.heckel.ntfy.db.Database/13.json b/app/schemas/io.heckel.ntfy.db.Database/13.json index dfb5031..3a89d0f 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/13.json +++ b/app/schemas/io.heckel.ntfy.db.Database/13.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 13, - "identityHash": "39849793e1ed04fe89f0d71a59a56956", + "identityHash": "44fc291d937fdf02b9bc2d0abb10d2e0", "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, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `dedicatedChannels` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "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", @@ -50,6 +50,12 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "insistent", + "columnName": "insistent", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", @@ -344,7 +350,7 @@ "views": [], "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, '39849793e1ed04fe89f0d71a59a56956')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '44fc291d937fdf02b9bc2d0abb10d2e0')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0580856..0950b40 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -142,6 +142,13 @@ android:exported="false"> + + + + @ColumnInfo(name = "icon") val icon: String?, // content://-URI (or later other identifier) @ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name @@ -29,8 +30,42 @@ data class Subscription( @Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE ) { - constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, minPriority: Int, autoDelete: Long, lastNotificationId: String, icon: String, upAppId: String, upConnectorToken: String, displayName: String?, dedicatedChannels: Boolean?) : - this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, lastNotificationId, icon, upAppId, upConnectorToken, displayName, dedicatedChannels == true, 0, 0, 0, ConnectionState.NOT_APPLICABLE) + constructor( + id: Long, + baseUrl: String, + topic: String, + instant: Boolean, + mutedUntil: Long, + minPriority: Int, + autoDelete: Long, + insistent: Int, + lastNotificationId: String, + icon: String, + upAppId: String, + upConnectorToken: String, + displayName: String?, + dedicatedChannels: Boolean + ) : + this( + id, + baseUrl, + topic, + instant, + mutedUntil, + minPriority, + autoDelete, + insistent, + lastNotificationId, + icon, + upAppId, + upConnectorToken, + displayName, + dedicatedChannels, + totalCount = 0, + newCount = 0, + lastActive = 0, + state = ConnectionState.NOT_APPLICABLE + ) } enum class ConnectionState { @@ -45,6 +80,7 @@ data class SubscriptionWithMetadata( val mutedUntil: Long, val autoDelete: Long, val minPriority: Int, + val insistent: Int, val lastNotificationId: String?, val icon: String?, val upAppId: String?, @@ -289,7 +325,8 @@ abstract class Database : RoomDatabase() { private val MIGRATION_12_13 = object : Migration(12, 13) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE Subscription ADD COLUMN dedicatedChannels INTEGER NOT NULL DEFAULT('0')") + db.execSQL("ALTER TABLE Subscription ADD COLUMN insistent INTEGER NOT NULL DEFAULT (-1)") // = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL + db.execSQL("ALTER TABLE Subscription ADD COLUMN dedicatedChannels INTEGER NOT NULL DEFAULT (0)") } } } @@ -299,7 +336,7 @@ abstract class Database : RoomDatabase() { interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -312,7 +349,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -325,7 +362,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -338,7 +375,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -351,7 +388,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive 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 e2cae59..423c716 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -2,6 +2,7 @@ package io.heckel.ntfy.db import android.content.Context import android.content.SharedPreferences +import android.media.MediaPlayer import android.os.Build import androidx.annotation.WorkerThread import androidx.appcompat.app.AppCompatDelegate @@ -18,7 +19,10 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas private val connectionStates = ConcurrentHashMap() private val connectionStatesLiveData = MutableLiveData(connectionStates) + + // TODO Move these into an ApplicationState singleton val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ... + val mediaPlayer = MediaPlayer() init { Log.d(TAG, "Created $this") @@ -288,6 +292,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas .apply() } + fun getInsistentMaxPriorityEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, false) // Disabled by default + } + + fun setInsistentMaxPriorityEnabled(enabled: Boolean) { + sharedPrefs.edit() + .putBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, enabled) + .apply() + } + fun getRecordLogs(): Boolean { return sharedPrefs.getBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, false) // Disabled by default } @@ -389,6 +403,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas mutedUntil = s.mutedUntil, minPriority = s.minPriority, autoDelete = s.autoDelete, + insistent = s.insistent, lastNotificationId = s.lastNotificationId, icon = s.icon, upAppId = s.upAppId, @@ -415,6 +430,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas mutedUntil = s.mutedUntil, minPriority = s.minPriority, autoDelete = s.autoDelete, + insistent = s.insistent, lastNotificationId = s.lastNotificationId, icon = s.icon, upAppId = s.upAppId, @@ -461,6 +477,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val SHARED_PREFS_CONNECTION_PROTOCOL = "ConnectionProtocol" const val SHARED_PREFS_DARK_MODE = "DarkMode" const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled" + const val SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED = "InsistentMaxPriority" const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs" const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime" const val SHARED_PREFS_WEBSOCKET_REMIND_TIME = "JsonStreamRemindTime" // "Use WebSocket" banner (used to be JSON stream deprecation banner) @@ -492,6 +509,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val AUTO_DELETE_THREE_MONTHS_SECONDS = 90 * ONE_DAY_SECONDS const val AUTO_DELETE_DEFAULT_SECONDS = AUTO_DELETE_ONE_MONTH_SECONDS + const val INSISTENT_MAX_PRIORITY_USE_GLOBAL = -1 // Values must match values.xml + const val INSISTENT_MAX_PRIORITY_ENABLED = 1 // 0 = Disabled (but not needed in code) + const val CONNECTION_PROTOCOL_JSONHTTP = "jsonhttp" const val CONNECTION_PROTOCOL_WS = "ws" diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index bdd8b01..54c48ea 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -37,7 +37,7 @@ class ApiService { user: User? = null, message: String, title: String = "", - priority: Int = 3, + priority: Int = PRIORITY_DEFAULT, tags: List = emptyList(), delay: String = "", body: RequestBody? = null, @@ -45,7 +45,7 @@ class ApiService { ) { val url = topicUrl(baseUrl, topic) val query = mutableListOf() - if (priority in 1..5) { + if (priority in ALL_PRIORITIES) { query.add("priority=$priority") } if (tags.isNotEmpty()) { 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 0c0dede..eec1e59 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -5,6 +5,8 @@ import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioManager import android.media.RingtoneManager import android.net.Uri import android.os.Build @@ -21,9 +23,9 @@ import io.heckel.ntfy.ui.MainActivity import io.heckel.ntfy.util.* import java.util.* - class NotificationService(val context: Context) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val repository = Repository.getInstance(context) fun display(subscription: Subscription, notification: Notification) { Log.d(TAG, "Displaying notification $notification") @@ -58,18 +60,18 @@ class NotificationService(val context: Context) { fun createDefaultNotificationChannels() { maybeCreateNotificationGroup(DEFAULT_GROUP, context.getString(R.string.channel_notifications_group_default_name)) - (1..5).forEach { priority -> maybeCreateNotificationChannel(DEFAULT_GROUP, priority) } + ALL_PRIORITIES.forEach { priority -> maybeCreateNotificationChannel(DEFAULT_GROUP, priority) } } fun createSubscriptionNotificationChannels(subscription: Subscription) { val groupId = subscriptionGroupId(subscription) maybeCreateNotificationGroup(groupId, subscriptionGroupName(subscription)) - (1..5).forEach { priority -> maybeCreateNotificationChannel(groupId, priority) } + ALL_PRIORITIES.forEach { priority -> maybeCreateNotificationChannel(groupId, priority) } } fun deleteSubscriptionNotificationChannels(subscription: Subscription) { val groupId = subscriptionGroupId(subscription) - (1..5).forEach { priority -> maybeDeleteNotificationChannel(groupId, priority) } + ALL_PRIORITIES.forEach { priority -> maybeDeleteNotificationChannel(groupId, priority) } maybeDeleteNotificationGroup(groupId) } @@ -78,7 +80,7 @@ class NotificationService(val context: Context) { } private fun subscriptionGroupId(subscription: Subscription): String { - return subscription.id.toString() + return SUBSCRIPTION_GROUP_PREFIX + subscription.id.toString() } private fun subscriptionGroupName(subscription: Subscription): String { @@ -88,7 +90,10 @@ class NotificationService(val context: Context) { private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false) { val title = formatTitle(subscription, notification) val groupId = if (subscription.dedicatedChannels) subscriptionGroupId(subscription) else DEFAULT_GROUP - val builder = NotificationCompat.Builder(context, toChannelId(groupId, notification.priority)) + val channelId = toChannelId(groupId, notification.priority) + val insistent = notification.priority == PRIORITY_MAX && + (repository.getInsistentMaxPriorityEnabled() || subscription.insistent == Repository.INSISTENT_MAX_PRIORITY_ENABLED) + val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notification) .setColor(ContextCompat.getColor(context, Colors.notificationIcon(context))) .setContentTitle(title) @@ -96,7 +101,8 @@ class NotificationService(val context: Context) { .setAutoCancel(true) // Cancel when notification is clicked setStyleAndText(builder, subscription, notification) // Preview picture or big text style setClickAction(builder, subscription, notification) - maybeSetSound(builder, update) + maybeSetDeleteIntent(builder, insistent) + maybeSetSound(builder, insistent, update) maybeSetProgress(builder, notification) maybeAddOpenAction(builder, notification) maybeAddBrowseAction(builder, notification) @@ -106,12 +112,24 @@ class NotificationService(val context: Context) { maybeCreateNotificationGroup(groupId, subscriptionGroupName(subscription)) maybeCreateNotificationChannel(groupId, notification.priority) + maybePlayInsistentSound(groupId, insistent) notificationManager.notify(notification.notificationId, builder.build()) } - private fun maybeSetSound(builder: NotificationCompat.Builder, update: Boolean) { - if (!update) { + private fun maybeSetDeleteIntent(builder: NotificationCompat.Builder, insistent: Boolean) { + if (!insistent) { + return + } + val intent = Intent(context, DeleteBroadcastReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE) + builder.setDeleteIntent(pendingIntent) + } + + private fun maybeSetSound(builder: NotificationCompat.Builder, insistent: Boolean, update: Boolean) { + // Note that the sound setting is ignored in Android => O (26) in favor of notification channels + val hasSound = !update && !insistent + if (hasSound) { val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) builder.setSound(defaultSoundUri) } else { @@ -327,6 +345,18 @@ class NotificationService(val context: Context) { } } + /** + * Receives a broadcast when a notification is swiped away. This is currently + * only called for notifications with an insistent sound. + */ + class DeleteBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "Media player: Stopping insistent ring") + val mediaPlayer = Repository.getInstance(context).mediaPlayer + mediaPlayer.stop() + } + } + private fun detailActivityIntent(subscription: Subscription): PendingIntent? { val intent = Intent(context, DetailActivity::class.java).apply { putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) @@ -349,9 +379,9 @@ class NotificationService(val context: Context) { val channelId = toChannelId(group, priority) val pause = 300L val channel = when (priority) { - 1 -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_min_name), NotificationManager.IMPORTANCE_MIN) - 2 -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_low_name), NotificationManager.IMPORTANCE_LOW) - 4 -> { + PRIORITY_MIN -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_min_name), NotificationManager.IMPORTANCE_MIN) + PRIORITY_LOW -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_low_name), NotificationManager.IMPORTANCE_LOW) + PRIORITY_HIGH -> { val channel = NotificationChannel(channelId, context.getString(R.string.channel_notifications_high_name), NotificationManager.IMPORTANCE_HIGH) channel.enableVibration(true) channel.vibrationPattern = longArrayOf( @@ -360,10 +390,11 @@ class NotificationService(val context: Context) { ) channel } - 5 -> { + PRIORITY_MAX -> { val channel = NotificationChannel(channelId, context.getString(R.string.channel_notifications_max_name), NotificationManager.IMPORTANCE_HIGH) // IMPORTANCE_MAX does not exist channel.enableLights(true) channel.enableVibration(true) + channel.setBypassDnd(true) channel.vibrationPattern = longArrayOf( pause, 100, pause, 100, pause, 100, pause, 2000, @@ -399,13 +430,46 @@ class NotificationService(val context: Context) { } } - private fun toChannelId(group: String, priority: Int): String { + private fun toChannelId(groupId: String, priority: Int): String { return when (priority) { - 1 -> group + GROUP_SUFFIX_PRIORITY_MIN - 2 -> group + GROUP_SUFFIX_PRIORITY_LOW - 4 -> group + GROUP_SUFFIX_PRIORITY_HIGH - 5 -> group + GROUP_SUFFIX_PRIORITY_MAX - else -> group + GROUP_SUFFIX_PRIORITY_DEFAULT + PRIORITY_MIN -> groupId + GROUP_SUFFIX_PRIORITY_MIN + PRIORITY_LOW -> groupId + GROUP_SUFFIX_PRIORITY_LOW + PRIORITY_HIGH -> groupId + GROUP_SUFFIX_PRIORITY_HIGH + PRIORITY_MAX -> groupId + GROUP_SUFFIX_PRIORITY_MAX + else -> groupId + GROUP_SUFFIX_PRIORITY_DEFAULT + } + } + + private fun maybePlayInsistentSound(groupId: String, insistent: Boolean) { + if (!insistent) { + return + } + try { + val mediaPlayer = repository.mediaPlayer + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + if (audioManager.getStreamVolume(AudioManager.STREAM_ALARM) != 0) { + Log.d(TAG, "Media player: Playing insistent alarm on alarm channel") + mediaPlayer.reset() + mediaPlayer.setDataSource(context, getInsistentSound(groupId)) + mediaPlayer.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build()) + mediaPlayer.isLooping = true + mediaPlayer.prepare() + mediaPlayer.start() + } else { + Log.d(TAG, "Media player: Alarm volume is 0; not playing insistent alarm") + } + } catch (e: Exception) { + Log.w(TAG, "Media player: Failed to play insistent alarm", e) + } + } + + private fun getInsistentSound(groupId: String): Uri { + return if (channelsSupported()) { + val channelId = toChannelId(groupId, PRIORITY_MAX) + val channel = notificationManager.getNotificationChannel(channelId) + channel.sound + } else { + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) } } @@ -466,6 +530,7 @@ class NotificationService(val context: Context) { private const val TAG = "NtfyNotifService" private 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" private const val GROUP_SUFFIX_PRIORITY_DEFAULT = "" diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 1df09eb..851f43a 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -115,6 +115,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra mutedUntil = 0, minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, + insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL, lastNotificationId = null, icon = null, upAppId = null, @@ -256,6 +257,13 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra // Mark this subscription as "open" so we don't receive notifications for it repository.detailViewSubscriptionId.set(subscriptionId) + + // Stop insistent playback (if running, otherwise it'll throw) + try { + repository.mediaPlayer.stop() + } catch (_: Exception) { + // Ignore errors + } } override fun onResume() { diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index c3a08fe..0b56803 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -145,22 +145,22 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: private fun renderPriority(context: Context, notification: Notification) { when (notification.priority) { - 1 -> { + PRIORITY_MIN -> { priorityImageView.visibility = View.VISIBLE priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_1_24dp)) } - 2 -> { + PRIORITY_LOW -> { priorityImageView.visibility = View.VISIBLE priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_2_24dp)) } - 3 -> { + PRIORITY_DEFAULT -> { priorityImageView.visibility = View.GONE } - 4 -> { + PRIORITY_HIGH -> { priorityImageView.visibility = View.VISIBLE priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_4_24dp)) } - 5 -> { + PRIORITY_MAX -> { priorityImageView.visibility = View.VISIBLE priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_5_24dp)) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt index 819b421..ccc0191 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -118,6 +118,7 @@ class DetailSettingsActivity : AppCompatActivity() { loadMutedUntilPref() loadMinPriorityPref() loadAutoDeletePref() + loadInsistentMaxPriorityPref() loadIconSetPref() loadIconRemovePref() if (notificationService.channelsSupported()) { @@ -261,8 +262,8 @@ class DetailSettingsActivity : AppCompatActivity() { value = repository.getMinPriority() } val summary = when (value) { - 1 -> getString(R.string.settings_notifications_min_priority_summary_any) - 5 -> getString(R.string.settings_notifications_min_priority_summary_max) + PRIORITY_MIN -> getString(R.string.settings_notifications_min_priority_summary_any) + PRIORITY_MAX -> getString(R.string.settings_notifications_min_priority_summary_max) else -> { val minPriorityString = toPriorityString(requireContext(), value) getString(R.string.settings_notifications_min_priority_summary_x_or_higher, value, minPriorityString) @@ -289,7 +290,7 @@ class DetailSettingsActivity : AppCompatActivity() { pref?.summaryProvider = Preference.SummaryProvider { preference -> var seconds = preference.value.toLongOrNull() ?: Repository.AUTO_DELETE_USE_GLOBAL val global = seconds == Repository.AUTO_DELETE_USE_GLOBAL - if (seconds == Repository.AUTO_DELETE_USE_GLOBAL) { + if (global) { seconds = repository.getAutoDeleteSeconds() } val summary = when (seconds) { @@ -305,6 +306,33 @@ class DetailSettingsActivity : AppCompatActivity() { } } + private fun loadInsistentMaxPriorityPref() { + val prefId = context?.getString(R.string.detail_settings_notifications_insistent_max_priority_key) ?: return + val pref: ListPreference? = findPreference(prefId) + pref?.isVisible = true + pref?.value = subscription.insistent.toString() + pref?.preferenceDataStore = object : PreferenceDataStore() { + override fun putString(key: String?, value: String?) { + val intValue = value?.toIntOrNull() ?:return + save(subscription.copy(insistent = intValue)) + } + override fun getString(key: String?, defValue: String?): String { + return subscription.insistent.toString() + } + } + pref?.summaryProvider = Preference.SummaryProvider { preference -> + val value = preference.value.toIntOrNull() ?: Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL + val global = value == Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL + val enabled = if (global) repository.getInsistentMaxPriorityEnabled() else value == Repository.INSISTENT_MAX_PRIORITY_ENABLED + val summary = if (enabled) { + getString(R.string.settings_notifications_insistent_max_priority_summary_enabled) + } else { + getString(R.string.settings_notifications_insistent_max_priority_summary_disabled) + } + maybeAppendGlobal(summary, global) + } + } + private fun loadIconSetPref() { val prefId = context?.getString(R.string.detail_settings_appearance_icon_set_key) ?: return iconSetPref = findPreference(prefId) ?: return 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 3e65656..e131f32 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -454,6 +454,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc mutedUntil = 0, minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, + insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL, lastNotificationId = null, icon = null, upAppId = null, diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index cc6f882..a03942a 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -191,8 +191,8 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere } minPriority?.summaryProvider = Preference.SummaryProvider { pref -> when (val minPriorityValue = pref.value.toIntOrNull() ?: 1) { // 1/low means all priorities - 1 -> getString(R.string.settings_notifications_min_priority_summary_any) - 5 -> getString(R.string.settings_notifications_min_priority_summary_max) + PRIORITY_MIN -> getString(R.string.settings_notifications_min_priority_summary_any) + PRIORITY_MAX -> getString(R.string.settings_notifications_min_priority_summary_max) else -> { val minPriorityString = toPriorityString(requireContext(), minPriorityValue) getString(R.string.settings_notifications_min_priority_summary_x_or_higher, minPriorityValue, minPriorityString) @@ -200,6 +200,26 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere } } + // Keep alerting for max priority + val insistentMaxPriorityPrefId = context?.getString(R.string.settings_notifications_insistent_max_priority_key) ?: return + val insistentMaxPriority: SwitchPreference? = findPreference(insistentMaxPriorityPrefId) + insistentMaxPriority?.isChecked = repository.getInsistentMaxPriorityEnabled() + insistentMaxPriority?.preferenceDataStore = object : PreferenceDataStore() { + override fun putBoolean(key: String?, value: Boolean) { + repository.setInsistentMaxPriorityEnabled(value) + } + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + return repository.getInsistentMaxPriorityEnabled() + } + } + insistentMaxPriority?.summaryProvider = Preference.SummaryProvider { pref -> + if (pref.isChecked) { + getString(R.string.settings_notifications_insistent_max_priority_summary_enabled) + } else { + getString(R.string.settings_notifications_insistent_max_priority_summary_disabled) + } + } + // Channel settings val channelPrefsPrefId = context?.getString(R.string.settings_notifications_channel_prefs_key) ?: return val channelPrefs: Preference? = findPreference(channelPrefsPrefId) diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 093d51f..20cf963 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -74,6 +74,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() { mutedUntil = 0, minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, + insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL, lastNotificationId = null, icon = null, upAppId = appId, diff --git a/app/src/main/java/io/heckel/ntfy/util/Constants.kt b/app/src/main/java/io/heckel/ntfy/util/Constants.kt new file mode 100644 index 0000000..d708011 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/util/Constants.kt @@ -0,0 +1,11 @@ +package io.heckel.ntfy.util + +const val ANDROID_APP_MIME_TYPE = "application/vnd.android.package-archive" + +const val PRIORITY_MIN = 1 +const val PRIORITY_LOW = 2 +const val PRIORITY_DEFAULT = 3 +const val PRIORITY_HIGH = 4 +const val PRIORITY_MAX = 5 + +val ALL_PRIORITIES = listOf(PRIORITY_MIN, PRIORITY_LOW, PRIORITY_DEFAULT, PRIORITY_HIGH, PRIORITY_MAX) diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 8524dc0..26b616c 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -99,17 +99,16 @@ fun formatDateShort(timestampSecs: Long): String { } fun toPriority(priority: Int?): Int { - if (priority != null && (1..5).contains(priority)) return priority - else return 3 + return if (priority != null && ALL_PRIORITIES.contains(priority)) priority else PRIORITY_DEFAULT } fun toPriorityString(context: Context, priority: Int): String { return when (priority) { - 1 -> context.getString(R.string.settings_notifications_priority_min) - 2 -> context.getString(R.string.settings_notifications_priority_low) - 3 -> context.getString(R.string.settings_notifications_priority_default) - 4 -> context.getString(R.string.settings_notifications_priority_high) - 5 -> context.getString(R.string.settings_notifications_priority_max) + PRIORITY_MIN -> context.getString(R.string.settings_notifications_priority_min) + PRIORITY_LOW -> context.getString(R.string.settings_notifications_priority_low) + PRIORITY_DEFAULT -> context.getString(R.string.settings_notifications_priority_default) + PRIORITY_HIGH -> context.getString(R.string.settings_notifications_priority_high) + PRIORITY_MAX -> context.getString(R.string.settings_notifications_priority_max) else -> context.getString(R.string.settings_notifications_priority_default) } } @@ -319,8 +318,6 @@ fun formatBytes(bytes: Long, decimals: Int = 1): String { return java.lang.String.format("%.${decimals}f %cB", value / 1024.0, ci.current()) } -const val androidAppMimeType = "application/vnd.android.package-archive" - fun mimeTypeToIconResource(mimeType: String?): Int { return if (mimeType?.startsWith("image/") == true) { R.drawable.ic_file_image_red_24dp @@ -328,7 +325,7 @@ fun mimeTypeToIconResource(mimeType: String?): Int { R.drawable.ic_file_video_orange_24dp } else if (mimeType?.startsWith("audio/") == true) { R.drawable.ic_file_audio_purple_24dp - } else if (mimeType == androidAppMimeType) { + } else if (mimeType == ANDROID_APP_MIME_TYPE) { R.drawable.ic_file_app_gray_24dp } else { R.drawable.ic_file_document_blue_24dp @@ -342,7 +339,7 @@ fun supportedImage(mimeType: String?): Boolean { // Google Play doesn't allow us to install received .apk files anymore. // See https://github.com/binwiederhier/ntfy/issues/531 fun canOpenAttachment(attachment: Attachment?): Boolean { - if (attachment?.type == androidAppMimeType && !BuildConfig.INSTALL_PACKAGES_AVAILABLE) { + if (attachment?.type == ANDROID_APP_MIME_TYPE && !BuildConfig.INSTALL_PACKAGES_AVAILABLE) { return false } return true diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index deecdbe..533e145 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -281,6 +281,9 @@ After one week After one month After 3 months + Keep alerting for highest priority + Max priority notifications continuously alert until dismissed + Max priority notifications only alert once General Default server Enter your server\'s root URL to use your own server as a default when subscribing to new topics and/or sharing to topics. @@ -355,6 +358,8 @@ Using default settings (sounds, Do Not Disturb override, etc.) Configure notification settings Do Not Disturb (DND) override, sounds, etc. + Keep alerting + Alert only once Appearance Subscription icon Set an icon to be displayed in notifications diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 6155bcd..ebab08b 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -18,6 +18,7 @@ ChannelPrefs AutoDownload AutoDelete + InsistentMaxPriority DefaultBaseURL ManageUsers DarkMode @@ -38,6 +39,7 @@ SubscriptionOpenChannels SubscriptionMinPriority SubscriptionAutoDelete + SubscriptionInsistentMaxPriority SubscriptionAppearance SubscriptionIconSet SubscriptionIconRemove @@ -148,6 +150,16 @@ 2592000 7776000 + + @string/detail_settings_global_setting_title + @string/detail_settings_notifications_insistent_max_priority_list_item_enabled + @string/detail_settings_notifications_insistent_max_priority_list_item_disabled + + + -1 + 1 + 0 + @string/settings_advanced_connection_protocol_entry_jsonhttp @string/settings_advanced_connection_protocol_entry_ws diff --git a/app/src/main/res/xml/detail_preferences.xml b/app/src/main/res/xml/detail_preferences.xml index 6e1eb9f..38cd18b 100644 --- a/app/src/main/res/xml/detail_preferences.xml +++ b/app/src/main/res/xml/detail_preferences.xml @@ -28,6 +28,13 @@ app:entryValues="@array/detail_settings_notifications_auto_delete_values" app:defaultValue="-1" app:isPreferenceVisible="false"/> + +