Instant delivery

This commit is contained in:
Philipp Heckel 2021-11-13 19:26:37 -05:00
parent 95a296c556
commit 719a04aeaa
17 changed files with 529 additions and 89 deletions

View file

@ -1,9 +1,6 @@
# ntfy Android App # ntfy Android App
This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)). 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).
## 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.
## License ## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](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) * [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) * [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) * [Designing a logo with Inkscape](https://www.youtube.com/watch?v=r2Kv61cd2P4)
* [Foreground service](https://robertohuertas.com/2019/06/29/android_foreground_services/)
Thanks to these projects for allowing me to copy-paste a lot.

View file

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 2, "version": 2,
"identityHash": "30177aa8688290d24499babf22b15720", "identityHash": "df0a0eab3fc3056bf12e04a09c084660",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -25,6 +25,12 @@
"columnName": "topic", "columnName": "topic",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
},
{
"fieldPath": "instant",
"columnName": "instant",
"affinity": "INTEGER",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@ -94,7 +100,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }

View file

@ -1,9 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.heckel.ntfy"> package="io.heckel.ntfy">
<!-- Permissions --> <!--
<uses-permission android:name="android.permission.INTERNET"/> Permissions
- FOREGROUND_SERVICE is needed to support "use another server" feature
- WAKE_LOCK & RECEIVE_BOOT_COMPLETED are required to restart the foreground service
if it is stopped; see https://robertohuertas.com/2019/06/29/android_foreground_services/
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!--
Application
- usesCleartextTraffic is required to support "use another server" feature
-->
<application <application
android:name=".app.Application" android:name=".app.Application"
android:allowBackup="true" android:allowBackup="true"
@ -11,7 +23,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<!-- Main activity --> <!-- Main activity -->
<activity <activity
@ -33,6 +46,9 @@
android:value=".ui.MainActivity" /> android:value=".ui.MainActivity" />
</activity> </activity>
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".msg.SubscriberService" />
<!-- Firebase messaging --> <!-- Firebase messaging -->
<service <service
android:name=".msg.FirebaseService" android:name=".msg.FirebaseService"

View file

