Enable/disable fast delivery; restart service on boot

This commit is contained in:
Philipp Heckel 2021-11-14 13:54:48 -05:00
parent 719a04aeaa
commit 276d773152
20 changed files with 353 additions and 170 deletions

View file

@ -49,6 +49,13 @@
<!-- Subscriber foreground service for hosts other than ntfy.sh --> <!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".msg.SubscriberService" /> <service android:name=".msg.SubscriberService" />
<!-- Subscriber service restart on reboot -->
<receiver android:enabled="true" android:name=".msg.SubscriberService$StartReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<!-- Firebase messaging --> <!-- Firebase messaging -->
<service <service
android:name=".msg.FirebaseService" android:name=".msg.FirebaseService"

View file

@ -116,6 +116,9 @@ interface SubscriptionDao {
@Insert @Insert
fun add(subscription: Subscription) fun add(subscription: Subscription)
@Update
fun update(subscription: Subscription)
@Query("DELETE FROM subscription WHERE id = :subscriptionId") @Query("DELETE FROM subscription WHERE id = :subscriptionId")
fun remove(subscriptionId: Long) fun remove(subscriptionId: Long)
} }

View file

@ -19,17 +19,23 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
.map { list -> toSubscriptionList(list) } .map { list -> toSubscriptionList(list) }
} }
fun getSubscriptionIdsLiveData(): LiveData<Set<Long>> { fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData<Set<Pair<Long, Boolean>>> {
return subscriptionDao return subscriptionDao
.listFlow() .listFlow()
.asLiveData() .asLiveData()
.map { list -> list.map { it.id }.toSet() } .map { list -> list.map { Pair(it.id, it.instant) }.toSet() }
} }
fun getSubscriptions(): List<Subscription> { fun getSubscriptions(): List<Subscription> {
return toSubscriptionList(subscriptionDao.list()) return toSubscriptionList(subscriptionDao.list())
} }
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun getSubscription(subscriptionId: Long): Subscription? {
return toSubscription(subscriptionDao.get(subscriptionId))
}
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? { suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
@ -42,6 +48,12 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
subscriptionDao.add(subscription) subscriptionDao.add(subscription)
} }
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun updateSubscription(subscription: Subscription) {
subscriptionDao.update(subscription)
}
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun removeSubscription(subscriptionId: Long) { suspend fun removeSubscription(subscriptionId: Long) {

View file

@ -79,10 +79,12 @@ class ApiService {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Connection to $url failed (1): ${e.message}", e)
fail(e) fail(e)
} }
} }
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.e(TAG, "Connection to $url failed (2): ${e.message}", e)
fail(e) fail(e)
} }
}) })

View file

