From 276d7731529e4ec7b002f0f59f8509bf03236020 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 14 Nov 2021 13:54:48 -0500 Subject: [PATCH] Enable/disable fast delivery; restart service on boot --- app/src/main/AndroidManifest.xml | 7 + .../main/java/io/heckel/ntfy/data/Database.kt | 3 + .../java/io/heckel/ntfy/data/Repository.kt | 16 +- .../java/io/heckel/ntfy/msg/ApiService.kt | 2 + .../io/heckel/ntfy/msg/FirebaseService.kt | 1 + .../java/io/heckel/ntfy/msg/StartReceiver.kt | 6 + .../io/heckel/ntfy/msg/SubscriberService.kt | 223 +++++++++++------- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 66 +++++- .../java/io/heckel/ntfy/ui/MainActivity.kt | 37 +-- .../java/io/heckel/ntfy/ui/MainAdapter.kt | 6 + .../java/io/heckel/ntfy/ui/MainViewModel.kt | 4 +- .../io/heckel/ntfy/ui/SubscriberManager.kt | 44 ++++ .../main/res/drawable/ic_bolt_black_24dp.xml | 9 + ..._delete_20.xml => ic_delete_gray_20dp.xml} | 0 .../main/res/layout/main_fragment_item.xml | 78 +++--- .../main/res/menu/detail_action_bar_menu.xml | 5 +- .../main/res/menu/detail_action_mode_menu.xml | 2 +- .../main/res/menu/main_action_mode_menu.xml | 2 +- app/src/main/res/values/strings.xml | 11 +- assets/bolt_black_24dp.svg | 1 + 20 files changed, 353 insertions(+), 170 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/msg/StartReceiver.kt create mode 100644 app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt create mode 100644 app/src/main/res/drawable/ic_bolt_black_24dp.xml rename app/src/main/res/drawable/{baseline_delete_20.xml => ic_delete_gray_20dp.xml} (100%) create mode 100644 assets/bolt_black_24dp.svg diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1146d7d..369e85d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,13 @@ + + + + + + + toSubscriptionList(list) } } - fun getSubscriptionIdsLiveData(): LiveData> { + fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData>> { return subscriptionDao .listFlow() .asLiveData() - .map { list -> list.map { it.id }.toSet() } + .map { list -> list.map { Pair(it.id, it.instant) }.toSet() } } fun getSubscriptions(): List { return toSubscriptionList(subscriptionDao.list()) } + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun getSubscription(subscriptionId: Long): Subscription? { + return toSubscription(subscriptionDao.get(subscriptionId)) + } + @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun getSubscription(baseUrl: String, topic: String): Subscription? { @@ -42,6 +48,12 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif subscriptionDao.add(subscription) } + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun updateSubscription(subscription: Subscription) { + subscriptionDao.update(subscription) + } + @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun removeSubscription(subscriptionId: Long) { diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 52700a8..fec1629 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -79,10 +79,12 @@ class ApiService { } } } catch (e: Exception) { + Log.e(TAG, "Connection to $url failed (1): ${e.message}", e) fail(e) } } override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Connection to $url failed (2): ${e.message}", e) fail(e) } }) 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 9017ba7..0088f3b 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt @@ -32,6 +32,7 @@ class FirebaseService : FirebaseMessagingService() { Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}") return } + Log.d(TAG, "Received notification: from=${remoteMessage.from}, data=${data}") CoroutineScope(job).launch { val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL! diff --git a/app/src/main/java/io/heckel/ntfy/msg/StartReceiver.kt b/app/src/main/java/io/heckel/ntfy/msg/StartReceiver.kt new file mode 100644 index 0000000..3f88e38 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/StartReceiver.kt @@ -0,0 +1,6 @@ +package io.heckel.ntfy.msg + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build 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 dd031ce..2f8022d 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt @@ -1,6 +1,7 @@ package io.heckel.ntfy.msg import android.app.* +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build @@ -33,10 +34,8 @@ class SubscriberService : Service() { private val calls = ConcurrentHashMap() // Subscription ID -> Cal private val api = ApiService() private val notifier = NotificationService(this) - - override fun onBind(intent: Intent): IBinder? { - return null // We don't provide binding, so return null - } + private var notificationManager: NotificationManager? = null + private var notification: Notification? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "onStartCommand executed with startId: $startId") @@ -56,29 +55,24 @@ class SubscriberService : Service() { override fun onCreate() { super.onCreate() - Log.d(TAG, "The service has been created".toUpperCase()) - val notification = createNotification() - startForeground(SERVICE_ID, notification) + Log.d(TAG, "Subscriber service has been created") + + val title = getString(R.string.channel_subscriber_notification_title) + val text = getString(R.string.channel_subscriber_notification_text) + notificationManager = createNotificationChannel() + notification = createNotification(title, text) + + startForeground(NOTIFICATION_SERVICE_ID, notification) } override fun onDestroy() { super.onDestroy() - Log.d(TAG, "The service has been destroyed".toUpperCase()) - } - - override fun onTaskRemoved(rootIntent: Intent) { - val restartServiceIntent = Intent(applicationContext, SubscriberService::class.java).also { - it.setPackage(packageName) - }; - val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT); - applicationContext.getSystemService(Context.ALARM_SERVICE); - val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager; - alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent); + Log.d(TAG, "Subscriber service has been destroyed") } private fun startService() { if (isServiceStarted) { - launchOrCancelJobs() + launchAndCancelJobs() return } Log.d(TAG, "Starting the foreground service task") @@ -89,7 +83,7 @@ class SubscriberService : Service() { acquire() } } - launchOrCancelJobs() + launchAndCancelJobs() } private fun stopService() { @@ -118,100 +112,116 @@ class SubscriberService : Service() { saveServiceState(this, ServiceState.STOPPED) } - private fun launchOrCancelJobs() = GlobalScope.launch(Dispatchers.IO) { - val subscriptions = repository.getSubscriptions().filter { s -> s.instant } - val subscriptionIds = subscriptions.map { it.id } - Log.d(TAG, "Starting/stopping jobs for current subscriptions") - Log.d(TAG, "- Subscriptions: $subscriptions") - Log.d(TAG, "- Jobs: $jobs") - Log.d(TAG, "- HTTP calls: $calls") - subscriptions.forEach { subscription -> - if (!jobs.containsKey(subscription.id)) { - Log.d(TAG, "Starting job for $subscription") - jobs[subscription.id] = launchJob(this, subscription) - } - } - jobs.keys().toList().forEach { subscriptionId -> - if (!subscriptionIds.contains(subscriptionId)) { - Log.d(TAG, "Cancelling job for $subscriptionId") - val job = jobs.remove(subscriptionId) - val call = calls.remove(subscriptionId) - job?.cancel() - call?.cancel() + private fun launchAndCancelJobs() = + GlobalScope.launch(Dispatchers.IO) { + val subscriptions = repository.getSubscriptions().filter { s -> s.instant } + val subscriptionIds = subscriptions.map { it.id } + Log.d(TAG, "Refreshing subscriptions") + Log.d(TAG, "- Subscriptions: $subscriptions") + Log.d(TAG, "- Jobs: $jobs") + Log.d(TAG, "- HTTP calls: $calls") + subscriptions.forEach { subscription -> + if (!jobs.containsKey(subscription.id)) { + jobs[subscription.id] = launchJob(this, subscription) + } + } + jobs.keys().toList().forEach { subscriptionId -> + if (!subscriptionIds.contains(subscriptionId)) { + cancelJob(subscriptionId) + } } } + + private fun cancelJob(subscriptionId: Long?) { + Log.d(TAG, "Cancelling job for $subscriptionId") + val job = jobs.remove(subscriptionId) + val call = calls.remove(subscriptionId) + job?.cancel() + call?.cancel() } - private fun launchJob(scope: CoroutineScope, subscription: Subscription): Job = scope.launch(Dispatchers.IO) { - val url = topicUrl(subscription.baseUrl, subscription.topic) - Log.d(TAG, "[$url] Starting connection job") - var since = 0L - var retryMillis = 0L - while (isActive && isServiceStarted) { - Log.d(TAG, "[$url] (Re-)starting subscription for $subscription") - val startTime = System.currentTimeMillis() + private fun launchJob(scope: CoroutineScope, subscription: Subscription): Job = + scope.launch(Dispatchers.IO) { + val url = topicUrl(subscription.baseUrl, subscription.topic) + Log.d(TAG, "[$url] Starting connection job") - try { - val failed = AtomicBoolean(false) + // Retry-loop: if the connection fails, we retry unless the job or service is cancelled/stopped + var since = 0L + var retryMillis = 0L + while (isActive && isServiceStarted) { + Log.d(TAG, "[$url] (Re-)starting subscription for $subscription") + val startTime = System.currentTimeMillis() val notify = { n: io.heckel.ntfy.data.Notification -> - Log.d(TAG, "[$url] Received new notification: $n") since = n.timestamp - scope.launch(Dispatchers.IO) { - val added = repository.addNotification(n) - if (added) { - Log.d(TAG, "[$url] Showing notification: $n") - notifier.send(subscription, n.message) - } + onNotificationReceived(scope, subscription, n) + } + val failed = AtomicBoolean(false) + val fail = { e: Exception -> failed.set(true) } + + // Call /json subscribe endpoint and loop until the call fails, is canceled, + // or the job or service are cancelled/stopped + try { + val call = api.subscribe(subscription.id, subscription.baseUrl, subscription.topic, since, notify, fail) + calls[subscription.id] = call + while (!failed.get() && !call.isCanceled() && isActive && isServiceStarted) { + Log.d(TAG, "[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=$isServiceStarted") + delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled } - Unit + } catch (e: Exception) { + Log.e(TAG, "[$url] Connection failed: ${e.message}", e) } - val fail = { e: Exception -> - Log.e(TAG, "[$url] Connection failed (1): ${e.message}", e) - failed.set(true) + + // If we're not cancelled yet, wait little before retrying (incremental back-off) + if (isActive && isServiceStarted) { + retryMillis = nextRetryMillis(retryMillis, startTime) + Log.d(TAG, "Connection failed, retrying connection in ${retryMillis/1000}s ...") + delay(retryMillis) } - val call = api.subscribe(subscription.id, subscription.baseUrl, subscription.topic, since, notify, fail) - calls[subscription.id] = call - while (!failed.get() && !call.isCanceled() && isActive && isServiceStarted) { - Log.d(TAG, "[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=$isServiceStarted") - delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled - } - } catch (e: Exception) { - Log.e(TAG, "[$url] Connection failed (2): ${e.message}", e) } - if (isActive && isServiceStarted) { - val connectionDurationMillis = System.currentTimeMillis() - startTime - if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) { - retryMillis = RETRY_STEP_MILLIS - } else if (retryMillis + RETRY_STEP_MILLIS >= RETRY_MAX_MILLIS) { - retryMillis = RETRY_MAX_MILLIS - } else { - retryMillis += RETRY_STEP_MILLIS - } - Log.d(TAG, "Connection failed, retrying connection in ${retryMillis/1000}s ...") - delay(retryMillis) + Log.d(TAG, "[$url] Connection job SHUT DOWN") + } + + private fun onNotificationReceived(scope: CoroutineScope, subscription: Subscription, n: io.heckel.ntfy.data.Notification) { + val url = topicUrl(subscription.baseUrl, subscription.topic) + Log.d(TAG, "[$url] Received notification: $n") + scope.launch(Dispatchers.IO) { + val added = repository.addNotification(n) + if (added) { + Log.d(TAG, "[$url] Showing notification: $n") + notifier.send(subscription, n.message) } } - Log.d(TAG, "[$url] Connection job SHUT DOWN") } - private fun createNotification(): Notification { + private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long { + val connectionDurationMillis = System.currentTimeMillis() - startTime + if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) { + return RETRY_STEP_MILLIS + } else if (retryMillis + RETRY_STEP_MILLIS >= RETRY_MAX_MILLIS) { + return RETRY_MAX_MILLIS + } + return retryMillis + RETRY_STEP_MILLIS + } + + private fun createNotificationChannel(): NotificationManager? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channelName = getString(R.string.channel_subscriber_service_name) // Show's up in UI - val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let { + val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let { it.setShowBadge(false) // Don't show long-press badge it } notificationManager.createNotificationChannel(channel) + return notificationManager } + return null + } + private fun createNotification(title: String, text: String): Notification { val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent -> PendingIntent.getActivity(this, 0, notificationIntent, 0) } - - val title = getString(R.string.channel_subscriber_notification_title) - val text = getString(R.string.channel_subscriber_notification_text) - return NotificationCompat.Builder(this, CHANNEL_ID) + return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification_icon) .setContentTitle(title) .setContentText(text) @@ -221,6 +231,39 @@ class SubscriberService : Service() { .build() } + override fun onBind(intent: Intent): IBinder? { + return null // We don't provide binding, so return null + } + + /* This re-schedules the task when the "Clear recent apps" button is pressed */ + override fun onTaskRemoved(rootIntent: Intent) { + val restartServiceIntent = Intent(applicationContext, SubscriberService::class.java).also { + it.setPackage(packageName) + }; + val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT); + applicationContext.getSystemService(Context.ALARM_SERVICE); + val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager; + alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent); + } + + /* This re-starts the service on reboot; see manifest */ + class StartReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) { + Intent(context, SubscriberService::class.java).also { + it.action = Actions.START.name + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.d(TAG, "Starting subscriber service in >=26 Mode from a BroadcastReceiver") + context.startForegroundService(it) + return + } + Log.d(TAG, "Starting subscriber service in < 26 Mode from a BroadcastReceiver") + context.startService(it) + } + } + } + } + enum class Actions { START, STOP @@ -234,14 +277,14 @@ class SubscriberService : Service() { companion object { private const val TAG = "NtfySubscriberService" private const val WAKE_LOCK_TAG = "SubscriberService:lock" - private const val CHANNEL_ID = "ntfy-subscriber" - private const val SERVICE_ID = 2586 + private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber" + private const val NOTIFICATION_SERVICE_ID = 2586 private const val SHARED_PREFS_ID = "SubscriberService" private const val SHARED_PREFS_SERVICE_STATE = "ServiceState" private const val CONNECTION_LOOP_DELAY_MILLIS = 30_000L private const val RETRY_STEP_MILLIS = 5_000L private const val RETRY_MAX_MILLIS = 60_000L - private const val RETRY_RESET_AFTER_MILLIS = 30_000L + private const val RETRY_RESET_AFTER_MILLIS = 60_000L // Must be larger than CONNECTION_LOOP_DELAY_MILLIS fun saveServiceState(context: Context, state: ServiceState) { val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) 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 0336ec1..8c1d550 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -19,12 +19,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView +import androidx.work.WorkManager import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Notification 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.launch import java.util.* @@ -37,16 +39,20 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { } private val repository by lazy { (application as Application).repository } private val api = ApiService() + private var subscriberManager: SubscriberManager? = null // Context-dependent // Which subscription are we looking at private var subscriptionId: Long = 0L // Set in onCreate() private var subscriptionBaseUrl: String = "" // Set in onCreate() private var subscriptionTopic: String = "" // Set in onCreate() - private var subscriptionInstant: Boolean = false // Set in onCreate() + private var subscriptionInstant: Boolean = false // Set in onCreate() & updated by options menu! + + // UI elements + private lateinit var adapter: DetailAdapter + private lateinit var mainList: RecyclerView + private lateinit var menu: Menu // Action mode stuff - private lateinit var mainList: RecyclerView - private lateinit var adapter: DetailAdapter private var actionMode: ActionMode? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -55,6 +61,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { Log.d(MainActivity.TAG, "Create $this") + // Dependencies that depend on Context + subscriberManager = SubscriberManager(this) + // Show 'Back' button supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -101,10 +110,17 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { } } } + + // React to changes in fast delivery setting + repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) { + subscriberManager?.refreshService(it) + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.detail_action_bar_menu, menu) + this.menu = menu + showHideInstantMenuItems(subscriptionInstant) return true } @@ -118,6 +134,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { onRefreshClick() true } + R.id.detail_menu_enable_instant -> { + onInstantEnableClick(enable = true) + true + } + R.id.detail_menu_disable_instant -> { + onInstantEnableClick(enable = false) + true + } R.id.detail_menu_copy_url -> { onCopyUrlClick() true @@ -183,6 +207,42 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { } } + private fun onInstantEnableClick(enable: Boolean) { + Log.d(TAG, "Toggling instant delivery setting for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") + + lifecycleScope.launch(Dispatchers.IO) { + val subscription = repository.getSubscription(subscriptionId) + val newSubscription = subscription?.copy(instant = enable) + newSubscription?.let { repository.updateSubscription(newSubscription) } + showHideInstantMenuItems(enable) + runOnUiThread { + if (enable) { + Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_enabled), Toast.LENGTH_SHORT) + .show() + } else { + Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_disabled), Toast.LENGTH_SHORT) + .show() + } + } + } + } + + private fun showHideInstantMenuItems(enable: Boolean) { + subscriptionInstant = enable + runOnUiThread { + val appBaseUrl = getString(R.string.app_base_url) + val enableInstantItem = menu.findItem(R.id.detail_menu_enable_instant) + val disableInstantItem = menu.findItem(R.id.detail_menu_disable_instant) + if (subscriptionBaseUrl == appBaseUrl) { + enableInstantItem?.isVisible = !subscriptionInstant + disableInstantItem?.isVisible = subscriptionInstant + } else { + enableInstantItem?.isVisible = false + disableInstantItem?.isVisible = false + } + } + } + private fun onDeleteClick() { Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") 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 6dcd9e4..fd8a21a 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -51,6 +51,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { private var actionMode: ActionMode? = null private var workManager: WorkManager? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent + private var subscriberManager: SubscriberManager? = null // Context-dependent override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -61,6 +62,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { // Dependencies that depend on Context workManager = WorkManager.getInstance(this) notifier = NotificationService(this) + subscriberManager = SubscriberManager(this) // Action bar title = getString(R.string.main_action_bar_title) @@ -93,13 +95,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { } } - viewModel.listIds().observe(this) { - maybeStartOrStopSubscriberService() + // React to changes in fast delivery setting + viewModel.listIdsWithInstantStatus().observe(this) { + subscriberManager?.refreshService(it) } // Background things startPeriodicWorker() - maybeStartOrStopSubscriberService() } private fun startPeriodicWorker() { @@ -239,35 +241,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { } } - private fun maybeStartOrStopSubscriberService() { - Log.d(TAG, "Triggering subscriber service refresh") - lifecycleScope.launch(Dispatchers.IO) { - val instantSubscriptions = repository.getSubscriptions().filter { s -> s.instant } - if (instantSubscriptions.isEmpty()) { - performActionOnSubscriberService(Actions.STOP) - } else { - performActionOnSubscriberService(Actions.START) - } - } - } - - private fun performActionOnSubscriberService(action: Actions) { - val serviceState = readServiceState(this) - if (serviceState == ServiceState.STOPPED && action == Actions.STOP) { - return - } - val intent = Intent(this, SubscriberService::class.java) - intent.action = action.name - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Log.d(TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)") - startForegroundService(intent) - return - } else { - Log.d(TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)") - startService(intent) - } - } - private fun startDetailView(subscription: Subscription) { Log.d(TAG, "Entering detail view for subscription $subscription") 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 863c3a6..315b4e7 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -48,6 +48,7 @@ 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 instantImageView: View = itemView.findViewById(R.id.main_item_instant_image) fun bind(subscription: Subscription) { this.subscription = subscription @@ -66,6 +67,11 @@ 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 + } itemView.setOnClickListener { onClick(subscription) } itemView.setOnLongClickListener { onLongClick(subscription); true } if (selected.contains(subscription.id)) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt index 234afca..f0b0275 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt @@ -14,8 +14,8 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { return repository.getSubscriptionsLiveData() } - fun listIds(): LiveData> { - return repository.getSubscriptionIdsLiveData() + fun listIdsWithInstantStatus(): LiveData>> { + return repository.getSubscriptionIdsWithInstantStatusLiveData() } fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt new file mode 100644 index 0000000..a348d8a --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/SubscriberManager.kt @@ -0,0 +1,44 @@ +package io.heckel.ntfy.ui + +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import io.heckel.ntfy.msg.SubscriberService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * This class only manages the SubscriberService, i.e. it starts or stops it. + * It's used in multiple activities. + */ +class SubscriberManager(private val activity: ComponentActivity) { + fun refreshService(subscriptionIdsWithInstantStatus: Set>) { + Log.d(MainActivity.TAG, "Triggering subscriber service refresh") + activity.lifecycleScope.launch(Dispatchers.IO) { + val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size + if (instantSubscriptions == 0) { + performActionOnSubscriberService(SubscriberService.Actions.STOP) + } else { + performActionOnSubscriberService(SubscriberService.Actions.START) + } + } + } + + private fun performActionOnSubscriberService(action: SubscriberService.Actions) { + val serviceState = SubscriberService.readServiceState(activity) + if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) { + return + } + val intent = Intent(activity, SubscriberService::class.java) + intent.action = action.name + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)") + activity.startForegroundService(intent) + } else { + Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)") + activity.startService(intent) + } + } +} diff --git a/app/src/main/res/drawable/ic_bolt_black_24dp.xml b/app/src/main/res/drawable/ic_bolt_black_24dp.xml new file mode 100644 index 0000000..722999e --- /dev/null +++ b/app/src/main/res/drawable/ic_bolt_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/baseline_delete_20.xml b/app/src/main/res/drawable/ic_delete_gray_20dp.xml similarity index 100% rename from app/src/main/res/drawable/baseline_delete_20.xml rename to app/src/main/res/drawable/ic_delete_gray_20dp.xml diff --git a/app/src/main/res/layout/main_fragment_item.xml b/app/src/main/res/layout/main_fragment_item.xml index 199da81..a5035bf 100644 --- a/app/src/main/res/layout/main_fragment_item.xml +++ b/app/src/main/res/layout/main_fragment_item.xml @@ -1,39 +1,47 @@ - + - - - - - + android:layout_width="37dp" + android:layout_height="37dp" app:srcCompat="@drawable/ic_sms_gray_48dp" + android:id="@+id/main_item_image" app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + android:layout_marginStart="15dp" android:layout_marginTop="12dp"/> - - + android:text="ntfy.sh/example" + android:layout_width="0dp" + android:layout_height="wrap_content" android:id="@+id/main_item_text" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@+id/main_item_status" + android:layout_marginStart="10dp" app:layout_constraintStart_toEndOf="@+id/main_item_image" + app:layout_constraintVertical_bias="0.0" android:textAppearance="@style/TextAppearance.AppCompat.Medium" + android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp" + app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/> + + + + diff --git a/app/src/main/res/menu/detail_action_bar_menu.xml b/app/src/main/res/menu/detail_action_bar_menu.xml index 9b169ef..3a0d49a 100644 --- a/app/src/main/res/menu/detail_action_bar_menu.xml +++ b/app/src/main/res/menu/detail_action_bar_menu.xml @@ -1,5 +1,8 @@ - + + + diff --git a/app/src/main/res/menu/detail_action_mode_menu.xml b/app/src/main/res/menu/detail_action_mode_menu.xml index 8c478a3..4f13ef2 100644 --- a/app/src/main/res/menu/detail_action_mode_menu.xml +++ b/app/src/main/res/menu/detail_action_mode_menu.xml @@ -1,4 +1,4 @@ + android:icon="@drawable/ic_delete_gray_20dp"/> diff --git a/app/src/main/res/menu/main_action_mode_menu.xml b/app/src/main/res/menu/main_action_mode_menu.xml index bf163a4..4017155 100644 --- a/app/src/main/res/menu/main_action_mode_menu.xml +++ b/app/src/main/res/menu/main_action_mode_menu.xml @@ -1,4 +1,4 @@ + android:icon="@drawable/ic_delete_gray_20dp"/> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 206f245..5b98321 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,7 +7,7 @@ Notifications Subscription Service Subscribed topics - Listening patiently for incoming notifications + Listening for incoming notifications %1$d notification(s) received @@ -44,9 +44,10 @@ You can subscribe to topics from your own server. Due to platform limitations, this option requires a foreground service and consumes more battery, but delivers notifications faster. - Instant delivery (even in doze mode) + Fast delivery - Requires foreground service and consumes more battery, but delivers notifications faster. + Enables instant notification delivery even in doze mode. Requires foreground service and consumes more + battery. Cancel Subscribe @@ -62,11 +63,15 @@ 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 + Fast delivery enabled + Fast delivery disabled Send test notification Copy topic address Force refresh + Enable fast delivery + Disable fast delivery Unsubscribe diff --git a/assets/bolt_black_24dp.svg b/assets/bolt_black_24dp.svg new file mode 100644 index 0000000..b3baae2 --- /dev/null +++ b/assets/bolt_black_24dp.svg @@ -0,0 +1 @@ + \ No newline at end of file