From 719a04aeaa13758c12acaf9de872f624287655e8 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 13 Nov 2021 19:26:37 -0500 Subject: [PATCH] Instant delivery --- README.md | 10 +- .../io.heckel.ntfy.data.Database/2.json | 12 +- app/src/main/AndroidManifest.xml | 22 +- .../main/java/io/heckel/ntfy/data/Database.kt | 14 +- .../java/io/heckel/ntfy/data/Repository.kt | 13 +- app/src/main/java/io/heckel/ntfy/data/Util.kt | 1 + .../java/io/heckel/ntfy/msg/ApiService.kt | 56 +++- .../io/heckel/ntfy/msg/FirebaseService.kt | 11 +- .../io/heckel/ntfy/msg/NotificationService.kt | 20 +- .../io/heckel/ntfy/msg/SubscriberService.kt | 259 ++++++++++++++++++ .../java/io/heckel/ntfy/ui/AddFragment.kt | 32 ++- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 22 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 91 ++++-- .../java/io/heckel/ntfy/ui/MainViewModel.kt | 4 + .../java/io/heckel/ntfy/work/PollWorker.kt | 2 - .../main/res/layout/add_dialog_fragment.xml | 33 ++- app/src/main/res/values/strings.xml | 16 +- 17 files changed, 529 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt diff --git a/README.md b/README.md index 2861a7c..201c758 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # ntfy Android App -This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)). - -## Current limitations -* The app on the Play store only works with ntfy.sh, not with other hosts, due to the fact that background services in - Android are pretty much impossible to implement. +This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)). It is available +in the [Play Store](https://play.google.com/store/apps/details?id=io.heckel.ntfy). ## License Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE). @@ -14,5 +11,4 @@ Thank you to these fantastic resources: * [Android Room with a View](https://github.com/googlecodelabs/android-room-with-a-view/tree/kotlin) (Apache 2.0) * [Firebase Messaging Example](https://github.com/firebase/quickstart-android/blob/7147f60451b3eeaaa05fc31208ffb67e2df73c3c/messaging/app/src/main/java/com/google/firebase/quickstart/fcm/kotlin/MyFirebaseMessagingService.kt) (Apache 2.0) * [Designing a logo with Inkscape](https://www.youtube.com/watch?v=r2Kv61cd2P4) - -Thanks to these projects for allowing me to copy-paste a lot. +* [Foreground service](https://robertohuertas.com/2019/06/29/android_foreground_services/) diff --git a/app/schemas/io.heckel.ntfy.data.Database/2.json b/app/schemas/io.heckel.ntfy.data.Database/2.json index 2112233..52562af 100644 --- a/app/schemas/io.heckel.ntfy.data.Database/2.json +++ b/app/schemas/io.heckel.ntfy.data.Database/2.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "30177aa8688290d24499babf22b15720", + "identityHash": "df0a0eab3fc3056bf12e04a09c084660", "entities": [ { "tableName": "Subscription", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -25,6 +25,12 @@ "columnName": "topic", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -94,7 +100,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '30177aa8688290d24499babf22b15720')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df0a0eab3fc3056bf12e04a09c084660')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a2652f..1146d7d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,9 +1,21 @@ - - + + + + + + + android:theme="@style/AppTheme" + android:usesCleartextTraffic="true"> + + + > @Query( - "SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + + "SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + "FROM subscription AS s " + "LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + "GROUP BY s.id " + @@ -94,7 +96,7 @@ interface SubscriptionDao { fun list(): List @Query( - "SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + + "SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + "FROM subscription AS s " + "LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + "WHERE s.baseUrl = :baseUrl AND s.topic = :topic " + @@ -103,7 +105,7 @@ interface SubscriptionDao { fun get(baseUrl: String, topic: String): SubscriptionWithMetadata? @Query( - "SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + + "SELECT s.id, s.baseUrl, s.topic, s.instant, COUNT(n.id) notifications, IFNULL(MAX(n.timestamp),0) AS lastActive " + "FROM subscription AS s " + "LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + "WHERE s.id = :subscriptionId " + diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index a083f58..a974bc4 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -19,6 +19,13 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif .map { list -> toSubscriptionList(list) } } + fun getSubscriptionIdsLiveData(): LiveData> { + return subscriptionDao + .listFlow() + .asLiveData() + .map { list -> list.map { it.id }.toSet() } + } + fun getSubscriptions(): List { return toSubscriptionList(subscriptionDao.list()) } @@ -52,11 +59,13 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif @Suppress("RedundantSuspendModifier") @WorkerThread - suspend fun addNotification(notification: Notification) { + suspend fun addNotification(notification: Notification): Boolean { val maybeExistingNotification = notificationDao.get(notification.id) if (maybeExistingNotification == null) { notificationDao.add(notification) + return true } + return false } @Suppress("RedundantSuspendModifier") @@ -77,6 +86,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif id = s.id, baseUrl = s.baseUrl, topic = s.topic, + instant = s.instant, lastActive = s.lastActive, notifications = s.notifications ) @@ -91,6 +101,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif id = s.id, baseUrl = s.baseUrl, topic = s.topic, + instant = s.instant, lastActive = s.lastActive, notifications = s.notifications ) diff --git a/app/src/main/java/io/heckel/ntfy/data/Util.kt b/app/src/main/java/io/heckel/ntfy/data/Util.kt index e38647e..6e3b135 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Util.kt @@ -1,6 +1,7 @@ package io.heckel.ntfy.data fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" +fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since" fun topicUrlJsonPoll(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/json?poll=1" fun topicShortUrl(baseUrl: String, topic: String) = topicUrl(baseUrl, topic) 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 1a3b12e..52700a8 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -4,19 +4,24 @@ import android.util.Log import com.google.gson.Gson import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.topicUrl +import io.heckel.ntfy.data.topicUrlJson import io.heckel.ntfy.data.topicUrlJsonPoll -import okhttp3.OkHttpClient -import okhttp3.Request +import okhttp3.* import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException import java.util.concurrent.TimeUnit class ApiService { private val gson = Gson() private val client = OkHttpClient.Builder() - .callTimeout(10, TimeUnit.SECONDS) // Total timeout for entire request - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) + .callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + private val subscriberClient = OkHttpClient.Builder() + .readTimeout(77, TimeUnit.SECONDS) // Assuming that keepalive messages are more frequent than this .build() fun publish(baseUrl: String, topic: String, message: String) { @@ -51,18 +56,53 @@ class ApiService { } } + fun subscribe(subscriptionId: Long, baseUrl: String, topic: String, since: Long, notify: (Notification) -> Unit, fail: (Exception) -> Unit): Call { + val sinceVal = if (since == 0L) "all" else since.toString() + val url = topicUrlJson(baseUrl, topic, sinceVal) + Log.d(TAG, "Opening subscription connection to $url") + + val request = Request.Builder().url(url).build() + val call = subscriberClient.newCall(request) + call.enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + try { + if (!response.isSuccessful) { + throw Exception("Unexpected response ${response.code} when subscribing to topic $url") + } + val source = response.body?.source() ?: throw Exception("Unexpected response for $url: body is empty") + while (!source.exhausted()) { + val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null") + val message = gson.fromJson(line, Message::class.java) + if (message.event == EVENT_MESSAGE) { + val notification = Notification(message.id, subscriptionId, message.time, message.message, false) + notify(notification) + } + } + } catch (e: Exception) { + fail(e) + } + } + override fun onFailure(call: Call, e: IOException) { + fail(e) + } + }) + return call + } + private fun fromString(subscriptionId: Long, s: String): Notification { - val n = gson.fromJson(s, NotificationData::class.java) // Indirection to prevent accidental field renames, etc. + val n = gson.fromJson(s, Message::class.java) return Notification(n.id, subscriptionId, n.time, n.message, false) } - private data class NotificationData( + private data class Message( val id: String, val time: Long, + val event: String, val message: String ) companion object { private const val TAG = "NtfyApiService" + private const val EVENT_MESSAGE = "message" } } 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 1403978..9017ba7 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt @@ -9,7 +9,6 @@ import io.heckel.ntfy.data.Notification import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import java.util.* class FirebaseService : FirebaseMessagingService() { private val repository by lazy { (application as Application).repository } @@ -40,11 +39,13 @@ class FirebaseService : FirebaseMessagingService() { // Add notification val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false) - repository.addNotification(notification) + val added = repository.addNotification(notification) - // Send notification - Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") - notifier.send(subscription, message) + // Send notification (only if it's not already known) + if (added) { + Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") + notifier.send(subscription, message) + } } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index e4058bc..4cfea53 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -10,23 +10,17 @@ import android.media.RingtoneManager import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage import io.heckel.ntfy.R -import io.heckel.ntfy.app.Application -import io.heckel.ntfy.data.* +import io.heckel.ntfy.data.Subscription +import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.MainActivity -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import java.util.* import kotlin.random.Random class NotificationService(val context: Context) { fun send(subscription: Subscription, message: String) { val title = topicShortUrl(subscription.baseUrl, subscription.topic) - Log.d(TAG, "Sending notification $title: $message") + Log.d(TAG, "Displaying notification $title: $message") // Create an Intent for the activity you want to start val intent = Intent(context, DetailActivity::class.java) @@ -38,9 +32,8 @@ class NotificationService(val context: Context) { getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack } - val channelId = context.getString(R.string.notification_channel_id) val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) - val notificationBuilder = NotificationCompat.Builder(context, channelId) + val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification_icon) .setContentTitle(title) .setContentText(message) @@ -50,8 +43,8 @@ class NotificationService(val context: Context) { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelName = context.getString(R.string.notification_channel_name) - val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT) + val channelName = context.getString(R.string.channel_notifications_name) // Show's up in UI + val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT) notificationManager.createNotificationChannel(channel) } notificationManager.notify(Random.nextInt(), notificationBuilder.build()) @@ -59,5 +52,6 @@ class NotificationService(val context: Context) { companion object { private const val TAG = "NtfyNotificationService" + private const val CHANNEL_ID = "ntfy" } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt new file mode 100644 index 0000000..dd031ce --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt @@ -0,0 +1,259 @@ +package io.heckel.ntfy.msg + +import android.app.* +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 io.heckel.ntfy.R +import io.heckel.ntfy.app.Application +import io.heckel.ntfy.data.Subscription +import io.heckel.ntfy.data.topicUrl +import io.heckel.ntfy.ui.MainActivity +import kotlinx.coroutines.* +import okhttp3.Call +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * + * 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 + */ +class SubscriberService : Service() { + private var wakeLock: PowerManager.WakeLock? = null + private var isServiceStarted = false + private val repository by lazy { (application as Application).repository } + private val jobs = ConcurrentHashMap() // Subscription ID -> Job + 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 + } + + 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, "The service has been created".toUpperCase()) + val notification = createNotification() + startForeground(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); + } + + private fun startService() { + if (isServiceStarted) { + launchOrCancelJobs() + 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() + } + } + launchOrCancelJobs() + } + + private fun stopService() { + Log.d(TAG, "Stopping the foreground service") + + // Cancelling all remaining jobs and open HTTP calls + jobs.values.forEach { job -> job.cancel() } + calls.values.forEach { call -> call.cancel() } + jobs.clear() + calls.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 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 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() + + try { + val failed = AtomicBoolean(false) + 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) + } + } + Unit + } + val fail = { e: Exception -> + Log.e(TAG, "[$url] Connection failed (1): ${e.message}", e) + failed.set(true) + } + 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 createNotification(): Notification { + 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 { + it.setShowBadge(false) // Don't show long-press badge + it + } + notificationManager.createNotificationChannel(channel) + } + + 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) + .setSmallIcon(R.drawable.ic_notification_icon) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(pendingIntent) + .setSound(null) + .setShowWhen(false) // Don't show date/time + .build() + } + + enum class Actions { + START, + STOP + } + + enum class ServiceState { + STARTED, + STOPPED, + } + + 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 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 + + 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!!) + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 36b7df8..b04eb25 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -20,10 +20,13 @@ import io.heckel.ntfy.data.Repository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class AddFragment(private val viewModel: SubscriptionsViewModel, private val onSubscribe: (topic: String, baseUrl: String) -> Unit) : DialogFragment() { +class AddFragment(private val viewModel: SubscriptionsViewModel, private val onSubscribe: (topic: String, baseUrl: String, instant: Boolean) -> Unit) : DialogFragment() { private lateinit var topicNameText: TextInputEditText private lateinit var baseUrlText: TextInputEditText private lateinit var useAnotherServerCheckbox: CheckBox + private lateinit var useAnotherServerDescription: View + private lateinit var instantDeliveryCheckbox: CheckBox + private lateinit var instantDeliveryDescription: View private lateinit var subscribeButton: Button override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -32,10 +35,10 @@ class AddFragment(private val viewModel: SubscriptionsViewModel, private val onS val view = requireActivity().layoutInflater.inflate(R.layout.add_dialog_fragment, null) topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText + instantDeliveryCheckbox = view.findViewById(R.id.add_dialog_instant_delivery_checkbox) as CheckBox + instantDeliveryDescription = view.findViewById(R.id.add_dialog_instant_delivery_description) useAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) as CheckBox - - // FIXME For now, other servers are disabled - useAnotherServerCheckbox.visibility = View.GONE + useAnotherServerDescription = view.findViewById(R.id.add_dialog_use_another_server_description) // Build dialog val alert = AlertDialog.Builder(it) @@ -43,7 +46,8 @@ class AddFragment(private val viewModel: SubscriptionsViewModel, private val onS .setPositiveButton(R.string.add_dialog_button_subscribe) { _, _ -> val topic = topicNameText.text.toString() val baseUrl = getBaseUrl() - onSubscribe(topic, baseUrl) + val instant = if (useAnotherServerCheckbox.isChecked) true else instantDeliveryCheckbox.isChecked + onSubscribe(topic, baseUrl, instant) } .setNegativeButton(R.string.add_dialog_button_cancel) { _, _ -> dialog?.cancel() @@ -70,9 +74,23 @@ class AddFragment(private val viewModel: SubscriptionsViewModel, private val onS } topicNameText.addTextChangedListener(textWatcher) baseUrlText.addTextChangedListener(textWatcher) + instantDeliveryCheckbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) instantDeliveryDescription.visibility = View.VISIBLE + else instantDeliveryDescription.visibility = View.GONE + } useAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) baseUrlText.visibility = View.VISIBLE - else baseUrlText.visibility = View.GONE + if (isChecked) { + useAnotherServerDescription.visibility = View.VISIBLE + baseUrlText.visibility = View.VISIBLE + instantDeliveryCheckbox.visibility = View.GONE + instantDeliveryDescription.visibility = View.GONE + } else { + useAnotherServerDescription.visibility = View.GONE + baseUrlText.visibility = View.GONE + instantDeliveryCheckbox.visibility = View.VISIBLE + if (instantDeliveryCheckbox.isChecked) instantDeliveryDescription.visibility = View.VISIBLE + else instantDeliveryDescription.visibility = View.GONE + } validateInput() } } 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 8161490..0336ec1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -29,6 +29,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.* +// TODO dismiss notifications when navigating to detail page + class DetailActivity : AppCompatActivity(), ActionMode.Callback { private val viewModel by viewModels { DetailViewModelFactory((application as Application).repository) @@ -40,6 +42,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { 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() // Action mode stuff private lateinit var mainList: RecyclerView @@ -59,6 +62,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0) subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return + subscriptionInstant = intent.getBooleanExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, false) // Set title val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return @@ -134,9 +138,11 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { val message = getString(R.string.detail_test_message, Date().toString()) api.publish(subscriptionBaseUrl, subscriptionTopic, message) } catch (e: Exception) { - Toast - .makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG) - .show() + runOnUiThread { + Toast + .makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG) + .show() + } } } } @@ -168,9 +174,11 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { newNotifications.forEach { notification -> repository.addNotification(notification) } runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() } } catch (e: Exception) { - Toast - .makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG) - .show() + runOnUiThread { + Toast + .makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG) + .show() + } } } } @@ -185,7 +193,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { // Return to main activity val result = Intent() .putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscriptionId) + .putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscriptionBaseUrl) .putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic) + .putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscriptionInstant) setResult(RESULT_OK, result) finish() 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 986da94..6dcd9e4 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -6,6 +6,7 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle import android.util.Log import android.view.ActionMode @@ -26,6 +27,10 @@ import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationService +import io.heckel.ntfy.msg.SubscriberService +import io.heckel.ntfy.msg.SubscriberService.ServiceState +import io.heckel.ntfy.msg.SubscriberService.Actions +import io.heckel.ntfy.msg.SubscriberService.Companion.readServiceState import io.heckel.ntfy.work.PollWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -88,14 +93,23 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { } } - // Kick off periodic polling - val sharedPref = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) - val workPolicy = if (sharedPref.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) { + viewModel.listIds().observe(this) { + maybeStartOrStopSubscriberService() + } + + // Background things + startPeriodicWorker() + maybeStartOrStopSubscriberService() + } + + private fun startPeriodicWorker() { + val sharedPrefs = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) + val workPolicy = if (sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) { Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy") ExistingPeriodicWorkPolicy.KEEP } else { Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy") - sharedPref.edit() + sharedPrefs.edit() .putInt(SHARED_PREFS_POLL_WORKER_VERSION, PollWorker.VERSION) .apply() ExistingPeriodicWorkPolicy.REPLACE @@ -135,12 +149,11 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { } private fun onSubscribeButtonClick() { - val newFragment = AddFragment(viewModel) { topic, baseUrl -> onSubscribe(topic, baseUrl) } + val newFragment = AddFragment(viewModel) { topic, baseUrl, instant -> onSubscribe(topic, baseUrl, instant) } newFragment.show(supportFragmentManager, "AddFragment") } - private fun onSubscribe(topic: String, baseUrl: String) { - // FIXME ignores baseUrl + private fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) { Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}") // Add subscription to database @@ -148,21 +161,25 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { id = Random.nextLong(), baseUrl = baseUrl, topic = topic, + instant = instant, notifications = 0, lastActive = Date().time/1000 ) viewModel.add(subscription) - // Subscribe to Firebase topic - FirebaseMessaging - .getInstance() - .subscribeToTopic(topic) - .addOnCompleteListener { - Log.d(TAG, "Subscribing to topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}") - } - .addOnFailureListener { - Log.e(TAG, "Subscribing to topic failed: $it") - } + // Subscribe to Firebase topic (instant subscriptions are triggered in observe()) + if (!instant) { + Log.d(TAG, "Subscribing to Firebase") + FirebaseMessaging + .getInstance() + .subscribeToTopic(topic) + .addOnCompleteListener { + Log.d(TAG, "Subscribing to topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}") + } + .addOnFailureListener { + Log.e(TAG, "Subscribing to topic failed: $it") + } + } // Fetch cached messages lifecycleScope.launch(Dispatchers.IO) { @@ -222,6 +239,35 @@ 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") @@ -229,6 +275,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { intent.putExtra(EXTRA_SUBSCRIPTION_ID, subscription.id) intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) + intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant) startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION) } @@ -236,10 +283,17 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { if (requestCode == REQUEST_CODE_DELETE_SUBSCRIPTION && resultCode == RESULT_OK) { val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0) val subscriptionTopic = data?.getStringExtra(EXTRA_SUBSCRIPTION_TOPIC) + val subscriptionInstant = data?.getBooleanExtra(EXTRA_SUBSCRIPTION_INSTANT, false) Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)") subscriptionId?.let { id -> viewModel.remove(id) } - subscriptionTopic?.let { topic -> FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) } // FIXME This only works for ntfy.sh + subscriptionInstant?.let { instant -> + if (!instant) { + Log.d(TAG, "Unsubscribing from Firebase") + subscriptionTopic?.let { topic -> FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) } + } + // Subscriber service changes are triggered in the observe() call above + } } else { super.onActivityResult(requestCode, resultCode, data) } @@ -360,6 +414,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl" const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic" + const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant" const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1 const val ANIMATION_DURATION = 80L const val SHARED_PREFS_ID = "MainPreferences" 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 9825f02..234afca 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainViewModel.kt @@ -14,6 +14,10 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { return repository.getSubscriptionsLiveData() } + fun listIds(): LiveData> { + return repository.getSubscriptionIdsLiveData() + } + fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) { repository.addSubscription(subscription) } diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt index 62408be..0170e15 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -2,11 +2,9 @@ package io.heckel.ntfy.work import android.content.Context import android.util.Log -import android.widget.Toast import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import io.heckel.ntfy.BuildConfig -import io.heckel.ntfy.R import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Repository import io.heckel.ntfy.msg.ApiService diff --git a/app/src/main/res/layout/add_dialog_fragment.xml b/app/src/main/res/layout/add_dialog_fragment.xml index 22a9e3d..b196dac 100644 --- a/app/src/main/res/layout/add_dialog_fragment.xml +++ b/app/src/main/res/layout/add_dialog_fragment.xml @@ -16,23 +16,42 @@ android:textAlignment="viewStart" android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"/> - + + android:layout_height="wrap_content" android:id="@+id/add_dialog_use_another_server_checkbox" + android:layout_marginTop="-5dp" android:layout_marginBottom="-5dp" android:layout_marginStart="-3dp"/> + + android:hint="@string/app_base_url" android:inputType="textUri" android:maxLines="1" + android:layout_marginTop="-2dp" android:layout_marginBottom="5dp"/> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19cdc90..206f245 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,9 +3,11 @@ Ntfy https://ntfy.sh - - Ntfy - ntfy + + Notifications + Subscription Service + Subscribed topics + Listening patiently for incoming notifications %1$d notification(s) received @@ -38,6 +40,14 @@ Topics are not password-protected, so choose a name that\'s not easy to guess. Once subscribed, you can PUT/POST to receive notifications on your phone. Topic name, e.g. phils_alerts Use another server + + 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) + + Requires foreground service and consumes more battery, but delivers notifications faster. + Cancel Subscribe