@ -13,16 +13,18 @@ data class Subscription(
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities @PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean,
@Ignore val notifications: Int, @Ignore val notifications: Int,
@Ignore val lastActive: Long = 0 // Unix timestamp @Ignore val lastActive: Long = 0 // Unix timestamp
) { ) {
constructor(id: Long, baseUrl: String, topic: String) : this(id, baseUrl, topic, 0, 0) constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0)
} }
data class SubscriptionWithMetadata( data class SubscriptionWithMetadata(
val id: Long, val id: Long,
val baseUrl: String, val baseUrl: String,
val topic: String, val topic: String,
val instant: Boolean,
val notifications: Int, val notifications: Int,
val lastActive: Long val lastActive: Long
) )
@ -60,7 +62,7 @@ abstract class Database : RoomDatabase() {
private val MIGRATION_1_2 = object : Migration(1, 2) { private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
// Drop "notifications" & "lastActive" columns (SQLite does not support dropping columns, ...) // Drop "notifications" & "lastActive" columns (SQLite does not support dropping columns, ...)
db.execSQL("CREATE TABLE Subscription_New (id INTEGER NOT NULL, baseUrl TEXT NOT NULL, topic TEXT NOT NULL, PRIMARY KEY(id))") db.execSQL("CREATE TABLE Subscription_New (id INTEGER NOT NULL, baseUrl TEXT NOT NULL, topic TEXT NOT NULL, instant INTEGER NOT NULL DEFAULT('0'), PRIMARY KEY(id))")
db.execSQL("INSERT INTO Subscription_New SELECT id, baseUrl, topic FROM Subscription") db.execSQL("INSERT INTO Subscription_New SELECT id, baseUrl, topic FROM Subscription")
db.execSQL("DROP TABLE Subscription") db.execSQL("DROP TABLE Subscription")
db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription") db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription")
@ -76,7 +78,7 @@ abstract class Database : RoomDatabase() {
@Dao @Dao
interface SubscriptionDao { interface SubscriptionDao {
@Query( @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 " + "FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + "LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"GROUP BY s.id " + "GROUP BY s.id " +
@ -85,7 +87,7 @@ interface SubscriptionDao {
fun listFlow(): Flow<List<SubscriptionWithMetadata>> fun listFlow(): Flow<List<SubscriptionWithMetadata>>
@Query( @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 " + "FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + "LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"GROUP BY s.id " + "GROUP BY s.id " +
@ -94,7 +96,7 @@ interface SubscriptionDao {
fun list(): List<SubscriptionWithMetadata> fun list(): List<SubscriptionWithMetadata>
@Query( @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 " + "FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + "LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"WHERE s.baseUrl = :baseUrl AND s.topic = :topic " + "WHERE s.baseUrl = :baseUrl AND s.topic = :topic " +
@ -103,7 +105,7 @@ interface SubscriptionDao {
fun get(baseUrl: String, topic: String): SubscriptionWithMetadata? fun get(baseUrl: String, topic: String): SubscriptionWithMetadata?
@Query( @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 " + "FROM subscription AS s " +
"LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " + "LEFT JOIN notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 " +
"WHERE s.id = :subscriptionId " + "WHERE s.id = :subscriptionId " +

View file

@ -19,6 +19,13 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
.map { list -> toSubscriptionList(list) } .map { list -> toSubscriptionList(list) }
} }
fun getSubscriptionIdsLiveData(): LiveData<Set<Long>> {
return subscriptionDao
.listFlow()
.asLiveData()
.map { list -> list.map { it.id }.toSet() }
}
fun getSubscriptions(): List<Subscription> { fun getSubscriptions(): List<Subscription> {
return toSubscriptionList(subscriptionDao.list()) return toSubscriptionList(subscriptionDao.list())
} }
@ -52,11 +59,13 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun addNotification(notification: Notification) { suspend fun addNotification(notification: Notification): Boolean {
val maybeExistingNotification = notificationDao.get(notification.id) val maybeExistingNotification = notificationDao.get(notification.id)
if (maybeExistingNotification == null) { if (maybeExistingNotification == null) {
notificationDao.add(notification) notificationDao.add(notification)
return true
} }
return false
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@ -77,6 +86,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
id = s.id, id = s.id,
baseUrl = s.baseUrl, baseUrl = s.baseUrl,
topic = s.topic, topic = s.topic,
instant = s.instant,
lastActive = s.lastActive, lastActive = s.lastActive,
notifications = s.notifications notifications = s.notifications
) )
@ -91,6 +101,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
id = s.id, id = s.id,
baseUrl = s.baseUrl, baseUrl = s.baseUrl,
topic = s.topic, topic = s.topic,
instant = s.instant,
lastActive = s.lastActive, lastActive = s.lastActive,
notifications = s.notifications notifications = s.notifications
) )

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.data package io.heckel.ntfy.data
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" 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 topicUrlJsonPoll(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/json?poll=1"
fun topicShortUrl(baseUrl: String, topic: String) = fun topicShortUrl(baseUrl: String, topic: String) =
topicUrl(baseUrl, topic) topicUrl(baseUrl, topic)

View file

@ -4,19 +4,24 @@ import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicUrl import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.data.topicUrlJson
import io.heckel.ntfy.data.topicUrlJsonPoll import io.heckel.ntfy.data.topicUrlJsonPoll
import okhttp3.OkHttpClient import okhttp3.*
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ApiService { class ApiService {
private val gson = Gson() private val gson = Gson()
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.callTimeout(10, TimeUnit.SECONDS) // Total timeout for entire request .callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, 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() .build()
fun publish(baseUrl: String, topic: String, message: String) { 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 { 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) return Notification(n.id, subscriptionId, n.time, n.message, false)
} }
private data class NotificationData( private data class Message(
val id: String, val id: String,
val time: Long, val time: Long,
val event: String,
val message: String val message: String
) )
companion object { companion object {
private const val TAG = "NtfyApiService" private const val TAG = "NtfyApiService"
private const val EVENT_MESSAGE = "message"
} }
} }

View file

@ -9,7 +9,6 @@ import io.heckel.ntfy.data.Notification
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
class FirebaseService : FirebaseMessagingService() { class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
@ -40,13 +39,15 @@ class FirebaseService : FirebaseMessagingService() {
// Add notification // Add notification
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false) val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false)
repository.addNotification(notification) val added = repository.addNotification(notification)
// Send notification // Send notification (only if it's not already known)
if (added) {
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
notifier.send(subscription, message) notifier.send(subscription, message)
} }
} }
}
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
// Called if the FCM registration token is updated // Called if the FCM registration token is updated

View file

@ -10,23 +10,17 @@ import android.media.RingtoneManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat 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.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.ui.DetailActivity import io.heckel.ntfy.ui.DetailActivity
import io.heckel.ntfy.ui.MainActivity 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 import kotlin.random.Random
class NotificationService(val context: Context) { class NotificationService(val context: Context) {
fun send(subscription: Subscription, message: String) { fun send(subscription: Subscription, message: String) {
val title = topicShortUrl(subscription.baseUrl, subscription.topic) 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 // Create an Intent for the activity you want to start
val intent = Intent(context, DetailActivity::class.java) 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 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 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) .setSmallIcon(R.drawable.ic_notification_icon)
.setContentTitle(title) .setContentTitle(title)
.setContentText(message) .setContentText(message)
@ -50,8 +43,8 @@ class NotificationService(val context: Context) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelName = context.getString(R.string.notification_channel_name) val channelName = context.getString(R.string.channel_notifications_name) // Show's up in UI
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT) val channel = NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
notificationManager.notify(Random.nextInt(), notificationBuilder.build()) notificationManager.notify(Random.nextInt(), notificationBuilder.build())
@ -59,5 +52,6 @@ class NotificationService(val context: Context) {
companion object { companion object {
private const val TAG = "NtfyNotificationService" private const val TAG = "NtfyNotificationService"
private const val CHANNEL_ID = "ntfy"
} }
} }

View file

@ -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<Long, Job>() // Subscription ID -> Job
private val calls = ConcurrentHashMap<Long, Call>() // 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!!)
}
}
}

