Instant delivery
This commit is contained in:
parent
95a296c556
commit
719a04aeaa
17 changed files with 529 additions and 89 deletions
10
README.md
10
README.md
|
@ -1,9 +1,6 @@
|
|||
# ntfy Android App
|
||||
This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)).
|
||||
|
||||
## Current limitations
|
||||
* The app on the Play store only works with ntfy.sh, not with other hosts, due to the fact that background services in
|
||||
Android are pretty much impossible to implement.
|
||||
This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)). It is available
|
||||
in the [Play Store](https://play.google.com/store/apps/details?id=io.heckel.ntfy).
|
||||
|
||||
## License
|
||||
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE).
|
||||
|
@ -14,5 +11,4 @@ Thank you to these fantastic resources:
|
|||
* [Android Room with a View](https://github.com/googlecodelabs/android-room-with-a-view/tree/kotlin) (Apache 2.0)
|
||||
* [Firebase Messaging Example](https://github.com/firebase/quickstart-android/blob/7147f60451b3eeaaa05fc31208ffb67e2df73c3c/messaging/app/src/main/java/com/google/firebase/quickstart/fcm/kotlin/MyFirebaseMessagingService.kt) (Apache 2.0)
|
||||
* [Designing a logo with Inkscape](https://www.youtube.com/watch?v=r2Kv61cd2P4)
|
||||
|
||||
Thanks to these projects for allowing me to copy-paste a lot.
|
||||
* [Foreground service](https://robertohuertas.com/2019/06/29/android_foreground_services/)
|
||||
|
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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 " +
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import io.heckel.ntfy.data.Notification
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class FirebaseService : FirebaseMessagingService() {
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
|
@ -40,11 +39,13 @@ class FirebaseService : FirebaseMessagingService() {
|
|||
// Add notification
|
||||
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
|
||||
val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false)
|
||||
repository.addNotification(notification)
|
||||
val added = repository.addNotification(notification)
|
||||
|
||||
// Send notification
|
||||
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
|
||||
notifier.send(subscription, message)
|
||||
// Send notification (only if it's not already known)
|
||||
if (added) {
|
||||
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
|
||||
notifier.send(subscription, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
259
app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
Normal file
259
app/src/main/java/io/heckel/ntfy/msg/SubscriberService.kt
Normal 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!!)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +138,11 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
val message = getString(R.string.detail_test_message, Date().toString())
|
||||
api.publish(subscriptionBaseUrl, subscriptionTopic, message)
|
||||
} catch (e: Exception) {
|
||||
Toast
|
||||
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
runOnUiThread {
|
||||
Toast
|
||||
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -168,9 +174,11 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
newNotifications.forEach { notification -> repository.addNotification(notification) }
|
||||
runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() }
|
||||
} catch (e: Exception) {
|
||||
Toast
|
||||
.makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
runOnUiThread {
|
||||
Toast
|
||||
.makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -185,7 +193,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
// Return to main activity
|
||||
val result = Intent()
|
||||
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscriptionId)
|
||||
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscriptionBaseUrl)
|
||||
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic)
|
||||
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscriptionInstant)
|
||||
setResult(RESULT_OK, result)
|
||||
finish()
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import android.app.AlertDialog
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.ActionMode
|
||||
|
@ -26,6 +27,10 @@ import io.heckel.ntfy.data.Subscription
|
|||
import io.heckel.ntfy.data.topicShortUrl
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.msg.SubscriberService
|
||||
import io.heckel.ntfy.msg.SubscriberService.ServiceState
|
||||
import io.heckel.ntfy.msg.SubscriberService.Actions
|
||||
import io.heckel.ntfy.msg.SubscriberService.Companion.readServiceState
|
||||
import io.heckel.ntfy.work.PollWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -88,14 +93,23 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
}
|
||||
}
|
||||
|
||||
// Kick off periodic polling
|
||||
val sharedPref = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
val workPolicy = if (sharedPref.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) {
|
||||
viewModel.listIds().observe(this) {
|
||||
maybeStartOrStopSubscriberService()
|
||||
}
|
||||
|
||||
// Background things
|
||||
startPeriodicWorker()
|
||||
maybeStartOrStopSubscriberService()
|
||||
}
|
||||
|
||||
private fun startPeriodicWorker() {
|
||||
val sharedPrefs = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
val workPolicy = if (sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) {
|
||||
Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
sharedPref.edit()
|
||||
sharedPrefs.edit()
|
||||
.putInt(SHARED_PREFS_POLL_WORKER_VERSION, PollWorker.VERSION)
|
||||
.apply()
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
|
@ -135,12 +149,11 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
}
|
||||
|
||||
private fun onSubscribeButtonClick() {
|
||||
val newFragment = AddFragment(viewModel) { topic, baseUrl -> onSubscribe(topic, baseUrl) }
|
||||
val newFragment = AddFragment(viewModel) { topic, baseUrl, instant -> onSubscribe(topic, baseUrl, instant) }
|
||||
newFragment.show(supportFragmentManager, "AddFragment")
|
||||
}
|
||||
|
||||
private fun onSubscribe(topic: String, baseUrl: String) {
|
||||
// FIXME ignores baseUrl
|
||||
private fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) {
|
||||
Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)}")
|
||||
|
||||
// Add subscription to database
|
||||
|
@ -148,21 +161,25 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
id = Random.nextLong(),
|
||||
baseUrl = baseUrl,
|
||||
topic = topic,
|
||||
instant = instant,
|
||||
notifications = 0,
|
||||
lastActive = Date().time/1000
|
||||
)
|
||||
viewModel.add(subscription)
|
||||
|
||||
// Subscribe to Firebase topic
|
||||
FirebaseMessaging
|
||||
.getInstance()
|
||||
.subscribeToTopic(topic)
|
||||
.addOnCompleteListener {
|
||||
Log.d(TAG, "Subscribing to topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}")
|
||||
}
|
||||
.addOnFailureListener {
|
||||
Log.e(TAG, "Subscribing to topic failed: $it")
|
||||
}
|
||||
// Subscribe to Firebase topic (instant subscriptions are triggered in observe())
|
||||
if (!instant) {
|
||||
Log.d(TAG, "Subscribing to Firebase")
|
||||
FirebaseMessaging
|
||||
.getInstance()
|
||||
.subscribeToTopic(topic)
|
||||
.addOnCompleteListener {
|
||||
Log.d(TAG, "Subscribing to topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}")
|
||||
}
|
||||
.addOnFailureListener {
|
||||
Log.e(TAG, "Subscribing to topic failed: $it")
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch cached messages
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
|
@ -222,6 +239,35 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
}
|
||||
}
|
||||
|
||||
private fun maybeStartOrStopSubscriberService() {
|
||||
Log.d(TAG, "Triggering subscriber service refresh")
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val instantSubscriptions = repository.getSubscriptions().filter { s -> s.instant }
|
||||
if (instantSubscriptions.isEmpty()) {
|
||||
performActionOnSubscriberService(Actions.STOP)
|
||||
} else {
|
||||
performActionOnSubscriberService(Actions.START)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performActionOnSubscriberService(action: Actions) {
|
||||
val serviceState = readServiceState(this)
|
||||
if (serviceState == ServiceState.STOPPED && action == Actions.STOP) {
|
||||
return
|
||||
}
|
||||
val intent = Intent(this, SubscriberService::class.java)
|
||||
intent.action = action.name
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Log.d(TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)")
|
||||
startForegroundService(intent)
|
||||
return
|
||||
} else {
|
||||
Log.d(TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)")
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDetailView(subscription: Subscription) {
|
||||
Log.d(TAG, "Entering detail view for subscription $subscription")
|
||||
|
||||
|
@ -229,6 +275,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
intent.putExtra(EXTRA_SUBSCRIPTION_ID, subscription.id)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
|
||||
startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
|
@ -236,10 +283,17 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
if (requestCode == REQUEST_CODE_DELETE_SUBSCRIPTION && resultCode == RESULT_OK) {
|
||||
val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0)
|
||||
val subscriptionTopic = data?.getStringExtra(EXTRA_SUBSCRIPTION_TOPIC)
|
||||
val subscriptionInstant = data?.getBooleanExtra(EXTRA_SUBSCRIPTION_INSTANT, false)
|
||||
Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)")
|
||||
|
||||
subscriptionId?.let { id -> viewModel.remove(id) }
|
||||
subscriptionTopic?.let { topic -> FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) } // FIXME This only works for ntfy.sh
|
||||
subscriptionInstant?.let { instant ->
|
||||
if (!instant) {
|
||||
Log.d(TAG, "Unsubscribing from Firebase")
|
||||
subscriptionTopic?.let { topic -> FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) }
|
||||
}
|
||||
// Subscriber service changes are triggered in the observe() call above
|
||||
}
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
@ -360,6 +414,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback {
|
|||
const val EXTRA_SUBSCRIPTION_ID = "subscriptionId"
|
||||
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
|
||||
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
|
||||
const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant"
|
||||
const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1
|
||||
const val ANIMATION_DURATION = 80L
|
||||
const val SHARED_PREFS_ID = "MainPreferences"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Reference in a new issue