@ -32,6 +32,7 @@ class FirebaseService : FirebaseMessagingService() {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}") Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
return return
} }
Log.d(TAG, "Received notification: from=${remoteMessage.from}, data=${data}")
CoroutineScope(job).launch { CoroutineScope(job).launch {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL! val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!

View file

@ -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

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.msg
import android.app.* import android.app.*
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@ -33,10 +34,8 @@ class SubscriberService : Service() {
private val calls = ConcurrentHashMap<Long, Call>() // Subscription ID -> Cal private val calls = ConcurrentHashMap<Long, Call>() // Subscription ID -> Cal
private val api = ApiService() private val api = ApiService()
private val notifier = NotificationService(this) private val notifier = NotificationService(this)
private var notificationManager: NotificationManager? = null
override fun onBind(intent: Intent): IBinder? { private var notification: Notification? = null
return null // We don't provide binding, so return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand executed with startId: $startId") Log.d(TAG, "onStartCommand executed with startId: $startId")
@ -56,29 +55,24 @@ class SubscriberService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d(TAG, "The service has been created".toUpperCase()) Log.d(TAG, "Subscriber service has been created")
val notification = createNotification()
startForeground(SERVICE_ID, notification) 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Log.d(TAG, "The service has been destroyed".toUpperCase()) Log.d(TAG, "Subscriber service has been destroyed")
}
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);
} }
private fun startService() { private fun startService() {
if (isServiceStarted) { if (isServiceStarted) {
launchOrCancelJobs() launchAndCancelJobs()
return return
} }
Log.d(TAG, "Starting the foreground service task") Log.d(TAG, "Starting the foreground service task")
@ -89,7 +83,7 @@ class SubscriberService : Service() {
acquire() acquire()
} }
} }
launchOrCancelJobs() launchAndCancelJobs()
} }
private fun stopService() { private fun stopService() {
@ -118,100 +112,116 @@ class SubscriberService : Service() {
saveServiceState(this, ServiceState.STOPPED) saveServiceState(this, ServiceState.STOPPED)
} }
private fun launchOrCancelJobs() = GlobalScope.launch(Dispatchers.IO) { private fun launchAndCancelJobs() =
val subscriptions = repository.getSubscriptions().filter { s -> s.instant } GlobalScope.launch(Dispatchers.IO) {
val subscriptionIds = subscriptions.map { it.id } val subscriptions = repository.getSubscriptions().filter { s -> s.instant }
Log.d(TAG, "Starting/stopping jobs for current subscriptions") val subscriptionIds = subscriptions.map { it.id }
Log.d(TAG, "- Subscriptions: $subscriptions") Log.d(TAG, "Refreshing subscriptions")
Log.d(TAG, "- Jobs: $jobs") Log.d(TAG, "- Subscriptions: $subscriptions")
Log.d(TAG, "- HTTP calls: $calls") Log.d(TAG, "- Jobs: $jobs")
subscriptions.forEach { subscription -> Log.d(TAG, "- HTTP calls: $calls")
if (!jobs.containsKey(subscription.id)) { subscriptions.forEach { subscription ->
Log.d(TAG, "Starting job for $subscription") if (!jobs.containsKey(subscription.id)) {
jobs[subscription.id] = launchJob(this, subscription) jobs[subscription.id] = launchJob(this, subscription)
} }
} }
jobs.keys().toList().forEach { subscriptionId -> jobs.keys().toList().forEach { subscriptionId ->
if (!subscriptionIds.contains(subscriptionId)) { if (!subscriptionIds.contains(subscriptionId)) {
Log.d(TAG, "Cancelling job for $subscriptionId") cancelJob(subscriptionId)
val job = jobs.remove(subscriptionId) }
val call = calls.remove(subscriptionId)
job?.cancel()
call?.cancel()
} }
} }
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) { private fun launchJob(scope: CoroutineScope, subscription: Subscription): Job =
val url = topicUrl(subscription.baseUrl, subscription.topic) scope.launch(Dispatchers.IO) {
Log.d(TAG, "[$url] Starting connection job") val url = topicUrl(subscription.baseUrl, subscription.topic)
var since = 0L Log.d(TAG, "[$url] Starting connection job")
var retryMillis = 0L
while (isActive && isServiceStarted) {
Log.d(TAG, "[$url] (Re-)starting subscription for $subscription")
val startTime = System.currentTimeMillis()
try { // Retry-loop: if the connection fails, we retry unless the job or service is cancelled/stopped
val failed = AtomicBoolean(false) 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 -> val notify = { n: io.heckel.ntfy.data.Notification ->
Log.d(TAG, "[$url] Received new notification: $n")
since = n.timestamp since = n.timestamp
scope.launch(Dispatchers.IO) { onNotificationReceived(scope, subscription, n)
val added = repository.addNotification(n) }
if (added) { val failed = AtomicBoolean(false)
Log.d(TAG, "[$url] Showing notification: $n") val fail = { e: Exception -> failed.set(true) }
notifier.send(subscription, n.message)
} // 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) // If we're not cancelled yet, wait little before retrying (incremental back-off)
failed.set(true) 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) { Log.d(TAG, "[$url] Connection job SHUT DOWN")
val connectionDurationMillis = System.currentTimeMillis() - startTime }
if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) {
retryMillis = RETRY_STEP_MILLIS private fun onNotificationReceived(scope: CoroutineScope, subscription: Subscription, n: io.heckel.ntfy.data.Notification) {
} else if (retryMillis + RETRY_STEP_MILLIS >= RETRY_MAX_MILLIS) { val url = topicUrl(subscription.baseUrl, subscription.topic)
retryMillis = RETRY_MAX_MILLIS Log.d(TAG, "[$url] Received notification: $n")
} else { scope.launch(Dispatchers.IO) {
retryMillis += RETRY_STEP_MILLIS val added = repository.addNotification(n)
} if (added) {
Log.d(TAG, "Connection failed, retrying connection in ${retryMillis/1000}s ...") Log.d(TAG, "[$url] Showing notification: $n")
delay(retryMillis) 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelName = getString(R.string.channel_subscriber_service_name) // Show's up in UI 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.setShowBadge(false) // Don't show long-press badge
it it
} }
notificationManager.createNotificationChannel(channel) 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 -> val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0, notificationIntent, 0) PendingIntent.getActivity(this, 0, notificationIntent, 0)
} }
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
val title = getString(R.string.channel_subscriber_notification_title)
val text = getString(R.string.channel_subscriber_notification_text)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_icon) .setSmallIcon(R.drawable.ic_notification_icon)
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
@ -221,6 +231,39 @@ class SubscriberService : Service() {
.build() .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 { enum class Actions {
START, START,
STOP STOP
@ -234,14 +277,14 @@ class SubscriberService : Service() {
companion object { companion object {
private const val TAG = "NtfySubscriberService" private const val TAG = "NtfySubscriberService"
private const val WAKE_LOCK_TAG = "SubscriberService:lock" private const val WAKE_LOCK_TAG = "SubscriberService:lock"
private const val CHANNEL_ID = "ntfy-subscriber" private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
private const val SERVICE_ID = 2586 private const val NOTIFICATION_SERVICE_ID = 2586
private const val SHARED_PREFS_ID = "SubscriberService" private const val SHARED_PREFS_ID = "SubscriberService"
private const val SHARED_PREFS_SERVICE_STATE = "ServiceState" private const val SHARED_PREFS_SERVICE_STATE = "ServiceState"
private const val CONNECTION_LOOP_DELAY_MILLIS = 30_000L private const val CONNECTION_LOOP_DELAY_MILLIS = 30_000L
private const val RETRY_STEP_MILLIS = 5_000L private const val RETRY_STEP_MILLIS = 5_000L
private const val RETRY_MAX_MILLIS = 60_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) { fun saveServiceState(context: Context, state: ServiceState) {
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)

View file

@ -19,12 +19,14 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkManager
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
@ -37,16 +39,20 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
} }
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService() private val api = ApiService()
private var subscriberManager: SubscriberManager? = null // Context-dependent
// Which subscription are we looking at // Which subscription are we looking at
private var subscriptionId: Long = 0L // Set in onCreate() private var subscriptionId: Long = 0L // Set in onCreate()
private var subscriptionBaseUrl: String = "" // Set in onCreate() private var subscriptionBaseUrl: String = "" // Set in onCreate()
private var subscriptionTopic: 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 // Action mode stuff
private lateinit var mainList: RecyclerView
private lateinit var adapter: DetailAdapter
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -55,6 +61,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
Log.d(MainActivity.TAG, "Create $this") Log.d(MainActivity.TAG, "Create $this")
// Dependencies that depend on Context
subscriberManager = SubscriberManager(this)
// Show 'Back' button // Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true) 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 { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.detail_action_bar_menu, menu) menuInflater.inflate(R.menu.detail_action_bar_menu, menu)
this.menu = menu
showHideInstantMenuItems(subscriptionInstant)
return true return true
} }
@ -118,6 +134,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
onRefreshClick() onRefreshClick()
true 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 -> { R.id.detail_menu_copy_url -> {
onCopyUrlClick() onCopyUrlClick()
true 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() { private fun onDeleteClick() {
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")

View file

@ -51,6 +51,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent private var workManager: WorkManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent
private var subscriberManager: SubscriberManager? = null // Context-dependent
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -61,6 +62,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
// Dependencies that depend on Context // Dependencies that depend on Context
workManager = WorkManager.getInstance(this) workManager = WorkManager.getInstance(this)
notifier = NotificationService(this) notifier = NotificationService(this)
subscriberManager = SubscriberManager(this)
// Action bar // Action bar
title = getString(R.string.main_action_bar_title) title = getString(R.string.main_action_bar_title)
@ -93,13 +95,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
} }
} }
viewModel.listIds().observe(this) { // React to changes in fast delivery setting
maybeStartOrStopSubscriberService() viewModel.listIdsWithInstantStatus().observe(this) {
subscriberManager?.refreshService(it)
} }
// Background things // Background things
startPeriodicWorker() startPeriodicWorker()
maybeStartOrStopSubscriberService()
} }
private fun startPeriodicWorker() { 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) { private fun startDetailView(subscription: Subscription) {
Log.d(TAG, "Entering detail view for subscription $subscription") Log.d(TAG, "Entering detail view for subscription $subscription")

View file

@ -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 nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.main_item_status) private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date) 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) { fun bind(subscription: Subscription) {
this.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) nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage statusView.text = statusMessage
dateView.text = dateText dateView.text = dateText
if (subscription.instant) {
instantImageView.visibility = View.VISIBLE
} else {
instantImageView.visibility = View.GONE
}
itemView.setOnClickListener { onClick(subscription) } itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true } itemView.setOnLongClickListener { onLongClick(subscription); true }
if (selected.contains(subscription.id)) { if (selected.contains(subscription.id)) {

View file

@ -14,8 +14,8 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
return repository.getSubscriptionsLiveData() return repository.getSubscriptionsLiveData()
} }
fun listIds(): LiveData<Set<Long>> { fun listIdsWithInstantStatus(): LiveData<Set<Pair<Long, Boolean>>> {
return repository.getSubscriptionIdsLiveData() return repository.getSubscriptionIdsWithInstantStatusLiveData()
} }
fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) { fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {

View file

@ -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<Pair<Long, Boolean>>) {
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)
}
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z"
android:fillColor="#000000"/>
</vector>

View file

@ -1,39 +1,47 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_height="wrap_content" xmlns:tools="http://schemas.android.com/tools"
android:background="?android:attr/selectableItemBackground" android:layout_width="match_parent"
android:orientation="horizontal" android:clickable="true" android:focusable="true"> android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="horizontal" android:clickable="true"
android:focusable="true">
<ImageView <ImageView
android:layout_width="35dp" android:layout_width="37dp"
android:layout_height="match_parent" app:srcCompat="@drawable/ic_sms_gray_24dp" android:layout_height="37dp" app:srcCompat="@drawable/ic_sms_gray_48dp"
android:id="@+id/topic_image" android:layout_marginStart="20dp" android:layout_weight="1"/> android:id="@+id/main_item_image" app:layout_constraintTop_toTopOf="parent"
<LinearLayout app:layout_constraintStart_toStartOf="parent"
android:orientation="vertical" android:layout_marginStart="15dp" android:layout_marginTop="12dp"/>
android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_weight="20">
<TextView
android:text="ntfy.sh/example"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/main_item_text"
android:layout_marginTop="10dp" android:layout_marginStart="12dp"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
<TextView
android:text="Subscribed, 0 notifications"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/main_item_status"
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginStart="12dp"
android:layout_marginBottom="10dp"/>
</LinearLayout>
<TextView <TextView
android:text="yesterday" android:text="ntfy.sh/example"
android:layout_width="75dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content" android:id="@+id/main_item_text"
android:textAppearance="@style/TextAppearance.AppCompat.Small" app:layout_constraintTop_toTopOf="parent"
android:id="@+id/main_item_date" android:layout_marginEnd="15dp" android:layout_weight="1" app:layout_constraintBottom_toTopOf="@+id/main_item_status"
android:layout_marginTop="10dp" android:textAlignment="textEnd"/> android:layout_marginStart="10dp" app:layout_constraintStart_toEndOf="@+id/main_item_image"
app:layout_constraintVertical_bias="0.0" android:textAppearance="@style/TextAppearance.AppCompat.Medium"
</LinearLayout> android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp"
app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/>
<TextView
android:text="89 notifications"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:id="@+id/main_item_status"
app:layout_constraintStart_toStartOf="@+id/main_item_text"
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="10dp"/>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp" app:srcCompat="@drawable/ic_bolt_black_24dp"
android:id="@+id/main_item_instant_image"
app:layout_constraintTop_toTopOf="@+id/main_item_text"
app:layout_constraintEnd_toStartOf="@+id/main_item_date"/>
<TextView
android:text="10:13"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:id="@+id/main_item_date"
app:layout_constraintTop_toTopOf="@+id/main_item_instant_image"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="15dp"
/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,5 +1,8 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" > <menu xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/> <item android:id="@+id/detail_menu_test" android:title="@string/detail_menu_test"/>
<item android:id="@+id/detail_menu_enable_instant" android:title="@string/detail_menu_enable_instant"
/>
<item android:id="@+id/detail_menu_disable_instant" android:title="@string/detail_menu_disable_instant"/>
<item android:id="@+id/detail_menu_refresh" android:title="@string/detail_menu_refresh"/> <item android:id="@+id/detail_menu_refresh" android:title="@string/detail_menu_refresh"/>
<item android:id="@+id/detail_menu_copy_url" android:title="@string/detail_menu_copy_url"/> <item android:id="@+id/detail_menu_copy_url" android:title="@string/detail_menu_copy_url"/>
<item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/> <item android:id="@+id/detail_menu_unsubscribe" android:title="@string/detail_menu_unsubscribe"/>