View file

@ -20,10 +20,13 @@ import io.heckel.ntfy.data.Repository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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 topicNameText: TextInputEditText
private lateinit var baseUrlText: TextInputEditText private lateinit var baseUrlText: TextInputEditText
private lateinit var useAnotherServerCheckbox: CheckBox 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 private lateinit var subscribeButton: Button
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 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) val view = requireActivity().layoutInflater.inflate(R.layout.add_dialog_fragment, null)
topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText
baseUrlText = view.findViewById(R.id.add_dialog_base_url_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 useAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) as CheckBox
useAnotherServerDescription = view.findViewById(R.id.add_dialog_use_another_server_description)
// FIXME For now, other servers are disabled
useAnotherServerCheckbox.visibility = View.GONE
// Build dialog // Build dialog
val alert = AlertDialog.Builder(it) 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) { _, _ -> .setPositiveButton(R.string.add_dialog_button_subscribe) { _, _ ->
val topic = topicNameText.text.toString() val topic = topicNameText.text.toString()
val baseUrl = getBaseUrl() 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) { _, _ -> .setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
dialog?.cancel() dialog?.cancel()
@ -70,9 +74,23 @@ class AddFragment(private val viewModel: SubscriptionsViewModel, private val onS
} }
topicNameText.addTextChangedListener(textWatcher) topicNameText.addTextChangedListener(textWatcher)
baseUrlText.addTextChangedListener(textWatcher) baseUrlText.addTextChangedListener(textWatcher)
instantDeliveryCheckbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) instantDeliveryDescription.visibility = View.VISIBLE
else instantDeliveryDescription.visibility = View.GONE
}
useAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked -> useAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) baseUrlText.visibility = View.VISIBLE if (isChecked) {
else baseUrlText.visibility = View.GONE 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() validateInput()
} }
} }

View file

