diff --git a/app/schemas/io.heckel.ntfy.data.Database/3.json b/app/schemas/io.heckel.ntfy.data.Database/3.json
new file mode 100644
index 0000000..42b369c
--- /dev/null
+++ b/app/schemas/io.heckel.ntfy.data.Database/3.json
@@ -0,0 +1,118 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "7b0ef556331f6d2dd3515425837c3d3a",
+ "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, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseUrl",
+ "columnName": "baseUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "topic",
+ "columnName": "topic",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instant",
+ "columnName": "instant",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mutedUntil",
+ "columnName": "mutedUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_Subscription_baseUrl_topic",
+ "unique": true,
+ "columnNames": [
+ "baseUrl",
+ "topic"
+ ],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "Notification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notificationId",
+ "columnName": "notificationId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deleted",
+ "columnName": "deleted",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "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, '7b0ef556331f6d2dd3515425837c3d3a')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 369e85d..a25955c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,15 +7,11 @@
- WAKE_LOCK & RECEIVE_BOOT_COMPLETED are required to restart the foreground service
if it is stopped; see https://robertohuertas.com/2019/06/29/android_foreground_services/
-->
-
-
-
+
+
+
-
-
+ android:value=".ui.MainActivity"/>
-
+
-
+
diff --git a/app/src/main/java/io/heckel/ntfy/app/Application.kt b/app/src/main/java/io/heckel/ntfy/app/Application.kt
index 911c345..79f3201 100644
--- a/app/src/main/java/io/heckel/ntfy/app/Application.kt
+++ b/app/src/main/java/io/heckel/ntfy/app/Application.kt
@@ -1,6 +1,7 @@
package io.heckel.ntfy.app
import android.app.Application
+import android.content.Context
import com.google.firebase.messaging.FirebaseMessagingService
import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository
@@ -8,5 +9,8 @@ import io.heckel.ntfy.msg.ApiService
class Application : Application() {
private val database by lazy { Database.getInstance(this) }
- val repository by lazy { Repository.getInstance(database.subscriptionDao(), database.notificationDao()) }
+ val repository by lazy {
+ val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
+ Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
+ }
}
diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
index 0704aad..5befac6 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Database.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -5,6 +5,7 @@ import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
+import java.util.*
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)])
data class Subscription(
@@ -12,12 +13,14 @@ data class Subscription(
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean,
+ @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule
@Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) {
- constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
+ constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long) :
+ this(id, baseUrl, topic, instant, mutedUntil, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
}
enum class ConnectionState {
@@ -29,6 +32,7 @@ data class SubscriptionWithMetadata(
val baseUrl: String,
val topic: String,
val instant: Boolean,
+ val mutedUntil: Long,
val totalCount: Int,
val newCount: Int,
val lastActive: Long
@@ -44,7 +48,7 @@ data class Notification(
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
-@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 2)
+@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 3)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
@@ -79,6 +83,12 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')")
}
}
+
+ private val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN mutedUntil INTEGER NOT NULL DEFAULT('0')")
+ }
+ }
}
}
@@ -86,7 +96,7 @@ abstract class Database : RoomDatabase() {
interface SubscriptionDao {
@Query("""
SELECT
- s.id, s.baseUrl, s.topic, s.instant,
+ s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@@ -99,7 +109,7 @@ interface SubscriptionDao {
@Query("""
SELECT
- s.id, s.baseUrl, s.topic, s.instant,
+ s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@@ -112,7 +122,7 @@ interface SubscriptionDao {
@Query("""
SELECT
- s.id, s.baseUrl, s.topic, s.instant,
+ s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@@ -125,7 +135,7 @@ interface SubscriptionDao {
@Query("""
SELECT
- s.id, s.baseUrl, s.topic, s.instant,
+ s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@@ -149,7 +159,7 @@ interface SubscriptionDao {
@Dao
interface NotificationDao {
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
- fun list(subscriptionId: Long): Flow>
+ fun listFlow(subscriptionId: Long): Flow>
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted
fun listIds(subscriptionId: Long): List
@@ -160,6 +170,9 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE id = :notificationId")
fun get(notificationId: String): Notification?
+ @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId")
+ fun clearAllNotificationIds(subscriptionId: Long)
+
@Update
fun update(notification: Notification)
diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index 348b40a..6bb0f87 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -1,12 +1,13 @@
package io.heckel.ntfy.data
+import android.content.SharedPreferences
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
-class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
+class Repository(private val sharedPrefs: SharedPreferences, private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
private val connectionStates = ConcurrentHashMap()
private val connectionStatesLiveData = MutableLiveData(connectionStates)
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
@@ -66,7 +67,11 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
}
fun getNotificationsLiveData(subscriptionId: Long): LiveData> {
- return notificationDao.list(subscriptionId).asLiveData()
+ return notificationDao.listFlow(subscriptionId).asLiveData()
+ }
+
+ fun clearAllNotificationIds(subscriptionId: Long) {
+ return notificationDao.clearAllNotificationIds(subscriptionId)
}
fun getNotification(notificationId: String): Notification? {
@@ -84,11 +89,17 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
val maybeExistingNotification = notificationDao.get(notification.id)
if (maybeExistingNotification == null) {
notificationDao.add(notification)
- return true
+ return shouldNotify(notification)
}
return false
}
+ private suspend fun shouldNotify(notification: Notification): Boolean {
+ val detailViewOpen = detailViewSubscriptionId.get() == notification.subscriptionId
+ val muted = isMuted(notification.subscriptionId)
+ return !detailViewOpen && !muted
+ }
+
fun updateNotification(notification: Notification) {
notificationDao.update(notification)
}
@@ -105,6 +116,51 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
notificationDao.removeAll(subscriptionId)
}
+ fun getPollWorkerVersion(): Int {
+ return sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0)
+ }
+
+ fun setPollWorkerVersion(version: Int) {
+ sharedPrefs.edit()
+ .putInt(SHARED_PREFS_POLL_WORKER_VERSION, version)
+ .apply()
+ }
+
+ private suspend fun isMuted(subscriptionId: Long): Boolean {
+ if (isGlobalMuted()) {
+ return true
+ }
+ val s = getSubscription(subscriptionId) ?: return true
+ return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000)
+ }
+
+ private fun isGlobalMuted(): Boolean {
+ val mutedUntil = getGlobalMutedUntil()
+ return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000)
+ }
+
+ fun getGlobalMutedUntil(): Long {
+ return sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
+ }
+
+ fun setGlobalMutedUntil(mutedUntilTimestamp: Long) {
+ sharedPrefs.edit()
+ .putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, mutedUntilTimestamp)
+ .apply()
+ }
+
+ fun checkGlobalMutedUntil(): Boolean {
+ val mutedUntil = sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
+ val expired = mutedUntil > 1L && System.currentTimeMillis()/1000 > mutedUntil
+ if (expired) {
+ sharedPrefs.edit()
+ .putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
+ .apply()
+ return true
+ }
+ return false
+ }
+
private fun toSubscriptionList(list: List): List {
return list.map { s ->
val connectionState = connectionStates.getOrElse(s.id) { ConnectionState.NOT_APPLICABLE }
@@ -113,6 +169,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
baseUrl = s.baseUrl,
topic = s.topic,
instant = s.instant,
+ mutedUntil = s.mutedUntil,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
@@ -130,6 +187,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
baseUrl = s.baseUrl,
topic = s.topic,
instant = s.instant,
+ mutedUntil = s.mutedUntil,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
@@ -160,12 +218,16 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
}
companion object {
+ const val SHARED_PREFS_ID = "MainPreferences"
+ const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
+ const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
+
private const val TAG = "NtfyRepository"
private var instance: Repository? = null
- fun getInstance(subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository {
+ fun getInstance(sharedPrefs: SharedPreferences, subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository {
return synchronized(Repository::class) {
- val newInstance = instance ?: Repository(subscriptionDao, notificationDao)
+ val newInstance = instance ?: Repository(sharedPrefs, subscriptionDao, notificationDao)
instance = newInstance
newInstance
}
diff --git a/app/src/main/java/io/heckel/ntfy/data/Util.kt b/app/src/main/java/io/heckel/ntfy/data/Util.kt
index 6e3b135..138c161 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Util.kt
@@ -7,3 +7,4 @@ fun topicShortUrl(baseUrl: String, topic: String) =
topicUrl(baseUrl, topic)
.replace("http://", "")
.replace("https://", "")
+
diff --git a/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt
index 6582aeb..d61006f 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt
@@ -48,11 +48,10 @@ class FirebaseService : FirebaseMessagingService() {
notificationId = Random.nextInt(),
deleted = false
)
- val added = repository.addNotification(notification)
- val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
+ val shouldNotify = repository.addNotification(notification)
// Send notification (only if it's not already known)
- if (added && !detailViewOpen) {
+ if (shouldNotify) {
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
notifier.send(subscription, notification)
}
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 efef910..830cb96 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt
@@ -29,6 +29,7 @@ class NotificationService(val context: Context) {
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
+ intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
val pendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack
diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
index 348bfcd..20581ec 100644
--- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
+++ b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
@@ -174,10 +174,8 @@ class SubscriberService : Service() {
val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $n")
GlobalScope.launch(Dispatchers.IO) {
- val added = repository.addNotification(n)
- val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
-
- if (added && !detailViewOpen) {
+ val shouldNotify = repository.addNotification(n)
+ if (shouldNotify) {
Log.d(TAG, "[$url] Showing notification: $n")
notifier.send(subscription, n)
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
index 64ae771..160704c 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
@@ -6,7 +6,6 @@ import android.content.Context
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
-import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.CheckBox
@@ -47,11 +46,12 @@ class AddFragment : DialogFragment() {
}
// Dependencies
- val database = Database.getInstance(activity!!.applicationContext)
- repository = Repository.getInstance(database.subscriptionDao(), database.notificationDao())
+ val database = Database.getInstance(requireActivity().applicationContext)
+ val sharedPrefs = requireActivity().getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
+ repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
// Build root view
- val view = requireActivity().layoutInflater.inflate(R.layout.add_dialog_fragment, null)
+ val view = requireActivity().layoutInflater.inflate(R.layout.fragment_add_dialog, null)
topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText
baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText
instantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box)
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 df00a25..a6da265 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
@@ -27,13 +27,11 @@ import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.*
+import java.text.DateFormat
import java.util.*
-import java.util.concurrent.atomic.AtomicLong
-class DetailActivity : AppCompatActivity(), ActionMode.Callback {
+class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFragment.NotificationSettingsListener {
private val viewModel by viewModels {
DetailViewModelFactory((application as Application).repository)
}
@@ -47,6 +45,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private var subscriptionBaseUrl: String = "" // Set in onCreate()
private var subscriptionTopic: String = "" // Set in onCreate()
private var subscriptionInstant: Boolean = false // Set in onCreate() & updated by options menu!
+ private var subscriptionMutedUntil: Long = 0L // Set in onCreate() & updated by options menu!
// UI elements
private lateinit var adapter: DetailAdapter
@@ -59,7 +58,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(R.layout.detail_activity)
+ setContentView(R.layout.activity_detail)
Log.d(MainActivity.TAG, "Create $this")
@@ -75,6 +74,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return
subscriptionInstant = intent.getBooleanExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, false)
+ subscriptionMutedUntil = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, 0L)
// Set title
val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
@@ -152,41 +152,78 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
override fun onPause() {
super.onPause()
- Log.d(TAG, "onResume hook: Marking subscription $subscriptionId as 'not open'")
+ Log.d(TAG, "onPause hook: Removing 'notificationId' from all notifications for $subscriptionId")
+ GlobalScope.launch(Dispatchers.IO) {
+ // Note: This is here and not in onDestroy/onStop, because we want to clear notifications as early
+ // as possible, so that we don't see the "new" bubble in the main list anymore.
+ repository.clearAllNotificationIds(subscriptionId)
+ }
+ Log.d(TAG, "onPause hook: Marking subscription $subscriptionId as 'not open'")
repository.detailViewSubscriptionId.set(0) // Mark as closed
}
- override fun onDestroy() {
- repository.detailViewSubscriptionId.set(0) // Mark as closed
- Log.d(TAG, "onDestroy hook: Marking subscription $subscriptionId as 'not open'")
- super.onDestroy()
- }
-
private fun maybeCancelNotificationPopups(notifications: List) {
val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 }
if (notificationsWithPopups.isNotEmpty()) {
lifecycleScope.launch(Dispatchers.IO) {
notificationsWithPopups.forEach { notification ->
notifier?.cancel(notification)
- repository.updateNotification(notification.copy(notificationId = 0))
+ // Do NOT remove the notificationId here, we need that for the UI indicators; we'll remove it in onPause()
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.detail_action_bar_menu, menu)
+ menuInflater.inflate(R.menu.menu_detail_action_bar, menu)
this.menu = menu
+
+ // Show and hide buttons
showHideInstantMenuItems(subscriptionInstant)
+ showHideNotificationMenuItems(subscriptionMutedUntil)
+
+ // Regularly check if "notification muted" time has passed
+ // NOTE: This is done here, because then we know that we've initialized the menu items.
+ startNotificationMutedChecker()
+
return true
}
+ private fun startNotificationMutedChecker() {
+ lifecycleScope.launch(Dispatchers.IO) {
+ delay(1000) // Just to be sure we've initialized all the things, we wait a bit ...
+ while (isActive) {
+ Log.d(TAG, "Checking 'muted until' timestamp for subscription $subscriptionId")
+ val subscription = repository.getSubscription(subscriptionId) ?: return@launch
+ val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil
+ if (mutedUntilExpired) {
+ val newSubscription = subscription.copy(mutedUntil = 0L)
+ repository.updateSubscription(newSubscription)
+ showHideNotificationMenuItems(0L)
+ }
+ delay(60_000)
+ }
+ }
+ }
+
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.detail_menu_test -> {
onTestClick()
true
}
+ R.id.detail_menu_notifications_enabled -> {
+ onNotificationSettingsClick(enable = false)
+ true
+ }
+ R.id.detail_menu_notifications_disabled_until -> {
+ onNotificationSettingsClick(enable = true)
+ true
+ }
+ R.id.detail_menu_notifications_disabled_forever -> {
+ onNotificationSettingsClick(enable = true)
+ true
+ }
R.id.detail_menu_enable_instant -> {
onInstantEnableClick(enable = true)
true
@@ -228,6 +265,37 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
}
}
+ private fun onNotificationSettingsClick(enable: Boolean) {
+ if (!enable) {
+ Log.d(TAG, "Showing notification settings dialog for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
+ val notificationFragment = NotificationFragment()
+ notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
+ } else {
+ Log.d(TAG, "Re-enabling notifications ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
+ onNotificationMutedUntilChanged(0L)
+ }
+ }
+
+ override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ val subscription = repository.getSubscription(subscriptionId)
+ val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp)
+ newSubscription?.let { repository.updateSubscription(newSubscription) }
+ subscriptionMutedUntil = mutedUntilTimestamp
+ showHideNotificationMenuItems(mutedUntilTimestamp)
+ runOnUiThread {
+ when (mutedUntilTimestamp) {
+ 0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
+ 1L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
+ else -> {
+ val formattedDate = formatDateShort(mutedUntilTimestamp)
+ Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+ }
+
private fun onCopyUrlClick() {
val url = topicUrl(subscriptionBaseUrl, subscriptionTopic)
Log.d(TAG, "Copying topic URL $url to clipboard ")
@@ -316,6 +384,23 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
}
}
+ private fun showHideNotificationMenuItems(mutedUntilTimestamp: Long) {
+ subscriptionMutedUntil = mutedUntilTimestamp
+ runOnUiThread {
+ val notificationsEnabledItem = menu.findItem(R.id.detail_menu_notifications_enabled)
+ val notificationsDisabledUntilItem = menu.findItem(R.id.detail_menu_notifications_disabled_until)
+ val notificationsDisabledForeverItem = menu.findItem(R.id.detail_menu_notifications_disabled_forever)
+ notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L
+ notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L
+ notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L
+ if (subscriptionMutedUntil > 1L) {
+ val formattedDate = formatDateShort(subscriptionMutedUntil)
+ notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate)
+ }
+
+ }
+ }
+
private fun onDeleteClick() {
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
@@ -329,6 +414,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscriptionBaseUrl)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscriptionInstant)
+ .putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscriptionMutedUntil)
setResult(RESULT_OK, result)
finish()
@@ -378,7 +464,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
this.actionMode = mode
if (mode != null) {
- mode.menuInflater.inflate(R.menu.detail_action_mode_menu, menu)
+ mode.menuInflater.inflate(R.menu.menu_detail_action_mode, menu)
mode.title = "1" // One item selected
}
return true
@@ -478,6 +564,5 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
companion object {
const val TAG = "NtfyDetailActivity"
- const val CANCEL_NOTIFICATION_DELAY_MILLIS = 20_000L
}
}
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 fa5ba80..e3a5534 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt
@@ -18,7 +18,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
/* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder {
val view = LayoutInflater.from(parent.context)
- .inflate(R.layout.detail_fragment_item, parent, false)
+ .inflate(R.layout.fragment_detail_item, parent, false)
return DetailViewHolder(view, selected, onClick, onLongClick)
}
@@ -41,11 +41,13 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
private var notification: Notification? = null
private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text)
private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text)
+ private val newImageView: View = itemView.findViewById(R.id.detail_item_new)
fun bind(notification: Notification) {
this.notification = notification
dateView.text = Date(notification.timestamp * 1000).toString()
messageView.text = notification.message
+ newImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE
itemView.setOnClickListener { onClick(notification) }
itemView.setOnLongClickListener { onLongClick(notification); true }
if (selected.contains(notification.id)) {
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 3c294ad..36c97f9 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -3,7 +3,6 @@ package io.heckel.ntfy.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.AlertDialog
-import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -29,23 +28,28 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.random.Random
-
-class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.SubscribeListener {
+class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.SubscribeListener, NotificationFragment.NotificationSettingsListener {
private val viewModel by viewModels {
SubscriptionsViewModelFactory((application as Application).repository)
}
private val repository by lazy { (application as Application).repository }
private val api = ApiService()
+ // UI elements
+ private lateinit var menu: Menu
private lateinit var mainList: RecyclerView
private lateinit var mainListContainer: SwipeRefreshLayout
private lateinit var adapter: MainAdapter
private lateinit var fab: View
+
+ // Other stuff
private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent
@@ -54,7 +58,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(R.layout.main_activity)
+ setContentView(R.layout.activity_main)
Log.d(TAG, "Create $this")
@@ -110,15 +114,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
private fun startPeriodicWorker() {
- val sharedPrefs = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
- val workPolicy = if (sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) {
+ val pollWorkerVersion = repository.getPollWorkerVersion()
+ val workPolicy = if (pollWorkerVersion == PollWorker.VERSION) {
Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy")
ExistingPeriodicWorkPolicy.KEEP
} else {
Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy")
- sharedPrefs.edit()
- .putInt(SHARED_PREFS_POLL_WORKER_VERSION, PollWorker.VERSION)
- .apply()
+ repository.setPollWorkerVersion(PollWorker.VERSION)
ExistingPeriodicWorkPolicy.REPLACE
}
val constraints = Constraints.Builder()
@@ -133,12 +135,76 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.main_action_bar_menu, menu)
+ menuInflater.inflate(R.menu.menu_main_action_bar, menu)
+ this.menu = menu
+ showHideNotificationMenuItems()
+ startNotificationMutedChecker() // This is done here, because then we know that we've initialized the menu
return true
}
+ private fun startNotificationMutedChecker() {
+ lifecycleScope.launch(Dispatchers.IO) {
+ delay(1000) // Just to be sure we've initialized all the things, we wait a bit ...
+ while (isActive) {
+ Log.d(DetailActivity.TAG, "Checking global and subscription-specific 'muted until' timestamp")
+
+ // Check global
+ val changed = repository.checkGlobalMutedUntil()
+ if (changed) {
+ Log.d(TAG, "Global muted until timestamp expired; updating prefs")
+ showHideNotificationMenuItems()
+ }
+
+ // Check subscriptions
+ var rerenderList = false
+ repository.getSubscriptions().forEach { subscription ->
+ val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil
+ if (mutedUntilExpired) {
+ Log.d(TAG, "Subscription ${subscription.id}: Muted until timestamp expired, updating subscription")
+ val newSubscription = subscription.copy(mutedUntil = 0L)
+ repository.updateSubscription(newSubscription)
+ rerenderList = true
+ }
+ }
+ if (rerenderList) {
+ redrawList()
+ }
+
+ delay(60_000)
+ }
+ }
+ }
+
+ private fun showHideNotificationMenuItems() {
+ val mutedUntilSeconds = repository.getGlobalMutedUntil()
+ runOnUiThread {
+ val notificationsEnabledItem = menu.findItem(R.id.main_menu_notifications_enabled)
+ val notificationsDisabledUntilItem = menu.findItem(R.id.main_menu_notifications_disabled_until)
+ val notificationsDisabledForeverItem = menu.findItem(R.id.main_menu_notifications_disabled_forever)
+ notificationsEnabledItem?.isVisible = mutedUntilSeconds == 0L
+ notificationsDisabledForeverItem?.isVisible = mutedUntilSeconds == 1L
+ notificationsDisabledUntilItem?.isVisible = mutedUntilSeconds > 1L
+ if (mutedUntilSeconds > 1L) {
+ val formattedDate = formatDateShort(mutedUntilSeconds)
+ notificationsDisabledUntilItem?.title = getString(R.string.main_menu_notifications_disabled_until, formattedDate)
+ }
+ }
+ }
+
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
+ R.id.main_menu_notifications_enabled -> {
+ onNotificationSettingsClick(enable = false)
+ true
+ }
+ R.id.main_menu_notifications_disabled_forever -> {
+ onNotificationSettingsClick(enable = true)
+ true
+ }
+ R.id.main_menu_notifications_disabled_until -> {
+ onNotificationSettingsClick(enable = true)
+ true
+ }
R.id.main_menu_source -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
true
@@ -151,6 +217,32 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
}
+ private fun onNotificationSettingsClick(enable: Boolean) {
+ if (!enable) {
+ Log.d(TAG, "Showing global notification settings dialog")
+ val notificationFragment = NotificationFragment()
+ notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
+ } else {
+ Log.d(TAG, "Re-enabling global notifications")
+ onNotificationMutedUntilChanged(0L)
+ }
+ }
+
+ override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
+ repository.setGlobalMutedUntil(mutedUntilTimestamp)
+ showHideNotificationMenuItems()
+ runOnUiThread {
+ when (mutedUntilTimestamp) {
+ 0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
+ 1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
+ else -> {
+ val formattedDate = formatDateShort(mutedUntilTimestamp)
+ Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+
private fun onSubscribeButtonClick() {
val newFragment = AddFragment()
newFragment.show(supportFragmentManager, AddFragment.TAG)
@@ -165,6 +257,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
baseUrl = baseUrl,
topic = topic,
instant = instant,
+ mutedUntil = 0,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
@@ -222,10 +315,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
- val notificationWithId = notification.copy(notificationId = Random.nextInt())
- repository.addNotification(notificationWithId)
- notifier?.send(subscription, notificationWithId)
newNotificationsCount++
+ val notificationWithId = notification.copy(notificationId = Random.nextInt())
+ val shouldNotify = repository.addNotification(notificationWithId)
+ if (shouldNotify) {
+ notifier?.send(subscription, notificationWithId)
+ }
}
}
val toastMessage = if (newNotificationsCount == 0) {
@@ -256,6 +351,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
+ intent.putExtra(EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION)
}
@@ -292,7 +388,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
this.actionMode = mode
if (mode != null) {
- mode.menuInflater.inflate(R.menu.main_action_mode_menu, menu)
+ mode.menuInflater.inflate(R.menu.menu_main_action_mode, menu)
mode.title = "1" // One item selected
}
return true
@@ -386,7 +482,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
private fun redrawList() {
- mainList.adapter = adapter // Oh, what a hack ...
+ runOnUiThread {
+ mainList.adapter = adapter // Oh, what a hack ...
+ }
}
companion object {
@@ -395,9 +493,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant"
+ const val EXTRA_SUBSCRIPTION_MUTED_UNTIL = "subscriptionMutedUntil"
const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1
const val ANIMATION_DURATION = 80L
- const val SHARED_PREFS_ID = "MainPreferences"
- const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
index 2086997..9cf7e24 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
@@ -23,7 +23,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
/* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context)
- .inflate(R.layout.main_fragment_item, parent, false)
+ .inflate(R.layout.fragment_main_item, parent, false)
return SubscriptionViewHolder(view, selected, onClick, onLongClick)
}
@@ -49,6 +49,8 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
+ private val notificationDisabledUntilImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_until_image)
+ private val notificationDisabledForeverImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_forever_image)
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
private val newItemsView: TextView = itemView.findViewById(R.id.main_item_new)
@@ -78,11 +80,9 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage
dateView.text = dateText
- if (subscription.instant) {
- instantImageView.visibility = View.VISIBLE
- } else {
- instantImageView.visibility = View.GONE
- }
+ notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
+ notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
+ instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
if (subscription.newCount > 0) {
newItemsView.visibility = View.VISIBLE
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
diff --git a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
new file mode 100644
index 0000000..a09dd8f
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
@@ -0,0 +1,96 @@
+package io.heckel.ntfy.ui
+
+import android.app.AlertDialog
+import android.app.Dialog
+import android.content.Context
+import android.os.Bundle
+import android.widget.RadioButton
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.lifecycleScope
+import io.heckel.ntfy.R
+import io.heckel.ntfy.data.Database
+import io.heckel.ntfy.data.Repository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.util.*
+
+class NotificationFragment : DialogFragment() {
+ private lateinit var repository: Repository
+ private lateinit var settingsListener: NotificationSettingsListener
+ private lateinit var muteFor30minButton: RadioButton
+ private lateinit var muteFor1hButton: RadioButton
+ private lateinit var muteFor2hButton: RadioButton
+ private lateinit var muteFor8hButton: RadioButton
+ private lateinit var muteUntilTomorrowButton: RadioButton
+ private lateinit var muteForeverButton: RadioButton
+
+ interface NotificationSettingsListener {
+ fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long)
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ settingsListener = activity as NotificationSettingsListener
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ if (activity == null) {
+ throw IllegalStateException("Activity cannot be null")
+ }
+
+ // Dependencies
+ val database = Database.getInstance(requireActivity().applicationContext)
+ val sharedPrefs = requireActivity().getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
+ repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
+
+ // Build root view
+ val view = requireActivity().layoutInflater.inflate(R.layout.fragment_notification_dialog, null)
+
+ muteFor30minButton = view.findViewById(R.id.notification_dialog_30min)
+ muteFor30minButton.setOnClickListener { onClickMinutes(30) }
+
+ muteFor1hButton = view.findViewById(R.id.notification_dialog_1h)
+ muteFor1hButton.setOnClickListener { onClickMinutes(60) }
+
+ muteFor2hButton = view.findViewById(R.id.notification_dialog_2h)
+ muteFor2hButton.setOnClickListener { onClickMinutes(2 * 60) }
+
+ muteFor8hButton = view.findViewById(R.id.notification_dialog_8h)
+ muteFor8hButton.setOnClickListener{ onClickMinutes(8 * 60) }
+
+ muteUntilTomorrowButton = view.findViewById(R.id.notification_dialog_tomorrow)
+ muteUntilTomorrowButton.setOnClickListener {
+ val date = Calendar.getInstance()
+ date.add(Calendar.DAY_OF_MONTH, 1)
+ date.set(Calendar.HOUR_OF_DAY, 8)
+ date.set(Calendar.MINUTE, 30)
+ date.set(Calendar.SECOND, 0)
+ date.set(Calendar.MILLISECOND, 0)
+ onClick(date.timeInMillis/1000)
+ }
+
+ muteForeverButton = view.findViewById(R.id.notification_dialog_forever)
+ muteForeverButton.setOnClickListener{ onClick(1) }
+
+ return AlertDialog.Builder(activity)
+ .setView(view)
+ .create()
+ }
+
+ private fun onClickMinutes(minutes: Int) {
+ onClick(System.currentTimeMillis()/1000 + minutes * 60)
+ }
+
+ private fun onClick(mutedUntilTimestamp: Long) {
+ lifecycleScope.launch(Dispatchers.Main) {
+ delay(150) // Another hack: Let the animation finish before dismissing the window
+ settingsListener.onNotificationMutedUntilChanged(mutedUntilTimestamp)
+ dismiss()
+ }
+ }
+
+ companion object {
+ const val TAG = "NtfyNotificationFragment"
+ }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/Util.kt b/app/src/main/java/io/heckel/ntfy/ui/Util.kt
index d37213e..0834354 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/Util.kt
@@ -3,6 +3,8 @@ package io.heckel.ntfy.ui
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.view.Window
+import java.text.DateFormat
+import java.util.*
// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
@@ -13,3 +15,8 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
}
statusBarColorAnimation.start()
}
+
+fun formatDateShort(timestampSecs: Long): String {
+ val mutedUntilDate = Date(timestampSecs*1000)
+ return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(mutedUntilDate)
+}
diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
index 2edb2f2..7ebbbf5 100644
--- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
+++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt
@@ -14,14 +14,16 @@ import kotlinx.coroutines.withContext
import kotlin.random.Random
class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
- // Every time the worker is changed, the periodic work has to be REPLACEd.
- // This is facilitated in the MainActivity using the VERSION below.
+ // IMPORTANT WARNING:
+ // Every time the worker is changed, the periodic work has to be REPLACEd.
+ // This is facilitated in the MainActivity using the VERSION below.
override suspend fun doWork(): Result {
return withContext(Dispatchers.IO) {
Log.d(TAG, "Polling for new notifications")
val database = Database.getInstance(applicationContext)
- val repository = Repository.getInstance(database.subscriptionDao(), database.notificationDao())
+ val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
+ val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
val notifier = NotificationService(applicationContext)
val api = ApiService()
@@ -32,10 +34,8 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
.onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) }
newNotifications.forEach { notification ->
- val added = repository.addNotification(notification)
- val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
-
- if (added && !detailViewOpen) {
+ val shouldNotify = repository.addNotification(notification)
+ if (shouldNotify) {
notifier.send(subscription, notification)
}
}
diff --git a/app/src/main/res/drawable/ic_bolt_black_24dp.xml b/app/src/main/res/drawable/ic_bolt_gray_24dp.xml
similarity index 91%
rename from app/src/main/res/drawable/ic_bolt_black_24dp.xml
rename to app/src/main/res/drawable/ic_bolt_gray_24dp.xml
index 722999e..d0d3ded 100644
--- a/app/src/main/res/drawable/ic_bolt_black_24dp.xml
+++ b/app/src/main/res/drawable/ic_bolt_gray_24dp.xml
@@ -5,5 +5,5 @@
android:viewportHeight="24">
+ android:fillColor="#555555"/>
diff --git a/app/src/main/res/drawable/ic_notifications_off_gray_outline_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_gray_outline_24dp.xml
new file mode 100644
index 0000000..a1ff961
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notifications_off_gray_outline_24dp.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notifications_off_time_gray_outline_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_time_gray_outline_24dp.xml
new file mode 100644
index 0000000..83a200d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notifications_off_time_gray_outline_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml
new file mode 100644
index 0000000..f3c0ad0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notifications_off_white_outline_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_white_outline_24dp.xml
new file mode 100644
index 0000000..cebc1e8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notifications_off_white_outline_24dp.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notifications_white_24dp.xml b/app/src/main/res/drawable/ic_notifications_white_24dp.xml
new file mode 100644
index 0000000..6410bf0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notifications_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout/detail_activity.xml b/app/src/main/res/layout/activity_detail.xml
similarity index 100%
rename from app/src/main/res/layout/detail_activity.xml
rename to app/src/main/res/layout/activity_detail.xml
diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/activity_main.xml
similarity index 100%
rename from app/src/main/res/layout/main_activity.xml
rename to app/src/main/res/layout/activity_main.xml
diff --git a/app/src/main/res/layout/detail_fragment_item.xml b/app/src/main/res/layout/detail_fragment_item.xml
deleted file mode 100644
index baf8bfc..0000000
--- a/app/src/main/res/layout/detail_fragment_item.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/add_dialog_fragment.xml b/app/src/main/res/layout/fragment_add_dialog.xml
similarity index 99%
rename from app/src/main/res/layout/add_dialog_fragment.xml
rename to app/src/main/res/layout/fragment_add_dialog.xml
index 9b671cf..dfde2be 100644
--- a/app/src/main/res/layout/add_dialog_fragment.xml
+++ b/app/src/main/res/layout/fragment_add_dialog.xml
@@ -54,7 +54,7 @@
android:layout_marginTop="-8dp" android:layout_marginBottom="-5dp" android:layout_marginStart="-3dp"/>
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/main_fragment_item.xml b/app/src/main/res/layout/fragment_main_item.xml
similarity index 78%
rename from app/src/main/res/layout/main_fragment_item.xml
rename to app/src/main/res/layout/fragment_main_item.xml
index c254ada..9f3528f 100644
--- a/app/src/main/res/layout/main_fragment_item.xml
+++ b/app/src/main/res/layout/fragment_main_item.xml
@@ -32,9 +32,23 @@
android:layout_marginBottom="10dp"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/main_action_bar_menu.xml b/app/src/main/res/menu/main_action_bar_menu.xml
deleted file mode 100644
index a6e655f..0000000
--- a/app/src/main/res/menu/main_action_bar_menu.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/app/src/main/res/menu/detail_action_bar_menu.xml b/app/src/main/res/menu/menu_detail_action_bar.xml
similarity index 58%
rename from app/src/main/res/menu/detail_action_bar_menu.xml
rename to app/src/main/res/menu/menu_detail_action_bar.xml
index 959e77b..0a40d57 100644
--- a/app/src/main/res/menu/detail_action_bar_menu.xml
+++ b/app/src/main/res/menu/menu_detail_action_bar.xml
@@ -1,4 +1,10 @@
diff --git a/app/src/main/res/menu/main_action_mode_menu.xml b/app/src/main/res/menu/menu_main_action_mode.xml
similarity index 100%
rename from app/src/main/res/menu/main_action_mode_menu.xml
rename to app/src/main/res/menu/menu_main_action_mode.xml
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0bc89ed..a5549d2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,13 +21,19 @@
Subscribed topics
+ Notifications enabled
+ Notifications disabled
+ Notifications disabled until %1$s
Report a bug
https://heckel.io/ntfy-android
Visit ntfy.sh
Unsubscribe
- Do you really want to unsubscribe from selected topic(s) and permanently delete all the messages you received?
+
+ Do you really want to unsubscribe from selected topic(s) and
+ permanently delete all the messages you received?
+
Permanently delete
Cancel
@@ -38,12 +44,19 @@
Yesterday
Add subscription
It looks like you don\'t have any subscriptions yet.
- Click the button below to create or subscribe to a topic. After that, you can send messages via PUT or POST and you\'ll receive notifications on your phone.
- For more detailed instructions, check out the ntfy.sh website and documentation.
+
+ Click the button below to create or subscribe to a topic. After that, you can send
+ messages via PUT or POST and you\'ll receive notifications on your phone.
+
+ For more detailed instructions, check out the ntfy.sh website and documentation.
+
Subscribe to topic
- Topics are not password-protected, so choose a name that\'s not easy to guess. Once subscribed, you can PUT/POST to receive notifications on your phone.
+
+ Topics are not password-protected, so choose a name that\'s not easy to
+ guess. Once subscribed, you can PUT/POST to receive notifications on your phone.
+
Topic name, e.g. phils_alerts
Use another server
@@ -63,7 +76,9 @@
To send notifications to this topic, simply PUT or POST to the topic URL.
$ curl -d \"Hi\" %1$s ]]>
For more detailed instructions, check out the ntfy.sh website and documentation.
- Do you really want to unsubscribe from this topic and delete all of the messages you received?
+ Do you really want to unsubscribe from this topic and delete all of the
+ messages you received?
+
Permanently delete
Cancel
This is a test notification from the Ntfy Android app. It was sent at %1$s.
@@ -74,17 +89,35 @@
Instant delivery cannot be disabled for subscriptions from other servers
- Send test notification
- Copy topic address
+ Notifications enabled
+ Notifications disabled
+ Notifications disabled until %1$s
Enable instant delivery
Disable instant delivery
+ Send test notification
+ Copy topic address
Instant delivery enabled
Unsubscribe
Copy
Delete
- Do you really want to permanently delete the selected message(s)?
+ Do you really want to permanently delete the selected message(s)?
+
Permanently delete
Cancel
+
+
+ Pause notifications
+ Cancel
+ Save
+ Notifications re-enabled
+ Notifications are now paused
+ Notifications are now paused until %1$s
+ 30 minutes
+ 1 hour
+ 2 hours
+ 8 hours
+ Until tomorrow
+ Forever
diff --git a/assets/notifications_black_outline_24dp.svg b/assets/notifications_black_outline_24dp.svg
new file mode 100644
index 0000000..45a9e49
--- /dev/null
+++ b/assets/notifications_black_outline_24dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/notifications_off_black_outline_24dp.svg b/assets/notifications_off_black_outline_24dp.svg
new file mode 100644
index 0000000..0d4f93d
--- /dev/null
+++ b/assets/notifications_off_black_outline_24dp.svg
@@ -0,0 +1,46 @@
+
+
diff --git a/assets/notifications_off_time_black_outline_24dp.svg b/assets/notifications_off_time_black_outline_24dp.svg
new file mode 100644
index 0000000..344be2b
--- /dev/null
+++ b/assets/notifications_off_time_black_outline_24dp.svg
@@ -0,0 +1,60 @@
+
+
diff --git a/assets/schedule_black_24dp.svg b/assets/schedule_black_24dp.svg
new file mode 100644
index 0000000..0d63267
--- /dev/null
+++ b/assets/schedule_black_24dp.svg
@@ -0,0 +1 @@
+
\ No newline at end of file