ntfy-android/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt

360 lines
16 KiB
Kotlin
Raw Normal View History

package io.heckel.ntfy.service
2021-11-14 13:26:37 +13:00
import android.app.*
import android.content.BroadcastReceiver
2021-11-14 13:26:37 +13:00
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
2021-11-14 13:26:37 +13:00
import android.util.Log
import androidx.core.app.NotificationCompat
2021-11-24 04:52:27 +13:00
import androidx.core.content.ContextCompat
2021-12-14 14:54:36 +13:00
import io.heckel.ntfy.BuildConfig
2021-11-14 13:26:37 +13:00
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
2021-11-15 15:42:41 +13:00
import io.heckel.ntfy.data.ConnectionState
2022-01-16 12:40:38 +13:00
import io.heckel.ntfy.data.Repository
2021-11-14 13:26:37 +13:00
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher
2021-11-14 13:26:37 +13:00
import io.heckel.ntfy.ui.MainActivity
2021-12-14 14:54:36 +13:00
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
2021-11-14 13:26:37 +13:00
import java.util.concurrent.ConcurrentHashMap
2021-12-14 14:54:36 +13:00
2021-11-14 13:26:37 +13:00
/**
2021-12-14 14:54:36 +13:00
* The subscriber service manages the foreground service for instant delivery.
*
* This should be so easy but it's a hot mess due to all the Android restrictions, and all the hoops you have to jump
* through to make your service not die or restart.
*
* Cliff notes:
* - If the service is running, we keep one connection per base URL open (we group all topics together)
* - Incoming notifications are immediately forwarded and broadcasted
*
* "Trying to keep the service running" cliff notes:
* - Manages the service SHOULD-BE state in a SharedPref, so that we know whether or not to restart the service
* - The foreground service is STICKY, so it is restarted by Android if it's killed
* - On destroy (onDestroy), we send a broadcast to AutoRestartReceiver (see AndroidManifest.xml) which will schedule
* a one-off AutoRestartWorker to restart the service (this is weird, but necessary because services started from
* receivers are apparently low priority, see the gist below for details)
* - The MainActivity schedules a periodic worker (AutoRestartWorker) which restarts the service
* - FCM receives keepalive message from the main ntfy.sh server, which broadcasts an intent to AutoRestartReceiver,
* which will schedule a one-off AutoRestartWorker to restart the service (see above)
* - On boot, the BootStartReceiver is triggered to restart the service (see AndroidManifest.xml)
*
* This is all a hot mess, but you do what you gotta do.
2021-11-14 13:26:37 +13:00
*
* Largely modeled after this fantastic resource:
* - https://robertohuertas.com/2019/06/29/android_foreground_services/
* - https://github.com/robertohuertasm/endless-service/blob/master/app/src/main/java/com/robertohuertas/endless/EndlessService.kt
2021-12-14 14:54:36 +13:00
* - https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd
2021-11-14 13:26:37 +13:00
*/
2022-01-16 07:31:34 +13:00
interface Connection {
fun start()
2022-01-16 13:20:30 +13:00
fun close()
2022-01-16 07:31:34 +13:00
fun since(): Long
fun matches(otherSubscriptionIds: Collection<Long>): Boolean
}
2021-11-14 13:26:37 +13:00
class SubscriberService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private val repository by lazy { (application as Application).repository }
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
2022-01-16 07:31:34 +13:00
private val connections = ConcurrentHashMap<String, Connection>() // Base URL -> Connection
2021-11-14 13:26:37 +13:00
private val api = ApiService()
private var notificationManager: NotificationManager? = null
2021-11-17 08:08:52 +13:00
private var serviceNotification: Notification? = null
2021-11-14 13:26:37 +13:00
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand executed with startId: $startId")
if (intent != null) {
val action = intent.action
Log.d(TAG, "using an intent with action $action")
when (action) {
Action.START.name -> startService()
Action.STOP.name -> stopService()
2021-11-14 13:26:37 +13:00
else -> Log.e(TAG, "This should never happen. No action in the received intent")
}
} else {
Log.d(TAG, "with a null intent. It has been probably restarted by the system.")
}
return START_STICKY // restart if system kills the service
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Subscriber service has been created")
val title = getString(R.string.channel_subscriber_notification_title)
val text = if (BuildConfig.FIREBASE_AVAILABLE) {
getString(R.string.channel_subscriber_notification_instant_text)
} else {
getString(R.string.channel_subscriber_notification_noinstant_text)
}
notificationManager = createNotificationChannel()
2021-11-17 08:08:52 +13:00
serviceNotification = createNotification(title, text)
2021-11-17 08:08:52 +13:00
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification)
2021-11-14 13:26:37 +13:00
}
override fun onDestroy() {
Log.d(TAG, "Subscriber service has been destroyed")
2022-01-12 11:12:32 +13:00
stopService()
2021-12-14 14:54:36 +13:00
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart it if necessary!
super.onDestroy()
2021-11-14 13:26:37 +13:00
}
private fun startService() {
if (isServiceStarted) {
2021-11-17 08:08:52 +13:00
refreshConnections()
2021-11-14 13:26:37 +13:00
return
}
Log.d(TAG, "Starting the foreground service task")
isServiceStarted = true
saveServiceState(this, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
2022-01-12 11:12:32 +13:00
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
}
if (repository.getWakelockEnabled()) {
wakeLock?.acquire()
2021-11-14 13:26:37 +13:00
}
2021-11-17 08:08:52 +13:00
refreshConnections()
2021-11-14 13:26:37 +13:00
}
private fun stopService() {
Log.d(TAG, "Stopping the foreground service")
// Cancelling all remaining jobs and open HTTP calls
2022-01-16 13:20:30 +13:00
connections.values.forEach { connection -> connection.close() }
2021-11-17 08:08:52 +13:00
connections.clear()
2021-11-14 13:26:37 +13:00
// Releasing wake-lock and stopping ourselves
try {
wakeLock?.let {
2022-01-12 11:35:11 +13:00
// Release all acquire()
2022-01-12 11:12:32 +13:00
while (it.isHeld) {
2021-11-14 13:26:37 +13:00
it.release()
}
}
2022-01-12 11:12:32 +13:00
wakeLock = null
2021-11-14 13:26:37 +13:00
stopForeground(true)
stopSelf()
} catch (e: Exception) {
Log.d(TAG, "Service stopped without being started: ${e.message}")
}
isServiceStarted = false
saveServiceState(this, ServiceState.STOPPED)
}
2021-11-17 08:08:52 +13:00
private fun refreshConnections() =
GlobalScope.launch(Dispatchers.IO) {
// Group INSTANT subscriptions by base URL, there is only one connection per base URL
val instantSubscriptions = repository.getSubscriptions()
2021-11-17 08:08:52 +13:00
.filter { s -> s.instant }
val instantSubscriptionsByBaseUrl = instantSubscriptions // BaseUrl->Map[Topic->SubscriptionId]
2021-11-17 08:08:52 +13:00
.groupBy { s -> s.baseUrl }
.mapValues { entry ->
entry.value.associate { subscription -> subscription.topic to subscription.id }
}
2021-11-17 08:08:52 +13:00
Log.d(TAG, "Refreshing subscriptions")
Log.d(TAG, "- Subscriptions: $instantSubscriptionsByBaseUrl")
2021-11-17 08:08:52 +13:00
Log.d(TAG, "- Active connections: $connections")
// Start new connections and restart connections (if subscriptions have changed)
instantSubscriptionsByBaseUrl.forEach { (baseUrl, subscriptions) ->
2022-01-12 13:37:34 +13:00
// Do NOT request old messages for new connections; we'll call poll() in MainActivity.
// This is important, so we don't download attachments from old messages, which is not desired.
var since = System.currentTimeMillis()/1000
2021-11-17 08:08:52 +13:00
val connection = connections[baseUrl]
if (connection != null && !connection.matches(subscriptions.values)) {
2021-11-17 08:08:52 +13:00
since = connection.since()
connections.remove(baseUrl)
2022-01-16 13:20:30 +13:00
connection.close()
2021-11-17 08:08:52 +13:00
}
if (!connections.containsKey(baseUrl)) {
val serviceActive = { -> isServiceStarted }
2022-01-16 12:40:38 +13:00
val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) {
2022-01-16 07:31:34 +13:00
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
WsConnection(repository, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, alarmManager)
} else {
JsonConnection(this, repository, api, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, serviceActive)
}
2021-11-17 08:08:52 +13:00
connections[baseUrl] = connection
2022-01-16 07:31:34 +13:00
connection.start()
}
2021-11-14 13:26:37 +13:00
}
2021-11-17 08:08:52 +13:00
// Close connections without subscriptions
val baseUrls = instantSubscriptionsByBaseUrl.keys
2021-11-17 08:08:52 +13:00
connections.keys().toList().forEach { baseUrl ->
if (!baseUrls.contains(baseUrl)) {
val connection = connections.remove(baseUrl)
2022-01-16 13:20:30 +13:00
connection?.close()
}
2021-11-14 13:26:37 +13:00
}
2021-11-17 08:08:52 +13:00
// Update foreground service notification popup
if (connections.size > 0) {
synchronized(this) {
val title = getString(R.string.channel_subscriber_notification_title)
val text = if (BuildConfig.FIREBASE_AVAILABLE) {
when (instantSubscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_instant_text_one)
2 -> getString(R.string.channel_subscriber_notification_instant_text_two)
3 -> getString(R.string.channel_subscriber_notification_instant_text_three)
4 -> getString(R.string.channel_subscriber_notification_instant_text_four)
else -> getString(R.string.channel_subscriber_notification_instant_text_more, instantSubscriptions.size)
}
} else {
when (instantSubscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_noinstant_text_one)
2 -> getString(R.string.channel_subscriber_notification_noinstant_text_two)
3 -> getString(R.string.channel_subscriber_notification_noinstant_text_three)
4 -> getString(R.string.channel_subscriber_notification_noinstant_text_four)
else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size)
}
}
2021-11-17 08:08:52 +13:00
serviceNotification = createNotification(title, text)
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
}
}
2021-11-14 13:26:37 +13:00
}
private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
2021-11-17 08:08:52 +13:00
repository.updateState(subscriptionIds, state)
2021-11-14 13:26:37 +13:00
}
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.data.Notification) {
2022-01-12 11:12:32 +13:00
// If permanent wakelock is not enabled, still take the wakelock while notifications are being dispatched
if (!repository.getWakelockEnabled()) {
// Wakelocks are reference counted by default so that should work neatly here
2022-01-12 16:50:07 +13:00
wakeLock?.acquire(10*60*1000L /*10 minutes*/)
2022-01-12 11:12:32 +13:00
}
val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $notification")
2021-11-17 08:08:52 +13:00
GlobalScope.launch(Dispatchers.IO) {
if (repository.addNotification(notification)) {
Log.d(TAG, "[$url] Dispatching notification $notification")
dispatcher.dispatch(subscription, notification)
}
2022-01-12 11:12:32 +13:00
if (!repository.getWakelockEnabled()) {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
}
2021-11-14 13:26:37 +13:00
}
}
private fun createNotificationChannel(): NotificationManager? {
2021-11-14 13:26:37 +13:00
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(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let {
2021-11-14 13:26:37 +13:00
it.setShowBadge(false) // Don't show long-press badge
it
}
notificationManager.createNotificationChannel(channel)
return notificationManager
2021-11-14 13:26:37 +13:00
}
return null
}
2021-11-14 13:26:37 +13:00
private fun createNotification(title: String, text: String): Notification {
2021-11-14 13:26:37 +13:00
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0, notificationIntent, 0)
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_instant)
2021-11-24 04:52:27 +13:00
.setColor(ContextCompat.getColor(this, R.color.primaryColor))
2021-11-14 13:26:37 +13:00
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setSound(null)
.setShowWhen(false) // Don't show date/time
.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 */
2021-12-14 14:54:36 +13:00
class BootStartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
2021-12-14 14:54:36 +13:00
Log.d(TAG, "BootStartReceiver: onReceive called")
SubscriberServiceManager.refresh(context)
2021-12-14 14:54:36 +13:00
}
}
// We are starting MyService via a worker and not directly because since Android 7
// (but officially since Lollipop!), any process called by a BroadcastReceiver
// (only manifest-declared receiver) is run at low priority and hence eventually
// killed by Android.
class AutoRestartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "AutoRestartReceiver: onReceive called")
SubscriberServiceManager.refresh(context)
}
}
enum class Action {
2021-11-14 13:26:37 +13:00
START,
STOP
}
enum class ServiceState {
STARTED,
STOPPED,
}
companion object {
2021-12-14 14:54:36 +13:00
const val TAG = "NtfySubscriberService"
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "NtfyAutoRestartWorkerPeriodic" // Do not change!
2021-12-14 14:54:36 +13:00
2021-11-14 13:26:37 +13:00
private const val WAKE_LOCK_TAG = "SubscriberService:lock"
private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
private const val NOTIFICATION_SERVICE_ID = 2586
2021-11-14 13:26:37 +13:00
private const val SHARED_PREFS_ID = "SubscriberService"
private const val SHARED_PREFS_SERVICE_STATE = "ServiceState"
fun saveServiceState(context: Context, state: ServiceState) {
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
sharedPrefs.edit()
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
.apply()
}
fun readServiceState(context: Context): ServiceState {
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
val value = sharedPrefs.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
return ServiceState.valueOf(value!!)
}
}
}