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