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"/>
+
+