From 8db05d7c8838d2eca9a2e7ea9f2878d562b931b1 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 22 Nov 2021 15:45:43 -0500 Subject: [PATCH] Muted until feature --- app/build.gradle | 1 - app/src/main/AndroidManifest.xml | 18 ++- .../java/io/heckel/ntfy/app/Application.kt | 6 +- .../main/java/io/heckel/ntfy/data/Database.kt | 9 +- .../java/io/heckel/ntfy/data/Repository.kt | 70 +++++++++- app/src/main/java/io/heckel/ntfy/data/Util.kt | 1 + .../io/heckel/ntfy/msg/FirebaseService.kt | 5 +- .../io/heckel/ntfy/msg/NotificationService.kt | 1 + .../io/heckel/ntfy/msg/SubscriberService.kt | 6 +- .../java/io/heckel/ntfy/ui/AddFragment.kt | 8 +- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 122 +++++++++++----- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 4 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 130 +++++++++++++++--- .../java/io/heckel/ntfy/ui/MainAdapter.kt | 12 +- .../io/heckel/ntfy/ui/NotificationFragment.kt | 79 +++++++---- .../io/heckel/ntfy/ui/SettingsFragment.kt | 5 - .../ntfy/ui/SubscriptionSettingsActivity.kt | 25 ---- app/src/main/java/io/heckel/ntfy/ui/Util.kt | 7 + .../java/io/heckel/ntfy/work/PollWorker.kt | 14 +- ...t_black_24dp.xml => ic_bolt_gray_24dp.xml} | 2 +- ...ic_notifications_off_gray_outline_24dp.xml | 12 ++ ...tifications_off_time_gray_outline_24dp.xml | 15 ++ ...ifications_off_time_white_outline_24dp.xml | 15 ++ ...c_notifications_off_white_outline_24dp.xml | 12 ++ ...etail_activity.xml => activity_detail.xml} | 0 .../{main_activity.xml => activity_main.xml} | 0 .../layout/activity_subscription_settings.xml | 18 --- .../main/res/layout/detail_fragment_item.xml | 28 ---- ...g_fragment.xml => fragment_add_dialog.xml} | 2 +- .../main/res/layout/fragment_detail_item.xml | 39 ++++++ ...agment_item.xml => fragment_main_item.xml} | 18 ++- .../layout/fragment_notification_dialog.xml | 72 ++++++++++ .../layout/notification_dialog_fragment.xml | 66 --------- .../main/res/menu/main_action_bar_menu.xml | 4 - ...ar_menu.xml => menu_detail_action_bar.xml} | 6 +- ...e_menu.xml => menu_detail_action_mode.xml} | 0 .../main/res/menu/menu_main_action_bar.xml | 10 ++ ...ode_menu.xml => menu_main_action_mode.xml} | 0 app/src/main/res/values/arrays.xml | 16 --- app/src/main/res/values/strings.xml | 57 ++++---- app/src/main/res/xml/root_preferences.xml | 23 ---- assets/notifications_black_24dp.svg | 1 + assets/notifications_black_outline_24dp.svg | 1 + .../notifications_off_black_outline_24dp.svg | 46 +++++++ ...ifications_off_time_black_outline_24dp.svg | 60 ++++++++ assets/schedule_black_24dp.svg | 1 + 46 files changed, 702 insertions(+), 345 deletions(-) delete mode 100644 app/src/main/java/io/heckel/ntfy/ui/SettingsFragment.kt delete mode 100644 app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt rename app/src/main/res/drawable/{ic_bolt_black_24dp.xml => ic_bolt_gray_24dp.xml} (91%) create mode 100644 app/src/main/res/drawable/ic_notifications_off_gray_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_notifications_off_time_gray_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_notifications_off_white_outline_24dp.xml rename app/src/main/res/layout/{detail_activity.xml => activity_detail.xml} (100%) rename app/src/main/res/layout/{main_activity.xml => activity_main.xml} (100%) delete mode 100644 app/src/main/res/layout/activity_subscription_settings.xml delete mode 100644 app/src/main/res/layout/detail_fragment_item.xml rename app/src/main/res/layout/{add_dialog_fragment.xml => fragment_add_dialog.xml} (99%) create mode 100644 app/src/main/res/layout/fragment_detail_item.xml rename app/src/main/res/layout/{main_fragment_item.xml => fragment_main_item.xml} (78%) create mode 100644 app/src/main/res/layout/fragment_notification_dialog.xml delete mode 100644 app/src/main/res/layout/notification_dialog_fragment.xml delete mode 100644 app/src/main/res/menu/main_action_bar_menu.xml rename app/src/main/res/menu/{detail_action_bar_menu.xml => menu_detail_action_bar.xml} (63%) rename app/src/main/res/menu/{detail_action_mode_menu.xml => menu_detail_action_mode.xml} (100%) create mode 100644 app/src/main/res/menu/menu_main_action_bar.xml rename app/src/main/res/menu/{main_action_mode_menu.xml => menu_main_action_mode.xml} (100%) delete mode 100644 app/src/main/res/values/arrays.xml delete mode 100644 app/src/main/res/xml/root_preferences.xml create mode 100644 assets/notifications_black_24dp.svg create mode 100644 assets/notifications_black_outline_24dp.svg create mode 100644 assets/notifications_off_black_outline_24dp.svg create mode 100644 assets/notifications_off_time_black_outline_24dp.svg create mode 100644 assets/schedule_black_24dp.svg diff --git a/app/build.gradle b/app/build.gradle index 026d482..06a6598 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,7 +58,6 @@ dependencies { // WorkManager implementation "androidx.work:work-runtime-ktx:2.6.0" - implementation 'androidx.preference:preference:1.1.1' // Room (SQLite) def roomVersion = "2.3.0" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc21bac..a25955c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,8 +21,6 @@ android:supportsRtl="true" android:theme="@style/AppTheme" android:usesCleartextTraffic="true"> - - - + + + - - + + + + + + - + + + 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 c7af4b5..5befac6 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -13,9 +13,7 @@ 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, - //val notificationSchedule: String, - //val notificationSound: String, + @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 @@ -161,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 @@ -172,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 72915f3..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 } @@ -162,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 a62f1f0..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,11 +27,9 @@ 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, NotificationFragment.NotificationSettingsListener { private val viewModel by viewModels { @@ -47,6 +45,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra 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, NotificationFra 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, NotificationFra 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,43 +152,76 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra 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_notification -> { - onNotificationSettingsClick() + 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 -> { @@ -232,36 +265,35 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra } } - private fun onNotificationSettingsClick() { - Log.d(TAG, "Showing notification settings dialog for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") - val intent = Intent(this, SubscriptionSettingsActivity::class.java) - startActivityForResult(intent, /*XXXXXX*/MainActivity.REQUEST_CODE_DELETE_SUBSCRIPTION) -/* - val notificationFragment = NotificationFragment() - notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)*/ + 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 onNotificationSettingsChanged(mutedUntil: Long) { + override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { lifecycleScope.launch(Dispatchers.IO) { val subscription = repository.getSubscription(subscriptionId) - val newSubscription = subscription?.copy(mutedUntil = mutedUntil) + val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp) newSubscription?.let { repository.updateSubscription(newSubscription) } + subscriptionMutedUntil = mutedUntilTimestamp + showHideNotificationMenuItems(mutedUntilTimestamp) runOnUiThread { - when (mutedUntil) { - 0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_SHORT).show() - 1L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_SHORT).show() + 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 mutedUntilDate = Date(mutedUntil).toString() - Toast.makeText( - this@DetailActivity, - getString(R.string.notification_dialog_muted_until_toast_message, mutedUntilDate), - Toast.LENGTH_SHORT - ).show() + 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() { @@ -352,6 +384,23 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra } } + 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)}") @@ -365,6 +414,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra .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() @@ -414,7 +464,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra 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 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 2c89315..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) @@ -223,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) { @@ -257,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) } @@ -293,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 @@ -387,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 { @@ -396,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 index f59e93e..a09dd8f 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt @@ -4,27 +4,29 @@ import android.app.AlertDialog import android.app.Dialog 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 +import android.widget.RadioButton import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope -import com.google.android.material.textfield.TextInputEditText 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 onNotificationSettingsChanged(mutedUntil: Long) + fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) } override fun onAttach(context: Context) { @@ -38,34 +40,55 @@ class NotificationFragment : 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.notification_dialog_fragment, null) - // topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText + val view = requireActivity().layoutInflater.inflate(R.layout.fragment_notification_dialog, null) - // Build dialog - val alert = AlertDialog.Builder(activity) - .setView(view) - .setPositiveButton(R.string.notification_dialog_save) { _, _ -> - /// - settingsListener.onNotificationSettingsChanged(0L) - } - .setNegativeButton(R.string.notification_dialog_cancel) { _, _ -> - dialog?.cancel() - } - .create() + muteFor30minButton = view.findViewById(R.id.notification_dialog_30min) + muteFor30minButton.setOnClickListener { onClickMinutes(30) } - // Add logic to disable "Subscribe" button on invalid input - alert.setOnShowListener { - val dialog = it as AlertDialog - /// + 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) } - return alert + 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/SettingsFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsFragment.kt deleted file mode 100644 index 01204fe..0000000 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsFragment.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.heckel.ntfy.ui - -import android.os.Bundle -import androidx.preference.PreferenceFragmentCompat -import io.heckel.ntfy.R diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt deleted file mode 100644 index 1f80505..0000000 --- a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.heckel.ntfy.ui - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle -import androidx.preference.PreferenceFragmentCompat -import io.heckel.ntfy.R - -class SubscriptionSettingsActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_subscription_settings) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportFragmentManager - .beginTransaction() - .replace(R.id.subscription_settings_content, SubscriptionSettingsFragment()) - .commit() - } - - class SubscriptionSettingsFragment : PreferenceFragmentCompat() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.root_preferences, rootKey) - } - } -} 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/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/activity_subscription_settings.xml b/app/src/main/res/layout/activity_subscription_settings.xml deleted file mode 100644 index 11b43d5..0000000 --- a/app/src/main/res/layout/activity_subscription_settings.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - 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/layout/notification_dialog_fragment.xml b/app/src/main/res/layout/notification_dialog_fragment.xml deleted file mode 100644 index d133cea..0000000 --- a/app/src/main/res/layout/notification_dialog_fragment.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - 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 63% 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 081be9a..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,6 +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/arrays.xml b/app/src/main/res/values/arrays.xml deleted file mode 100644 index 8d55295..0000000 --- a/app/src/main/res/values/arrays.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - Forever - 30 minutes - 1 hour - 2 hours - - - - forever - 30min - 1hr - 2h - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33692ef..a5549d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,8 +10,7 @@ You are subscribed to instant delivery topics You are subscribed to one instant delivery topic You are subscribed to two instant delivery topics - You are subscribed to three instant delivery topics - + You are subscribed to three instant delivery topics You are subscribed to four instant delivery topics You are subscribed to %1$d instant delivery topics @@ -22,13 +21,17 @@ 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 + + Do you really want to unsubscribe from selected topic(s) and permanently delete all the messages you received? Permanently delete @@ -41,7 +44,8 @@ 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 + + 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. @@ -49,7 +53,8 @@ Subscribe to topic - Topics are not password-protected, so choose a name that\'s not easy to + + 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 @@ -68,18 +73,15 @@ You haven\'t received any notifications for this topic yet. - To send notifications to this topic, simply PUT or POST to the topic URL. - + 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. - + 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? Permanently delete Cancel - This is a test notification from the Ntfy Android app. It was sent at %1$s. - + This is a test notification from the Ntfy Android app. It was sent at %1$s. Could not send test message: %1$s Copied to clipboard Instant delivery enabled @@ -87,7 +89,9 @@ Instant delivery cannot be disabled for subscriptions from other servers - Notification + Notifications enabled + Notifications disabled + Notifications disabled until %1$s Enable instant delivery Disable instant delivery Send test notification @@ -98,33 +102,22 @@ 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 - Notification settings + Pause notifications Cancel Save Notifications re-enabled Notifications are now paused - Notifications are now paused until %s - - - - Notifications - Pause notifications - Until … - Sync - - - Your signature - - - - Download incoming attachments - Automatically download attachments for incoming emails - Only download attachments when manually requested + Notifications are now paused until %1$s + 30 minutes + 1 hour + 2 hours + 8 hours + Until tomorrow + Forever diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml deleted file mode 100644 index 410829d..0000000 --- a/app/src/main/res/xml/root_preferences.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - diff --git a/assets/notifications_black_24dp.svg b/assets/notifications_black_24dp.svg new file mode 100644 index 0000000..7d76ce6 --- /dev/null +++ b/assets/notifications_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file 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