ntfy-android/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
2021-12-29 21:36:47 +01:00

334 lines
15 KiB
Kotlin

package io.heckel.ntfy.msg
import android.app.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
/**
* 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.
*
* 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
* - https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd
*/
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) }
private val connections = ConcurrentHashMap<String, SubscriberConnection>() // Base URL -> Connection
private val api = ApiService()
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
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) {
Actions.START.name -> startService()
Actions.STOP.name -> stopService()
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 = getString(R.string.channel_subscriber_notification_text)
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(NOTIFICATION_SERVICE_ID, serviceNotification)
}
override fun onDestroy() {
Log.d(TAG, "Subscriber service has been destroyed")
sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart it if necessary!
super.onDestroy()
}
private fun startService() {
if (isServiceStarted) {
refreshConnections()
return
}
Log.d(TAG, "Starting the foreground service task")
isServiceStarted = true
saveServiceState(this, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
}
refreshConnections()
}
private fun stopService() {
Log.d(TAG, "Stopping the foreground service")
// Cancelling all remaining jobs and open HTTP calls
connections.values.forEach { connection -> connection.cancel() }
connections.clear()
// Releasing wake-lock and stopping ourselves
try {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
stopForeground(true)
stopSelf()
} catch (e: Exception) {
Log.d(TAG, "Service stopped without being started: ${e.message}")
}
isServiceStarted = false
saveServiceState(this, ServiceState.STOPPED)
}
private fun refreshConnections() =
GlobalScope.launch(Dispatchers.IO) {
// Group subscriptions by base URL (Base URL -> Map<SubId -> Sub>.
// There is only one connection per base URL.
val subscriptions = repository.getSubscriptions()
.filter { s -> s.instant }
val subscriptionsByBaseUrl = subscriptions
.groupBy { s -> s.baseUrl }
.mapValues { entry -> entry.value.associateBy { it.id } }
Log.d(TAG, "Refreshing subscriptions")
Log.d(TAG, "- Subscriptions: $subscriptionsByBaseUrl")
Log.d(TAG, "- Active connections: $connections")
// Start new connections and restart connections (if subscriptions have changed)
subscriptionsByBaseUrl.forEach { (baseUrl, subscriptions) ->
val connection = connections[baseUrl]
var since = 0L
if (connection != null && !connection.matches(subscriptions)) {
since = connection.since()
connections.remove(baseUrl)
connection.cancel()
}
if (!connections.containsKey(baseUrl)) {
val serviceActive = { -> isServiceStarted }
val connection = SubscriberConnection(api, baseUrl, since, subscriptions, ::onStateChanged, ::onNotificationReceived, serviceActive)
connections[baseUrl] = connection
connection.start(this)
}
}
// Close connections without subscriptions
val baseUrls = subscriptionsByBaseUrl.keys
connections.keys().toList().forEach { baseUrl ->
if (!baseUrls.contains(baseUrl)) {
val connection = connections.remove(baseUrl)
connection?.cancel()
}
}
// Update foreground service notification popup
if (connections.size > 0) {
synchronized(this) {
val title = getString(R.string.channel_subscriber_notification_title)
val text = when (subscriptions.size) {
1 -> getString(R.string.channel_subscriber_notification_text_one)
2 -> getString(R.string.channel_subscriber_notification_text_two)
3 -> getString(R.string.channel_subscriber_notification_text_three)
4 -> getString(R.string.channel_subscriber_notification_text_four)
else -> getString(R.string.channel_subscriber_notification_text_more, subscriptions.size)
}
serviceNotification = createNotification(title, text)
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
}
}
}
private fun onStateChanged(subscriptions: Collection<Subscription>, state: ConnectionState) {
val subscriptionIds = subscriptions.map { it.id }
repository.updateState(subscriptionIds, state)
}
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.data.Notification) {
val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $notification")
GlobalScope.launch(Dispatchers.IO) {
if (repository.addNotification(notification)) {
Log.d(TAG, "[$url] Dispatching notification $notification")
dispatcher.dispatch(subscription, notification)
}
}
}
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(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)
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_instant)
.setColor(ContextCompat.getColor(this, R.color.primaryColor))
.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 */
class BootStartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "BootStartReceiver: onReceive called")
if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) {
Intent(context, SubscriberService::class.java).also {
it.action = Actions.START.name
Log.d(TAG, "BootStartReceiver: Starting subscriber service")
ContextCompat.startForegroundService(context, it)
}
}
}
}
// 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")
val workManager = WorkManager.getInstance(context)
val startServiceRequest = OneTimeWorkRequest.Builder(AutoRestartWorker::class.java).build()
workManager.enqueue(startServiceRequest)
}
}
class AutoRestartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
Log.d(TAG, "AutoRestartReceiver: doWork called for: " + this.getId())
if (readServiceState(context) == ServiceState.STARTED) {
Intent(context, SubscriberService::class.java).also {
it.action = Actions.START.name
Log.d(TAG, "AutoRestartReceiver: Starting subscriber service")
ContextCompat.startForegroundService(context, it)
}
}
return Result.success()
}
}
enum class Actions {
START,
STOP
}
enum class ServiceState {
STARTED,
STOPPED,
}
companion object {
const val TAG = "NtfySubscriberService"
const val AUTO_RESTART_WORKER_VERSION = BuildConfig.VERSION_CODE
const val AUTO_RESTART_WORKER_WORK_NAME_PERIODIC = "NtfyAutoRestartWorkerPeriodic"
private const val WAKE_LOCK_TAG = "SubscriberService:lock"
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"
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!!)
}
}
}