View file

@ -1,4 +1,4 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" > <menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/detail_action_mode_delete" android:title="@string/detail_action_mode_menu_delete" <item android:id="@+id/detail_action_mode_delete" android:title="@string/detail_action_mode_menu_delete"
android:icon="@drawable/baseline_delete_20"/> android:icon="@drawable/ic_delete_gray_20dp"/>
</menu> </menu>

View file

@ -1,4 +1,4 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" > <menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/main_action_mode_delete" android:title="@string/main_action_mode_menu_unsubscribe" <item android:id="@+id/main_action_mode_delete" android:title="@string/main_action_mode_menu_unsubscribe"
android:icon="@drawable/baseline_delete_20"/> android:icon="@drawable/ic_delete_gray_20dp"/>
</menu> </menu>

View file

@ -7,7 +7,7 @@
<string name="channel_notifications_name">Notifications</string> <string name="channel_notifications_name">Notifications</string>
<string name="channel_subscriber_service_name">Subscription Service</string> <string name="channel_subscriber_service_name">Subscription Service</string>
<string name="channel_subscriber_notification_title">Subscribed topics</string> <string name="channel_subscriber_notification_title">Subscribed topics</string>
<string name="channel_subscriber_notification_text">Listening patiently for incoming notifications</string> <string name="channel_subscriber_notification_text">Listening for incoming notifications</string>
<!-- Common refresh toasts --> <!-- Common refresh toasts -->
<string name="refresh_message_result">%1$d notification(s) received</string> <string name="refresh_message_result">%1$d notification(s) received</string>
@ -44,9 +44,10 @@
You can subscribe to topics from your own server. Due to platform limitations, this option requires a foreground 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. service and consumes more battery, but delivers notifications faster.
</string> </string>
<string name="add_dialog_instant_delivery">Instant delivery (even in doze mode)</string> <string name="add_dialog_instant_delivery">Fast delivery</string>
<string name="add_dialog_instant_delivery_description"> <string name="add_dialog_instant_delivery_description">
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.
</string> </string>
<string name="add_dialog_button_cancel">Cancel</string> <string name="add_dialog_button_cancel">Cancel</string>
<string name="add_dialog_button_subscribe">Subscribe</string> <string name="add_dialog_button_subscribe">Subscribe</string>
@ -62,11 +63,15 @@
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string> <string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>
<string name="detail_test_message_error">Could not send test message: %1$s</string> <string name="detail_test_message_error">Could not send test message: %1$s</string>
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string> <string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
<string name="detail_instant_delivery_enabled">Fast delivery enabled</string>
<string name="detail_instant_delivery_disabled">Fast delivery disabled</string>
<!-- Detail activity: Action bar --> <!-- Detail activity: Action bar -->
<string name="detail_menu_test">Send test notification</string> <string name="detail_menu_test">Send test notification</string>
<string name="detail_menu_copy_url">Copy topic address</string> <string name="detail_menu_copy_url">Copy topic address</string>
<string name="detail_menu_refresh">Force refresh</string> <string name="detail_menu_refresh">Force refresh</string>
<string name="detail_menu_enable_instant">Enable fast delivery</string>
<string name="detail_menu_disable_instant">Disable fast delivery</string>
<string name="detail_menu_unsubscribe">Unsubscribe</string> <string name="detail_menu_unsubscribe">Unsubscribe</string>
<!-- Detail activity: Action mode --> <!-- Detail activity: Action mode -->

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M11,21h-1l1-7H7.5c-0.88,0-0.33-0.75-0.31-0.78C8.48,10.94,10.42,7.54,13.01,3h1l-1,7h3.51c0.4,0,0.62,0.19,0.4,0.66 C12.97,17.55,11,21,11,21z"/></g></svg>

After

Width:  |  Height:  |  Size: 348 B