@ -29,6 +29,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
// TODO dismiss notifications when navigating to detail page
class DetailActivity : AppCompatActivity(), ActionMode.Callback { class DetailActivity : AppCompatActivity(), ActionMode.Callback {
private val viewModel by viewModels<DetailViewModel> { private val viewModel by viewModels<DetailViewModel> {
DetailViewModelFactory((application as Application).repository) DetailViewModelFactory((application as Application).repository)
@ -40,6 +42,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
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()
// Action mode stuff // Action mode stuff
private lateinit var mainList: RecyclerView private lateinit var mainList: RecyclerView
@ -59,6 +62,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0) subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0)
subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return
subscriptionInstant = intent.getBooleanExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, false)
// Set title // Set title
val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
@ -134,12 +138,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
val message = getString(R.string.detail_test_message, Date().toString()) val message = getString(R.string.detail_test_message, Date().toString())
api.publish(subscriptionBaseUrl, subscriptionTopic, message) api.publish(subscriptionBaseUrl, subscriptionTopic, message)
} catch (e: Exception) { } catch (e: Exception) {
runOnUiThread {
Toast Toast
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG) .makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG)
.show() .show()
} }
} }
} }
}
private fun onCopyUrlClick() { private fun onCopyUrlClick() {
val url = topicUrl(subscriptionBaseUrl, subscriptionTopic) val url = topicUrl(subscriptionBaseUrl, subscriptionTopic)
@ -168,12 +174,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
newNotifications.forEach { notification -> repository.addNotification(notification) } newNotifications.forEach { notification -> repository.addNotification(notification) }
runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() } runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() }
} catch (e: Exception) { } catch (e: Exception) {
runOnUiThread {
Toast Toast
.makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG) .makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG)
.show() .show()
} }
} }
} }
}
private fun onDeleteClick() { private fun onDeleteClick() {
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
@ -185,7 +193,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
// Return to main activity // Return to main activity
val result = Intent() val result = Intent()
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscriptionId) .putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscriptionId)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscriptionBaseUrl)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic) .putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscriptionInstant)
setResult(RESULT_OK, result) setResult(RESULT_OK, result)
finish() finish()

View file

@ -6,6 +6,7 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.ActionMode import android.view.ActionMode
@ -26,6 +27,10 @@ import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService 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 io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -88,14 +93,23 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
} }
} }
// Kick off periodic polling viewModel.listIds().observe(this) {
val sharedPref = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) maybeStartOrStopSubscriberService()
val workPolicy = if (sharedPref.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) { }
// 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") Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy")
ExistingPeriodicWorkPolicy.KEEP ExistingPeriodicWorkPolicy.KEEP
} else { } else {
Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy") 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) .putInt(SHARED_PREFS_POLL_WORKER_VERSION, PollWorker.VERSION)
.apply() .apply()
ExistingPeriodicWorkPolicy.REPLACE ExistingPeriodicWorkPolicy.REPLACE
@ -135,12 +149,11 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
} }
private fun onSubscribeButtonClick() { 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") newFragment.show(supportFragmentManager, "AddFragment")
} }
private fun onSubscribe(topic: String, baseUrl: String) { private fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) {
// FIXME ignores baseUrl
Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}") Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}")
// Add subscription to database // Add subscription to database
@ -148,12 +161,15 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
id = Random.nextLong(), id = Random.nextLong(),
baseUrl = baseUrl, baseUrl = baseUrl,
topic = topic, topic = topic,
instant = instant,
notifications = 0, notifications = 0,
lastActive = Date().time/1000 lastActive = Date().time/1000
) )
viewModel.add(subscription) viewModel.add(subscription)
// Subscribe to Firebase topic // Subscribe to Firebase topic (instant subscriptions are triggered in observe())
if (!instant) {
Log.d(TAG, "Subscribing to Firebase")
FirebaseMessaging FirebaseMessaging
.getInstance() .getInstance()
.subscribeToTopic(topic) .subscribeToTopic(topic)
@ -163,6 +179,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
.addOnFailureListener { .addOnFailureListener {
Log.e(TAG, "Subscribing to topic failed: $it") Log.e(TAG, "Subscribing to topic failed: $it")
} }
}
// Fetch cached messages // Fetch cached messages
lifecycleScope.launch(Dispatchers.IO) { 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) { private fun startDetailView(subscription: Subscription) {
Log.d(TAG, "Entering detail view for 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_ID, subscription.id)
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION) startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION)
} }
@ -236,10 +283,17 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
if (requestCode == REQUEST_CODE_DELETE_SUBSCRIPTION && resultCode == RESULT_OK) { if (requestCode == REQUEST_CODE_DELETE_SUBSCRIPTION && resultCode == RESULT_OK) {
val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0) val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0)
val subscriptionTopic = data?.getStringExtra(EXTRA_SUBSCRIPTION_TOPIC) 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)") Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)")
subscriptionId?.let { id -> viewModel.remove(id) } 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 { } else {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
} }
@ -360,6 +414,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" const val EXTRA_SUBSCRIPTION_ID = "subscriptionId"
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl" const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic" const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant"
const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1 const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1
const val ANIMATION_DURATION = 80L const val ANIMATION_DURATION = 80L
const val SHARED_PREFS_ID = "MainPreferences" const val SHARED_PREFS_ID = "MainPreferences"

