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
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/)

View file

@ -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')"
]
}
}

View file

@ -1,9 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
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
android:name=".app.Application"
android:allowBackup="true"
@ -11,7 +23,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<!-- Main activity -->
<activity
@ -33,6 +46,9 @@
android:value=".ui.MainActivity" />
</activity>
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".msg.SubscriberService" />
<!-- Firebase messaging -->
<service
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
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean,
@Ignore val notifications: Int,
@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(
val id: Long,
val baseUrl: String,
val topic: String,
val instant: Boolean,
val notifications: Int,
val lastActive: Long
)
@ -60,7 +62,7 @@ abstract class Database : RoomDatabase() {
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// 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("DROP TABLE Subscription")
db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription")
@ -76,7 +78,7 @@ abstract class Database : RoomDatabase() {
@Dao
interface SubscriptionDao {
@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 " +
@ -85,7 +87,7 @@ interface SubscriptionDao {
fun listFlow(): Flow<List<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 " +
"GROUP BY s.id " +
@ -94,7 +96,7 @@ interface SubscriptionDao {
fun list(): List<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.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 " +

View file

@ -19,6 +19,13 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
.map { list -> toSubscriptionList(list) }
}
fun getSubscriptionIdsLiveData(): LiveData<Set<Long>> {
return subscriptionDao
.listFlow()
.asLiveData()
.map { list -> list.map { it.id }.toSet() }
}
fun getSubscriptions(): List<Subscription> {
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
)

View file

@ -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)

View file

@ -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"
}
}

View file

@ -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,13 +39,15 @@ 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
// 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)
}
}
}
override fun onNewToken(token: String) {
// Called if the FCM registration token is updated

View file

@ -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"
}
}

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.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()
}
}

View file

@ -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<DetailViewModel> {
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,12 +138,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
val message = getString(R.string.detail_test_message, Date().toString())
api.publish(subscriptionBaseUrl, subscriptionTopic, message)
} catch (e: Exception) {
runOnUiThread {
Toast
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG)
.show()
}
}
}
}
private fun onCopyUrlClick() {
val url = topicUrl(subscriptionBaseUrl, subscriptionTopic)
@ -168,12 +174,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
newNotifications.forEach { notification -> repository.addNotification(notification) }
runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() }
} catch (e: Exception) {
runOnUiThread {
Toast
.makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG)
.show()
}
}
}
}
private fun onDeleteClick() {
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
@ -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()

View file

@ -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,12 +161,15 @@ 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
// Subscribe to Firebase topic (instant subscriptions are triggered in observe())
if (!instant) {
Log.d(TAG, "Subscribing to Firebase")
FirebaseMessaging
.getInstance()
.subscribeToTopic(topic)
@ -163,6 +179,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
.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"

View file

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

View file

@ -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

View file

@ -16,23 +16,42 @@
android:textAlignment="viewStart"
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
android:text="@string/add_dialog_description_below"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_description_below"
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
android:text="@string/add_dialog_use_another_server"
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
android:id="@+id/add_dialog_base_url_text"
android:layout_width="match_parent"
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>

View file

@ -3,9 +3,11 @@
<string name="app_name">Ntfy</string>
<string name="app_base_url">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
<!-- Notifications -->
<string name="notification_channel_name">Ntfy</string>
<string name="notification_channel_id">ntfy</string>
<!-- Notification channels -->
<string name="channel_notifications_name">Notifications</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 -->
<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_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_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_subscribe">Subscribe</string>