aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt
blob: 103ff1cb37378ec9be93c2602da497638e2bdf92 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
package io.heckel.ntfy.service

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 androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.ConnectionState
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.ui.Colors
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
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<ConnectionId, Connection>()
    private val api = ApiService()
    private var notificationManager: NotificationManager? = null
    private var serviceNotification: Notification? = null
    private val refreshMutex = Mutex() // Ensure refreshConnections() is only run one at a time

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d(TAG, "onStartCommand executed with startId: $startId")
        if (intent != null) {
            Log.d(TAG, "using an intent with action ${intent.action}")
            when (intent.action) {
                Action.START.name -> startService()
                Action.STOP.name -> stopService()
                else -> Log.w(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.init(this) // Init logs in all entry points
        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()
        serviceNotification = createNotification(title, text)

        startForeground(NOTIFICATION_SERVICE_ID, serviceNotification)
    }

    override fun onDestroy() {
        Log.d(TAG, "Subscriber service has been destroyed")
        stopService()
        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)
        }
        refreshConnections()
    }

    private fun stopService() {
        Log.d(TAG, "Stopping the foreground service")

        // Cancelling all remaining jobs and open HTTP calls
        connections.values.forEach { connection -> connection.close() }
        connections.clear()

        // Releasing wake-lock and stopping ourselves
        try {
            wakeLock?.let {
                // Release all acquire()
                while (it.isHeld) {
                    it.release()
                }
            }
            wakeLock = null
            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) {
            if (!refreshMutex.tryLock()) {
                Log.d(TAG, "Refreshing subscriptions already in progress. Skipping.")
                return@launch
            }
            try {
                reallyRefreshConnections(this)
            } finally {
                refreshMutex.unlock()
            }
        }
    }

    /**
     * Start/stop connections based on the desired state
     * It is guaranteed that only one of function is run at a time (see mutex above).
     */
    private suspend fun reallyRefreshConnections(scope: CoroutineScope) {
        // Group INSTANT subscriptions by base URL, there is only one connection per base URL
        val instantSubscriptions = repository.getSubscriptions()
            .filter { s -> s.instant }
        val activeConnectionIds = connections.keys().toList().toSet()
        val desiredConnectionIds = instantSubscriptions // Set<ConnectionId>
            .groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) }
            .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) }
            .toSet()
        val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds)
        val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds)
        val match = activeConnectionIds == desiredConnectionIds
        val sinceByBaseUrl = connections
            .map { e -> e.key.baseUrl to e.value.since() } // Use since=<id>, avoid retrieving old messages (see comment below)
            .toMap()

        Log.d(TAG, "Refreshing subscriptions")
        Log.d(TAG, "- Desired connections: $desiredConnectionIds")
        Log.d(TAG, "- Active connections: $activeConnectionIds")
        Log.d(TAG, "- New connections: $newConnectionIds")
        Log.d(TAG, "- Obsolete connections: $obsoleteConnectionIds")
        Log.d(TAG, "- Match? --> $match")

        if (match) {
            Log.d(TAG, "- No action required.")
            return
        }

        // Open new connections
        newConnectionIds.forEach { connectionId ->
            // IMPORTANT: Do NOT request old messages for new connections; we call poll() in MainActivity to
            // retrieve old messages. This is important, so we don't download attachments from old messages.

            val since = sinceByBaseUrl[connectionId.baseUrl] ?: "none"
            val serviceActive = { -> isServiceStarted }
            val user = repository.getUser(connectionId.baseUrl)
            val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) {
                val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
                WsConnection(connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager)
            } else {
                JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive)
            }
            connections[connectionId] = connection
            connection.start()
        }

        // Close connections without subscriptions
        obsoleteConnectionIds.forEach { connectionId ->
            val connection = connections.remove(connectionId)
            connection?.close()
        }

        // Update foreground service notification popup
        if (connections.size > 0) {
            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)
                    5 -> getString(R.string.channel_subscriber_notification_instant_text_five)
                    6 -> getString(R.string.channel_subscriber_notification_instant_text_six)
                    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)
                    5 -> getString(R.string.channel_subscriber_notification_noinstant_text_five)
                    6 -> getString(R.string.channel_subscriber_notification_noinstant_text_six)
                    else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size)
                }
            }
            serviceNotification = createNotification(title, text)
            notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
        }
    }

    private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
        repository.updateState(subscriptionIds, state)
    }

    private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {
        // Wakelock while notifications are being dispatched
        // Wakelocks are reference counted by default so that should work neatly here
        wakeLock?.acquire(NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS)

        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)
            }
            wakeLock?.let {
                if (it.isHeld) {
                    it.release()
                }
            }
        }
    }

    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, PendingIntent.FLAG_IMMUTABLE)
        }
        return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification_instant)
            .setColor(ContextCompat.getColor(this, Colors.notificationIcon(this)))
            .setContentTitle(title)
            .setContentText(text)
            .setContentIntent(pendingIntent)
            .setSound(null)
            .setShowWhen(false) // Don't show date/time
            .setOngoing(true) // Starting SDK 33 / Android 13, foreground notifications can be swiped away
            .setGroup(NOTIFICATION_GROUP_ID) // Do not group with other notifications
            .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 or PendingIntent.FLAG_IMMUTABLE);
        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")
            SubscriberServiceManager.refresh(context)
        }
    }

    // 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 {
        START,
        STOP
    }

    enum class ServiceState {
        STARTED,
        STOPPED,
    }

    companion object {
        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!

        private const val WAKE_LOCK_TAG = "SubscriberService:lock"
        private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
        private const val NOTIFICATION_GROUP_ID = "io.heckel.ntfy.NOTIFICATION_GROUP_SERVICE"
        private const val NOTIFICATION_SERVICE_ID = 2586
        private const val NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS = 10*60*1000L /*10 minutes*/
        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!!)
        }
    }
}