View file

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

View file

@ -2,11 +2,9 @@ package io.heckel.ntfy.work
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService

View file

@ -16,23 +16,42 @@
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"/> android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_dialog_topic_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:hint="@string/add_dialog_topic_name_hint"
android:maxLines="1" android:inputType="text"/>
<TextView <TextView
android:text="@string/add_dialog_description_below" android:text="@string/add_dialog_description_below"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_description_below" android:layout_height="wrap_content" android:id="@+id/add_dialog_description_below"
android:paddingStart="4dp" android:paddingTop="3dp"/> android:paddingStart="4dp" android:paddingTop="3dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_dialog_topic_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:hint="@string/add_dialog_topic_name_hint"
android:maxLines="1" android:inputType="text" android:maxLength="64"/>
<CheckBox <CheckBox
android:text="@string/add_dialog_use_another_server" android:text="@string/add_dialog_use_another_server"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_use_another_server_checkbox"/> 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"/>
<TextView
android:text="@string/add_dialog_use_another_server_description"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_use_another_server_description"
android:paddingStart="4dp" android:paddingTop="0dp" android:layout_marginTop="-5dp"
android:visibility="gone"/>
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_dialog_base_url_text" android:id="@+id/add_dialog_base_url_text"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:visibility="gone" android:layout_height="wrap_content" android:visibility="gone"
android:hint="@string/app_base_url" android:inputType="textUri" android:maxLines="1"/> android:hint="@string/app_base_url" android:inputType="textUri" android:maxLines="1"
android:layout_marginTop="-2dp" android:layout_marginBottom="5dp"/>
<CheckBox
android:text="@string/add_dialog_instant_delivery"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_checkbox"
android:layout_marginTop="-8dp" android:layout_marginBottom="-5dp" android:layout_marginStart="-3dp"/>
<TextView
android:text="@string/add_dialog_instant_delivery_description"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_description"
android:paddingStart="4dp" android:paddingTop="0dp" android:layout_marginTop="-5dp"
android:visibility="gone"/>
</LinearLayout> </LinearLayout>

View file

@ -3,9 +3,11 @@
<string name="app_name">Ntfy</string> <string name="app_name">Ntfy</string>
<string name="app_base_url">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! --> <string name="app_base_url">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
<!-- Notifications --> <!-- Notification channels -->
<string name="notification_channel_name">Ntfy</string> <string name="channel_notifications_name">Notifications</string>
<string name="notification_channel_id">ntfy</string> <string name="channel_subscriber_service_name">Subscription Service</string>
<string name="channel_subscriber_notification_title">Subscribed topics</string>
<string name="channel_subscriber_notification_text">Listening patiently 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>
@ -38,6 +40,14 @@
<string name="add_dialog_description_below">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.</string> <string name="add_dialog_description_below">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.</string>
<string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string> <string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string>
<string name="add_dialog_use_another_server">Use another server</string> <string name="add_dialog_use_another_server">Use another server</string>
<string name="add_dialog_use_another_server_description">
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.
</string>
<string name="add_dialog_instant_delivery">Instant delivery (even in doze mode)</string>
<string name="add_dialog_instant_delivery_description">
Requires foreground service and consumes more battery, but delivers notifications faster.
</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>