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
|
# 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.
|
|
||||||
|
|
|
@ -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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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 " +
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,11 +39,13 @@ 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)
|
||||||
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
|
if (added) {
|
||||||
notifier.send(subscription, message)
|
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.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,9 +138,11 @@ 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) {
|
||||||
Toast
|
runOnUiThread {
|
||||||
.makeText(this@DetailActivity, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG)
|
Toast
|
||||||
.show()
|
.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) }
|
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) {
|
||||||
Toast
|
runOnUiThread {
|
||||||
.makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG)
|
Toast
|
||||||
.show()
|
.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
|
// 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()
|
||||||
|
|
||||||
|
|
|
@ -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,21 +161,25 @@ 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())
|
||||||
FirebaseMessaging
|
if (!instant) {
|
||||||
.getInstance()
|
Log.d(TAG, "Subscribing to Firebase")
|
||||||
.subscribeToTopic(topic)
|
FirebaseMessaging
|
||||||
.addOnCompleteListener {
|
.getInstance()
|
||||||
Log.d(TAG, "Subscribing to topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}")
|
.subscribeToTopic(topic)
|
||||||
}
|
.addOnCompleteListener {
|
||||||
.addOnFailureListener {
|
Log.d(TAG, "Subscribing to topic complete: result=${it.result}, exception=${it.exception}, successful=${it.isSuccessful}")
|
||||||
Log.e(TAG, "Subscribing to topic failed: $it")
|
}
|
||||||
}
|
.addOnFailureListener {
|
||||||
|
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"
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue