Merge pull request #6 from binwiederhier/up

UnifiedPush
This commit is contained in:
Philipp C. Heckel 2022-01-01 17:09:47 +01:00 committed by GitHub
commit 91d13bdd13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1163 additions and 227 deletions

View file

@ -12,8 +12,8 @@ android {
minSdkVersion 21
targetSdkVersion 30
versionCode 12
versionName "1.4.2"
versionCode 13
versionName "1.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -67,6 +67,7 @@ dependencies {
// WorkManager
implementation "androidx.work:work-runtime-ktx:2.6.0"
implementation 'androidx.preference:preference:1.1.1'
// Room (SQLite)
def roomVersion = "2.3.0"

View file

@ -0,0 +1,158 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "306578182c2ad0f9803956beda094d28",
"entities": [
{
"tableName": "Subscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topic",
"columnName": "topic",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "instant",
"columnName": "instant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mutedUntil",
"columnName": "mutedUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "upAppId",
"columnName": "upAppId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "upConnectorToken",
"columnName": "upConnectorToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_Subscription_baseUrl_topic",
"unique": true,
"columnNames": [
"baseUrl",
"topic"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
},
{
"name": "index_Subscription_upConnectorToken",
"unique": true,
"columnNames": [
"upConnectorToken"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
}
],
"foreignKeys": []
},
{
"tableName": "Notification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscriptionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationId",
"columnName": "notificationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"subscriptionId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"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, '306578182c2ad0f9803956beda094d28')"
]
}
}

View file

@ -12,7 +12,7 @@
<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"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.VIBRATE"/>
<application
android:name=".app.Application"
@ -23,6 +23,7 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<!-- Main activity -->
<activity
android:name=".ui.MainActivity"
@ -43,27 +44,54 @@
android:value=".ui.MainActivity"/>
</activity>
<!-- Settings activity -->
<activity
android:name=".ui.SettingsActivity"
android:parentActivityName=".ui.MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity"/>
</activity>
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".msg.SubscriberService"/>
<service android:name=".service.SubscriberService"/>
<!-- Subscriber service restart on reboot -->
<receiver android:name=".msg.SubscriberService$BootStartReceiver" android:enabled="true">
<receiver
android:name=".service.SubscriberService$BootStartReceiver"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<!-- Subscriber service restart on destruction -->
<receiver android:name=".msg.SubscriberService$AutoRestartReceiver" android:enabled="true"
android:exported="false"/>
<receiver
android:name=".service.SubscriberService$AutoRestartReceiver"
android:enabled="true"
android:exported="false"/>
<!-- Broadcast receiver to send messages via intents -->
<receiver android:name=".msg.BroadcastService$BroadcastReceiver" android:enabled="true" android:exported="true">
<receiver
android:name=".msg.BroadcastService$BroadcastReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="io.heckel.ntfy.SEND_MESSAGE"/>
</intent-filter>
</receiver>
<!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md -->
<receiver
android:name=".up.BroadcastReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="org.unifiedpush.android.distributor.REGISTER"/>
<action android:name="org.unifiedpush.android.distributor.UNREGISTER"/>
</intent-filter>
</receiver>
<!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
<service
android:name=".firebase.FirebaseService"
@ -72,7 +100,6 @@
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false"/>
@ -80,5 +107,4 @@
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification"/>
</application>
</manifest>

View file

@ -6,20 +6,22 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)])
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
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,
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule
@ColumnInfo(name = "upAppId") val upAppId: String?,
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?,
@Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long) :
this(id, baseUrl, topic, instant, mutedUntil, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, upAppId: String, upConnectorToken: String) :
this(id, baseUrl, topic, instant, mutedUntil, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
}
enum class ConnectionState {
@ -32,6 +34,8 @@ data class SubscriptionWithMetadata(
val topic: String,
val instant: Boolean,
val mutedUntil: Long,
val upAppId: String?,
val upConnectorToken: String?,
val totalCount: Int,
val newCount: Int,
val lastActive: Long
@ -50,7 +54,7 @@ data class Notification(
@ColumnInfo(name = "deleted") val deleted: Boolean,
)
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 4)
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 5)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
@ -66,6 +70,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigration()
.build()
this.instance = instance
@ -102,6 +107,14 @@ abstract class Database : RoomDatabase() {
db.execSQL("ALTER TABLE Notification_New RENAME TO Notification")
}
}
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT")
db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT")
db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)")
}
}
}
}
@ -109,33 +122,33 @@ abstract class Database : RoomDatabase() {
interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
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
ORDER BY MAX(n.timestamp) DESC
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
""")
fun listFlow(): Flow<List<SubscriptionWithMetadata>>
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
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
ORDER BY MAX(n.timestamp) DESC
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
""")
fun list(): List<SubscriptionWithMetadata>
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -148,17 +161,30 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
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
WHERE s.id = :subscriptionId
GROUP BY s.id
""")
fun get(subscriptionId: Long): SubscriptionWithMetadata?
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
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.upConnectorToken = :connectorToken
GROUP BY s.id
""")
fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata?
@Insert
fun add(subscription: Subscription)

View file

@ -36,6 +36,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
return toSubscriptionList(subscriptionDao.list())
}
fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
return subscriptionDao
.list()
.map { Pair(it.id, it.instant) }.toSet()
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun getSubscription(subscriptionId: Long): Subscription? {
@ -48,6 +54,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
return toSubscription(subscriptionDao.get(baseUrl, topic))
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun getSubscriptionByConnectorToken(connectorToken: String): Subscription? {
return toSubscription(subscriptionDao.getByConnectorToken(connectorToken))
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun addSubscription(subscription: Subscription) {
@ -85,16 +97,13 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun addNotification(notification: Notification): NotificationAddResult {
suspend fun addNotification(notification: Notification): Boolean {
val maybeExistingNotification = notificationDao.get(notification.id)
if (maybeExistingNotification == null) {
notificationDao.add(notification)
val detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId
val muted = isMuted(notification.subscriptionId)
val notify = !detailsVisible && !muted
return NotificationAddResult(notify = notify, broadcast = true, muted = muted)
if (maybeExistingNotification != null) {
return false
}
return NotificationAddResult(notify = false, broadcast = false, muted = false)
notificationDao.add(notification)
return true
}
@Suppress("RedundantSuspendModifier")
@ -133,15 +142,60 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
.apply()
}
private suspend fun isMuted(subscriptionId: Long): Boolean {
if (isGlobalMuted()) {
return true
fun setMinPriority(minPriority: Int) {
if (minPriority <= 1) {
sharedPrefs.edit()
.remove(SHARED_PREFS_MIN_PRIORITY)
.apply()
} else {
sharedPrefs.edit()
.putInt(SHARED_PREFS_MIN_PRIORITY, minPriority)
.apply()
}
val s = getSubscription(subscriptionId) ?: return true
return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000)
}
private fun isGlobalMuted(): Boolean {
fun getMinPriority(): Int {
return sharedPrefs.getInt(SHARED_PREFS_MIN_PRIORITY, 1) // 1/low means all priorities
}
fun getBroadcastEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default
}
fun setBroadcastEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled)
.apply()
}
fun getUnifiedPushEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default
}
fun setUnifiedPushEnabled(enabled: Boolean) {
sharedPrefs.edit()
.putBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, enabled)
.apply()
}
fun getUnifiedPushBaseUrl(): String? {
return sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null)
}
fun setUnifiedPushBaseUrl(baseUrl: String) {
if (baseUrl == "") {
sharedPrefs
.edit()
.remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL)
.apply()
} else {
sharedPrefs.edit()
.putString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, baseUrl)
.apply()
}
}
fun isGlobalMuted(): Boolean {
val mutedUntil = getGlobalMutedUntil()
return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000)
}
@ -177,6 +231,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
topic = s.topic,
instant = s.instant,
mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
@ -195,6 +251,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
topic = s.topic,
instant = s.instant,
mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
@ -224,17 +282,15 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
}
data class NotificationAddResult(
val notify: Boolean,
val broadcast: Boolean,
val muted: Boolean,
)
companion object {
const val SHARED_PREFS_ID = "MainPreferences"
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
const val SHARED_PREFS_MIN_PRIORITY = "MinPriority"
const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL"
private const val TAG = "NtfyRepository"
private var instance: Repository? = null

View file

@ -13,8 +13,8 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
/**
* The broadcast service is responsible for sending and receiving broadcasted intents
* in order to facilitate taks app integrations.
* The broadcast service is responsible for sending and receiving broadcast intents
* in order to facilitate tasks app integrations.
*/
class BroadcastService(private val ctx: Context) {
fun send(subscription: Subscription, notification: Notification, muted: Boolean) {
@ -36,6 +36,10 @@ class BroadcastService(private val ctx: Context) {
ctx.sendBroadcast(intent)
}
/**
* This receiver is triggered when the SEND_MESSAGE intent is received.
* See AndroidManifest.xml for details.
*/
class BroadcastReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Broadcast received: $intent")
@ -46,24 +50,20 @@ class BroadcastService(private val ctx: Context) {
private fun send(ctx: Context, intent: Intent) {
val api = ApiService()
val baseUrl = intent.getStringExtra("base_url") ?: ctx.getString(R.string.app_base_url)
val topic = intent.getStringExtra("topic") ?: return
val message = intent.getStringExtra("message") ?: return
val title = intent.getStringExtra("title") ?: ""
val tags = intent.getStringExtra("tags") ?: ""
val priority = if (intent.getStringExtra("priority") != null) {
when (intent.getStringExtra("priority")) {
"min", "1" -> 1
"low", "2" -> 2
"default", "3" -> 3
"high", "4" -> 4
"urgent", "max", "5" -> 5
else -> 0
}
} else {
intent.getIntExtra("priority", 0)
val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url)
val topic = getStringExtra(intent, "topic") ?: return
val message = getStringExtra(intent, "message") ?: return
val title = getStringExtra(intent, "title") ?: ""
val tags = getStringExtra(intent,"tags") ?: ""
val priority = when (getStringExtra(intent, "priority")) {
"min", "1" -> 1
"low", "2" -> 2
"default", "3" -> 3
"high", "4" -> 4
"urgent", "max", "5" -> 5
else -> 0
}
val delay = intent.getStringExtra("delay") ?: ""
val delay = getStringExtra(intent,"delay") ?: ""
GlobalScope.launch(Dispatchers.IO) {
api.publish(
baseUrl = baseUrl,
@ -76,11 +76,26 @@ class BroadcastService(private val ctx: Context) {
)
}
}
/**
* Gets an extra as a String value, even if the extra may be an int or a long.
*/
private fun getStringExtra(intent: Intent, name: String): String? {
if (intent.getStringExtra(name) != null) {
return intent.getStringExtra(name)
} else if (intent.getIntExtra(name, DOES_NOT_EXIST) != DOES_NOT_EXIST) {
return intent.getIntExtra(name, DOES_NOT_EXIST).toString()
} else if (intent.getLongExtra(name, DOES_NOT_EXIST.toLong()) != DOES_NOT_EXIST.toLong()) {
return intent.getLongExtra(name, DOES_NOT_EXIST.toLong()).toString()
}
return null
}
}
companion object {
private const val TAG = "NtfyBroadcastService"
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE" // If changed, change in manifest too!
private const val DOES_NOT_EXIST = -2586000
}
}

View file

@ -0,0 +1,70 @@
package io.heckel.ntfy.msg
import android.content.Context
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.up.Distributor
import io.heckel.ntfy.util.safeLet
/**
* The notification dispatcher figures out what to do with a notification.
* It may display a notification, send out a broadcast, or forward via UnifiedPush.
*/
class NotificationDispatcher(val context: Context, val repository: Repository) {
private val notifier = NotificationService(context)
private val broadcaster = BroadcastService(context)
private val distributor = Distributor(context)
fun init() {
notifier.createNotificationChannels()
}
fun dispatch(subscription: Subscription, notification: Notification) {
val muted = getMuted(subscription)
val notify = shouldNotify(subscription, notification, muted)
val broadcast = shouldBroadcast(subscription)
val distribute = shouldDistribute(subscription)
if (notify) {
notifier.send(subscription, notification)
}
if (broadcast) {
broadcaster.send(subscription, notification, muted)
}
if (distribute) {
safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken ->
distributor.sendMessage(appId, connectorToken, notification.message)
}
}
}
private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean {
if (subscription.upAppId != null) {
return false
}
val priority = if (notification.priority > 0) notification.priority else 3
if (priority < repository.getMinPriority()) {
return false
}
val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId
return !detailsVisible && !muted
}
private fun shouldBroadcast(subscription: Subscription): Boolean {
if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions
return false
}
return repository.getBroadcastEnabled()
}
private fun shouldDistribute(subscription: Subscription): Boolean {
return subscription.upAppId != null // Only distribute for UnifiedPush subscriptions
}
private fun getMuted(subscription: Subscription): Boolean {
if (repository.isGlobalMuted()) {
return true
}
return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000)
}
}

View file

@ -1,9 +1,10 @@
package io.heckel.ntfy.msg
package io.heckel.ntfy.service
import android.util.Log
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.*
import okhttp3.Call

View file

@ -1,4 +1,4 @@
package io.heckel.ntfy.msg
package io.heckel.ntfy.service
import android.app.*
import android.content.BroadcastReceiver
@ -11,8 +11,6 @@ import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
@ -20,6 +18,8 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.ui.MainActivity
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.*
@ -58,10 +58,9 @@ class SubscriberService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private val repository by lazy { (application as Application).repository }
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
private val connections = ConcurrentHashMap<String, SubscriberConnection>() // Base URL -> Connection
private val api = ApiService()
private val notifier = NotificationService(this)
private val broadcaster = BroadcastService(this)
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
@ -71,8 +70,8 @@ class SubscriberService : Service() {
val action = intent.action
Log.d(TAG, "using an intent with action $action")
when (action) {
Actions.START.name -> startService()
Actions.STOP.name -> stopService()
Action.START.name -> startService()
Action.STOP.name -> stopService()
else -> Log.e(TAG, "This should never happen. No action in the received intent")
}
} else {
@ -201,18 +200,13 @@ class SubscriberService : Service() {
repository.updateState(subscriptionIds, state)
}
private fun onNotificationReceived(subscription: Subscription, n: io.heckel.ntfy.data.Notification) {
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.data.Notification) {
val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $n")
Log.d(TAG, "[$url] Received notification: $notification")
GlobalScope.launch(Dispatchers.IO) {
val result = repository.addNotification(n)
if (result.notify) {
Log.d(TAG, "[$url] Showing notification: $n")
notifier.send(subscription, n)
}
if (result.broadcast) {
Log.d(TAG, "[$url] Broadcasting notification: $n")
broadcaster.send(subscription, n, result.muted)
if (repository.addNotification(notification)) {
Log.d(TAG, "[$url] Dispatching notification $notification")
dispatcher.dispatch(subscription, notification)
}
}
}
@ -265,13 +259,7 @@ class SubscriberService : Service() {
class BootStartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "BootStartReceiver: onReceive called")
if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) {
Intent(context, SubscriberService::class.java).also {
it.action = Actions.START.name
Log.d(TAG, "BootStartReceiver: Starting subscriber service")
ContextCompat.startForegroundService(context, it)
}
}
SubscriberServiceManager.refresh(context)
}
}
@ -282,27 +270,11 @@ class SubscriberService : Service() {
class AutoRestartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "AutoRestartReceiver: onReceive called")
val workManager = WorkManager.getInstance(context)
val startServiceRequest = OneTimeWorkRequest.Builder(AutoRestartWorker::class.java).build()
workManager.enqueue(startServiceRequest)
SubscriberServiceManager.refresh(context)
}
}
class AutoRestartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
Log.d(TAG, "AutoRestartReceiver: doWork called for: " + this.getId())
if (readServiceState(context) == ServiceState.STARTED) {
Intent(context, SubscriberService::class.java).also {
it.action = Actions.START.name
Log.d(TAG, "AutoRestartReceiver: Starting subscriber service")
ContextCompat.startForegroundService(context, it)
}
}
return Result.success()
}
}
enum class Actions {
enum class Action {
START,
STOP
}

View file

@ -0,0 +1,63 @@
package io.heckel.ntfy.service
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.*
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.up.BroadcastReceiver
/**
* This class only manages the SubscriberService, i.e. it starts or stops it.
* It's used in multiple activities.
*
* We are starting the service via a worker and not directly because since Android 7
* (but officially since Lollipop!), any process called by a BroadcastReceiver
* (only manifest-declared receiver) is run at low priority and hence eventually
* killed by Android.
*/
class SubscriberServiceManager(private val context: Context) {
fun refresh() {
Log.d(TAG, "Enqueuing work to refresh subscriber service")
val workManager = WorkManager.getInstance(context)
val startServiceRequest = OneTimeWorkRequest.Builder(RefreshWorker::class.java).build()
workManager.enqueue(startServiceRequest)
}
/**
* Starts or stops the foreground service by figuring out how many instant delivery subscriptions
* exist. If there's > 0, then we need a foreground service.
*/
class RefreshWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
if (context.applicationContext !is Application) {
Log.d(TAG, "RefreshWorker: Failed, no application found (work ID: ${this.id})")
return Result.failure()
}
val app = context.applicationContext as Application
val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus()
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
val action = if (instantSubscriptions > 0) SubscriberService.Action.START else SubscriberService.Action.STOP
val serviceState = SubscriberService.readServiceState(context)
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) {
return Result.success()
}
Log.d(TAG, "RefreshWorker: Starting foreground service with action $action (work ID: ${this.id})")
Intent(context, SubscriberService::class.java).also {
it.action = action.name
ContextCompat.startForegroundService(context, it)
}
return Result.success()
}
}
companion object {
const val TAG = "NtfySubscriberMgr"
fun refresh(context: Context) {
val manager = SubscriberServiceManager(context)
manager.refresh()
}
}
}

View file

@ -4,9 +4,6 @@ import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.net.Uri
import android.os.Bundle
import android.text.Html
import android.util.Log
@ -26,12 +23,12 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.util.topicUrl
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort
import kotlinx.coroutines.*
@ -45,7 +42,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private val repository by lazy { (application as Application).repository }
private val api = ApiService()
private val messenger = FirebaseMessenger()
private var subscriberManager: SubscriberManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent
private var appBaseUrl: String? = null // Context-dependent
@ -72,7 +68,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
Log.d(MainActivity.TAG, "Create $this")
// Dependencies that depend on Context
subscriberManager = SubscriberManager(this)
notifier = NotificationService(this)
appBaseUrl = getString(R.string.app_base_url)
@ -149,7 +144,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
// React to changes in fast delivery setting
repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
subscriberManager?.refreshService(it)
SubscriberServiceManager.refresh(this)
}
// Mark this subscription as "open" so we don't receive notifications for it
@ -423,7 +418,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
val formattedDate = formatDateShort(subscriptionMutedUntil)
notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate)
}
}
}

View file

@ -20,12 +20,11 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.work.PollWorker
import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.BroadcastService
import io.heckel.ntfy.msg.SubscriberService
import io.heckel.ntfy.msg.*
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort
import kotlinx.coroutines.Dispatchers
@ -54,9 +53,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Other stuff
private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent
private var broadcaster: BroadcastService? = null // Context-dependent
private var subscriberManager: SubscriberManager? = null // Context-dependent
private var dispatcher: NotificationDispatcher? = null // Context-dependent
private var appBaseUrl: String? = null // Context-dependent
override fun onCreate(savedInstanceState: Bundle?) {
@ -67,9 +64,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Dependencies that depend on Context
workManager = WorkManager.getInstance(this)
notifier = NotificationService(this)
broadcaster = BroadcastService(this)
subscriberManager = SubscriberManager(this)
dispatcher = NotificationDispatcher(this, repository)
appBaseUrl = getString(R.string.app_base_url)
// Action bar
@ -92,7 +87,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) }
mainList = findViewById(R.id.main_subscriptions_list)
adapter = MainAdapter(onSubscriptionClick, onSubscriptionLongClick)
adapter = MainAdapter(repository, onSubscriptionClick, onSubscriptionLongClick)
mainList.adapter = adapter
viewModel.list().observe(this) {
@ -108,20 +103,20 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
}
// React to changes in fast delivery setting
// React to changes in instant delivery setting
viewModel.listIdsWithInstantStatus().observe(this) {
subscriberManager?.refreshService(it)
SubscriberServiceManager.refresh(this)
}
// Create notification channels right away, so we can configure them immediately after installing the app
notifier!!.createNotificationChannels()
dispatcher?.init()
// Subscribe to control Firebase channel (so we can re-start the foreground service if it dies)
messenger.subscribe(ApiService.CONTROL_TOPIC)
// Background things
startPeriodicPollWorker()
startPeriodicAutoRestartWorker()
startPeriodicServiceRefreshWorker()
}
private fun startPeriodicPollWorker() {
@ -146,7 +141,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work)
}
private fun startPeriodicAutoRestartWorker() {
private fun startPeriodicServiceRefreshWorker() {
val workerVersion = repository.getAutoRestartWorkerVersion()
val workPolicy = if (workerVersion == SubscriberService.AUTO_RESTART_WORKER_VERSION) {
Log.d(TAG, "Auto restart worker version matches: choosing KEEP as existing work policy")
@ -156,12 +151,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
repository.setAutoRestartWorkerVersion(SubscriberService.AUTO_RESTART_WORKER_VERSION)
ExistingPeriodicWorkPolicy.REPLACE
}
val work = PeriodicWorkRequestBuilder<SubscriberService.AutoRestartWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES)
val work = PeriodicWorkRequestBuilder<SubscriberServiceManager.RefreshWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES)
.addTag(SubscriberService.TAG)
.addTag(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC)
.build()
Log.d(TAG, "Auto restart worker: Scheduling period work every ${MINIMUM_PERIODIC_WORKER_INTERVAL} minutes")
workManager!!.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
Log.d(TAG, "Auto restart worker: Scheduling period work every $MINIMUM_PERIODIC_WORKER_INTERVAL minutes")
workManager?.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -174,7 +169,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
private fun startNotificationMutedChecker() {
lifecycleScope.launch(Dispatchers.IO) {
delay(1000) // Just to be sure we've initialized all the things, we wait a bit ...
delay(5000) // Just to be sure we've initialized all the things, we wait a bit ...
while (isActive) {
Log.d(DetailActivity.TAG, "Checking global and subscription-specific 'muted until' timestamp")
@ -235,6 +230,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
onNotificationSettingsClick(enable = true)
true
}
R.id.main_menu_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.main_menu_source -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
true
@ -262,6 +261,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
repository.setGlobalMutedUntil(mutedUntilTimestamp)
showHideNotificationMenuItems()
runOnUiThread {
redrawList() // Update the "muted until" icons
when (mutedUntilTimestamp) {
0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
@ -288,6 +288,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
topic = topic,
instant = instant,
mutedUntil = 0,
upAppId = null,
upConnectorToken = null,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
@ -317,11 +319,21 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
private fun onSubscriptionItemClick(subscription: Subscription) {
if (actionMode != null) {
handleActionModeClick(subscription)
} else if (subscription.upAppId != null) { // Not UnifiedPush
displayUnifiedPushToast(subscription)
} else {
startDetailView(subscription)
}
}
private fun displayUnifiedPushToast(subscription: Subscription) {
runOnUiThread {
val appId = subscription.upAppId ?: return@runOnUiThread
val toastMessage = getString(R.string.main_unified_push_toast, appId)
Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show()
}
}
private fun onSubscriptionItemLongClick(subscription: Subscription) {
if (actionMode == null) {
beginActionMode(subscription)
@ -341,12 +353,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
newNotifications.forEach { notification ->
newNotificationsCount++
val notificationWithId = notification.copy(notificationId = Random.nextInt())
val result = repository.addNotification(notificationWithId)
if (result.notify) {
notifier?.send(subscription, notificationWithId)
}
if (result.broadcast) {
broadcaster?.send(subscription, notification, result.muted)
if (repository.addNotification(notificationWithId)) {
dispatcher?.dispatch(subscription, notificationWithId)
}
}
} catch (e: Exception) {
@ -422,7 +430,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val dialog = builder
.setMessage(R.string.main_action_mode_delete_dialog_message)
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
adapter.selected.map { viewModel.remove(it) }
adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) }
finishActionMode()
}
.setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ ->

View file

@ -10,12 +10,13 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R
import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl
import java.text.DateFormat
import java.util.*
class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
class MainAdapter(private val repository: Repository, private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<Long>() // Subscription IDs
@ -23,7 +24,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_main_item, parent, false)
return SubscriptionViewHolder(view, selected, onClick, onLongClick)
return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick)
}
/* Gets current topic and uses it to bind view. */
@ -41,7 +42,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
}
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
class SubscriptionViewHolder(itemView: View, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
class SubscriptionViewHolder(itemView: View, private val repository: Repository, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
RecyclerView.ViewHolder(itemView) {
private var subscription: Subscription? = null
private val context: Context = itemView.context
@ -55,7 +56,10 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
fun bind(subscription: Subscription) {
this.subscription = subscription
var statusMessage = if (subscription.totalCount == 1) {
val isUnifiedPush = subscription.upAppId != null
var statusMessage = if (isUnifiedPush) {
context.getString(R.string.main_item_status_unified_push, subscription.upAppId)
} else if (subscription.totalCount == 1) {
context.getString(R.string.main_item_status_text_one, subscription.totalCount)
} else {
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
@ -76,17 +80,21 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
} else {
dateStr
}
val globalMutedUntil = repository.getGlobalMutedUntil()
val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && !isUnifiedPush
val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && !isUnifiedPush
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage
dateView.text = dateText
notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
if (subscription.newCount > 0) {
if (isUnifiedPush || subscription.newCount == 0) {
newItemsView.visibility = View.GONE
} else {
newItemsView.visibility = View.VISIBLE
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
} else {
newItemsView.visibility = View.GONE
}
itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true }

View file

@ -1,10 +1,12 @@
package io.heckel.ntfy.ui
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.*
import io.heckel.ntfy.up.Distributor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.collections.List
@ -22,7 +24,12 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
repository.addSubscription(subscription)
}
fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
if (subscription.upAppId != null && subscription.upConnectorToken != null) {
val distributor = Distributor(context)
distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken)
}
repository.removeAllNotifications(subscriptionId)
repository.removeSubscription(subscriptionId)
}

View file

@ -4,6 +4,7 @@ import android.app.AlertDialog
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.widget.RadioButton
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
@ -16,8 +17,9 @@ import kotlinx.coroutines.launch
import java.util.*
class NotificationFragment : DialogFragment() {
var settingsListener: NotificationSettingsListener? = null
private lateinit var repository: Repository
private lateinit var settingsListener: NotificationSettingsListener
private lateinit var muteFor30minButton: RadioButton
private lateinit var muteFor1hButton: RadioButton
private lateinit var muteFor2hButton: RadioButton
@ -31,7 +33,9 @@ class NotificationFragment : DialogFragment() {
override fun onAttach(context: Context) {
super.onAttach(context)
settingsListener = activity as NotificationSettingsListener
if (settingsListener == null) {
settingsListener = activity as NotificationSettingsListener
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@ -85,7 +89,7 @@ class NotificationFragment : DialogFragment() {
private fun onClick(mutedUntilTimestamp: Long) {
lifecycleScope.launch(Dispatchers.Main) {
delay(150) // Another hack: Let the animation finish before dismissing the window
settingsListener.onNotificationMutedUntilChanged(mutedUntilTimestamp)
settingsListener?.onNotificationMutedUntilChanged(mutedUntilTimestamp)
dismiss()
}
}

View file

@ -0,0 +1,188 @@
package io.heckel.ntfy.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentManager
import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.util.formatDateShort
import io.heckel.ntfy.util.toPriorityString
class SettingsActivity : AppCompatActivity() {
private val repository by lazy { (application as Application).repository }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
Log.d(MainActivity.TAG, "Create $this")
if (savedInstanceState == null) {
supportFragmentManager
.beginTransaction()
.replace(R.id.settings_layout, SettingsFragment(repository, supportFragmentManager))
.commit()
}
// Action bar
title = getString(R.string.settings_title)
// Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
class SettingsFragment(val repository: Repository, private val supportFragmentManager: FragmentManager) : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.main_preferences, rootKey)
// Important note: We do not use the default shared prefs to store settings. Every
// preferenceDataStore is overridden to use the repository. This is convenient, because
// everybody has access to the repository.
// Notifications muted until (global)
val mutedUntilPrefId = context?.getString(R.string.settings_notifications_muted_until_key) ?: return
val mutedUntilSummary = { s: Long ->
when (s) {
0L -> getString(R.string.settings_notifications_muted_until_enabled)
1L -> getString(R.string.settings_notifications_muted_until_disabled_forever)
else -> {
val formattedDate = formatDateShort(s)
getString(R.string.settings_notifications_muted_until_disabled_until, formattedDate)
}
}
}
val mutedUntil: Preference? = findPreference(mutedUntilPrefId)
mutedUntil?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
mutedUntil?.summary = mutedUntilSummary(repository.getGlobalMutedUntil())
mutedUntil?.onPreferenceClickListener = OnPreferenceClickListener {
if (repository.getGlobalMutedUntil() > 0) {
repository.setGlobalMutedUntil(0)
mutedUntil?.summary = mutedUntilSummary(0)
} else {
val notificationFragment = NotificationFragment()
notificationFragment.settingsListener = object : NotificationFragment.NotificationSettingsListener {
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
repository.setGlobalMutedUntil(mutedUntilTimestamp)
mutedUntil?.summary = mutedUntilSummary(mutedUntilTimestamp)
}
}
notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
}
true
}
// Minimum priority
val minPriorityPrefId = context?.getString(R.string.settings_notifications_min_priority_key) ?: return
val minPriority: ListPreference? = findPreference(minPriorityPrefId)
minPriority?.value = repository.getMinPriority().toString()
minPriority?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val minPriorityValue = value?.toIntOrNull() ?:return
repository.setMinPriority(minPriorityValue)
}
override fun getString(key: String?, defValue: String?): String {
return repository.getMinPriority().toString()
}
}
minPriority?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
val minPriorityValue = pref.value.toIntOrNull() ?: 1 // 1/low means all priorities
when (minPriorityValue) {
1 -> getString(R.string.settings_notifications_min_priority_summary_any)
5 -> getString(R.string.settings_notifications_min_priority_summary_max)
else -> {
val minPriorityString = toPriorityString(minPriorityValue)
getString(R.string.settings_notifications_min_priority_summary_x_or_higher, minPriorityValue, minPriorityString)
}
}
}
// Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)
broadcastEnabled?.isChecked = repository.getBroadcastEnabled()
broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setBroadcastEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getBroadcastEnabled()
}
}
broadcastEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_broadcast_summary_enabled)
} else {
getString(R.string.settings_advanced_broadcast_summary_disabled)
}
}
// UnifiedPush enabled
val upEnabledPrefId = context?.getString(R.string.settings_unified_push_enabled_key) ?: return
val upEnabled: SwitchPreference? = findPreference(upEnabledPrefId)
upEnabled?.isChecked = repository.getUnifiedPushEnabled()
upEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setUnifiedPushEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getUnifiedPushEnabled()
}
}
upEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_unified_push_enabled_summary_on)
} else {
getString(R.string.settings_unified_push_enabled_summary_off)
}
}
// UnifiedPush Base URL
val appBaseUrl = context?.getString(R.string.app_base_url) ?: return
val upBaseUrlPrefId = context?.getString(R.string.settings_unified_push_base_url_key) ?: return
val upBaseUrl: EditTextPreference? = findPreference(upBaseUrlPrefId)
upBaseUrl?.text = repository.getUnifiedPushBaseUrl() ?: ""
upBaseUrl?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String, value: String?) {
val baseUrl = value ?: return
repository.setUnifiedPushBaseUrl(baseUrl)
}
override fun getString(key: String, defValue: String?): String? {
return repository.getUnifiedPushBaseUrl()
}
}
upBaseUrl?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref ->
if (TextUtils.isEmpty(pref.text)) {
getString(R.string.settings_unified_push_base_url_default_summary, appBaseUrl)
} else {
pref.text
}
}
// Version
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
val versionPref: Preference? = findPreference(versionPrefId)
val version = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)
versionPref?.summary = version
versionPref?.onPreferenceClickListener = OnPreferenceClickListener {
val context = context ?: return@OnPreferenceClickListener false
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("app version", version)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, getString(R.string.settings_about_version_copied_to_clipboard_message), Toast.LENGTH_LONG)
.show()
true
}
}
}
}

View file

@ -1,44 +0,0 @@
package io.heckel.ntfy.ui
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import io.heckel.ntfy.msg.SubscriberService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* This class only manages the SubscriberService, i.e. it starts or stops it.
* It's used in multiple activities.
*/
class SubscriberManager(private val activity: ComponentActivity) {
fun refreshService(subscriptionIdsWithInstantStatus: Set<Pair<Long, Boolean>>) {
Log.d(MainActivity.TAG, "Triggering subscriber service refresh")
activity.lifecycleScope.launch(Dispatchers.IO) {
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
if (instantSubscriptions == 0) {
performActionOnSubscriberService(SubscriberService.Actions.STOP)
} else {
performActionOnSubscriberService(SubscriberService.Actions.START)
}
}
}
private fun performActionOnSubscriberService(action: SubscriberService.Actions) {
val serviceState = SubscriberService.readServiceState(activity)
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Actions.STOP) {
return
}
val intent = Intent(activity, SubscriberService::class.java)
intent.action = action.name
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as foreground service, API >= 26)")
activity.startForegroundService(intent)
} else {
Log.d(MainActivity.TAG, "Performing SubscriberService action: ${action.name} (as background service, API >= 26)")
activity.startService(intent)
}
}
}

View file

@ -0,0 +1,113 @@
package io.heckel.ntfy.up
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.preference.PreferenceManager
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.randomString
import io.heckel.ntfy.util.topicUrlUp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import kotlin.random.Random
/**
* This is the UnifiedPush broadcast receiver to handle the distributor actions REGISTER and UNREGISTER.
* See https://unifiedpush.org/spec/android/ for details.
*/
class BroadcastReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent == null) {
return
}
when (intent.action) {
ACTION_REGISTER -> register(context, intent)
ACTION_UNREGISTER -> unregister(context, intent)
}
}
private fun register(context: Context, intent: Intent) {
val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: return
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
val app = context.applicationContext as Application
val repository = app.repository
val distributor = Distributor(app)
Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)")
if (!repository.getUnifiedPushEnabled() || appId.isBlank()) {
Log.w(TAG, "Refusing registration: UnifiedPush disabled or empty application")
distributor.sendRegistrationRefused(appId, connectorToken)
return
}
GlobalScope.launch(Dispatchers.IO) {
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
if (existingSubscription != null) {
if (existingSubscription.upAppId == appId) {
val endpoint = topicUrlUp(existingSubscription.baseUrl, existingSubscription.topic)
Log.d(TAG, "Subscription with connectorToken $connectorToken exists. Sending endpoint $endpoint.")
distributor.sendEndpoint(appId, connectorToken, endpoint)
} else {
Log.d(TAG, "Subscription with connectorToken $connectorToken exists for a different app. Refusing registration.")
distributor.sendRegistrationRefused(appId, connectorToken)
}
return@launch
}
// Add subscription
val baseUrl = repository.getUnifiedPushBaseUrl() ?: context.getString(R.string.app_base_url)
val topic = UP_PREFIX + randomString(TOPIC_RANDOM_ID_LENGTH)
val endpoint = topicUrlUp(baseUrl, topic)
val subscription = Subscription(
id = Random.nextLong(),
baseUrl = baseUrl,
topic = topic,
instant = true, // No Firebase, always instant!
mutedUntil = 0,
upAppId = appId,
upConnectorToken = connectorToken,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
)
Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription")
repository.addSubscription(subscription)
distributor.sendEndpoint(appId, connectorToken, endpoint)
// Refresh (and maybe start) foreground service
SubscriberServiceManager.refresh(app)
}
}
private fun unregister(context: Context, intent: Intent) {
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
val app = context.applicationContext as Application
val repository = app.repository
val distributor = Distributor(app)
Log.d(TAG, "UNREGISTER received (connectorToken=$connectorToken)")
GlobalScope.launch(Dispatchers.IO) {
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
if (existingSubscription == null) {
Log.d(TAG, "Subscription with connectorToken $connectorToken does not exist. Ignoring.")
return@launch
}
// Remove subscription
Log.d(TAG, "Removing subscription ${existingSubscription.id} with connectorToken $connectorToken")
repository.removeSubscription(existingSubscription.id)
existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) }
// Refresh (and maybe stop) foreground service
SubscriberServiceManager.refresh(context)
}
}
companion object {
private const val TAG = "NtfyUpBroadcastRecv"
private const val UP_PREFIX = "up"
private const val TOPIC_RANDOM_ID_LENGTH = 12
}
}

View file

@ -0,0 +1,22 @@
package io.heckel.ntfy.up
/**
* Constants as defined on the specs
* https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md
*/
const val ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT"
const val ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED"
const val ACTION_REGISTRATION_REFUSED = "org.unifiedpush.android.connector.REGISTRATION_REFUSED"
const val ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED"
const val ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"
const val ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"
const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"
const val ACTION_MESSAGE_ACK = "org.unifiedpush.android.distributor.MESSAGE_ACK"
const val EXTRA_APPLICATION = "application"
const val EXTRA_TOKEN = "token"
const val EXTRA_ENDPOINT = "endpoint"
const val EXTRA_MESSAGE = "message"
const val EXTRA_MESSAGE_ID = "id"

View file

@ -0,0 +1,53 @@
package io.heckel.ntfy.up
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* This is the UnifiedPush distributor, an amalgamation of messages to be sent as part of the spec.
* See https://unifiedpush.org/spec/android/ for details.
*/
class Distributor(val context: Context) {
fun sendMessage(app: String, connectorToken: String, message: String) {
Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): $message")
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_MESSAGE
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
broadcastIntent.putExtra(EXTRA_MESSAGE, message)
context.sendBroadcast(broadcastIntent)
}
fun sendEndpoint(app: String, connectorToken: String, endpoint: String) {
Log.d(TAG, "Sending NEW_ENDPOINT to $app (token=$connectorToken): $endpoint")
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_NEW_ENDPOINT
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
broadcastIntent.putExtra(EXTRA_ENDPOINT, endpoint)
context.sendBroadcast(broadcastIntent)
}
fun sendUnregistered(app: String, connectorToken: String) {
Log.d(TAG, "Sending UNREGISTERED to $app (token=$connectorToken)")
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_UNREGISTERED
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
context.sendBroadcast(broadcastIntent)
}
fun sendRegistrationRefused(app: String, connectorToken: String) {
Log.d(TAG, "Sending REGISTRATION_REFUSED to $app (token=$connectorToken)")
val broadcastIntent = Intent()
broadcastIntent.`package` = app
broadcastIntent.action = ACTION_REGISTRATION_REFUSED
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
context.sendBroadcast(broadcastIntent)
}
companion object {
private const val TAG = "NtfyUpDistributor"
}
}

View file

@ -5,10 +5,12 @@ import android.animation.ValueAnimator
import android.view.Window
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import java.security.SecureRandom
import java.text.DateFormat
import java.util.*
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
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) =
@ -26,6 +28,17 @@ fun toPriority(priority: Int?): Int {
else return 3
}
fun toPriorityString(priority: Int): String {
return when (priority) {
1 -> "min"
2 -> "low"
3 -> "default"
4 -> "high"
5 -> "max"
else -> "default"
}
}
fun joinTags(tags: List<String>?): String {
return tags?.joinToString(",") ?: ""
}
@ -101,3 +114,15 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
}
statusBarColorAnimation.start()
}
// Generates a (cryptographically secure) random string of a certain length
fun randomString(len: Int): String {
val random = SecureRandom()
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray()
return (1..len).map { chars[random.nextInt(chars.size)] }.joinToString("")
}
// Allows letting multiple variables at once, see https://stackoverflow.com/a/35522422/1440785
inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
return if (p1 != null && p2 != null) block(p1, p2) else null
}

View file

@ -7,8 +7,10 @@ import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.firebase.FirebaseService
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.BroadcastService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -25,8 +27,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
val database = Database.getInstance(applicationContext)
val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
val notifier = NotificationService(applicationContext)
val broadcaster = BroadcastService(applicationContext)
val dispatcher = NotificationDispatcher(applicationContext, repository)
val api = ApiService()
repository.getSubscriptions().forEach{ subscription ->
@ -36,12 +37,8 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
.onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) }
newNotifications.forEach { notification ->
val result = repository.addNotification(notification)
if (result.notify) {
notifier.send(subscription, notification)
}
if (result.broadcast) {
broadcaster.send(subscription, notification, result.muted)
if (repository.addNotification(notification)) {
dispatcher.dispatch(subscription, notification)
}
}
} catch (e: Exception) {

View file

@ -0,0 +1,9 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/settings_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>

View file

@ -24,12 +24,13 @@
android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp"
app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/>
<TextView
android:text="89 notifications"
android:layout_width="wrap_content"
android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush"
android:layout_width="0dp"
android:layout_height="wrap_content" android:id="@+id/main_item_status"
app:layout_constraintStart_toStartOf="@+id/main_item_text"
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="10dp"/>
android:layout_marginBottom="10dp" app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/>
<ImageView
android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp"

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
This is a slightly edited copy of the original Android project layout
to make wrapping the summary line work.
~ Copyright (C) 2015 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="?android:attr/listPreferredItemPaddingLeft"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingRight="?android:attr/listPreferredItemPaddingRight"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="?android:attr/selectableItemBackground"
android:baselineAligned="false"
android:layout_marginTop="16dp"
android:gravity="center_vertical">
<include layout="@layout/image_frame"/>
<RelativeLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@android:id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:textAlignment="viewStart"
style="@style/PreferenceCategoryTitleTextStyle"/>
<!-- EDITED singleLine -->
<TextView
android:id="@android:id/summary"
android:ellipsize="end"
android:singleLine="false"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignLeft="@android:id/title"
android:layout_alignStart="@android:id/title"
android:layout_gravity="start"
android:textAlignment="viewStart"
android:textColor="?android:attr/textColorSecondary"
android:maxLines="10"
style="@style/PreferenceSummaryTextStyle"/>
</RelativeLayout>
</LinearLayout>

View file

@ -5,6 +5,7 @@
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"/>
<item android:id="@+id/main_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/>
<item android:id="@+id/main_menu_settings" android:title="@string/main_menu_settings_title"/>
<item android:id="@+id/main_menu_source" android:title="@string/main_menu_source_title"/>
<item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/>
</menu>

View file

@ -30,8 +30,9 @@
<string name="main_menu_notifications_enabled">Notifications enabled</string>
<string name="main_menu_notifications_disabled_forever">Notifications disabled</string>
<string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</string>
<string name="main_menu_settings_title">Settings</string>
<string name="main_menu_source_title">Report a bug</string>
<string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
<string name="main_menu_source_url">https://github.com/binwiederhier/ntfy/issues</string>
<string name="main_menu_website_title">Visit ntfy.sh</string>
<!-- Main activity: Action mode -->
@ -47,6 +48,7 @@
<string name="main_item_status_text_one">%1$d notification</string>
<string name="main_item_status_text_not_one">%1$d notifications</string>
<string name="main_item_status_reconnecting">reconnecting …</string>
<string name="main_item_status_unified_push">%1$s (UnifiedPush)</string>
<string name="main_item_date_yesterday">Yesterday</string>
<string name="main_add_button_description">Add subscription</string>
<string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
@ -56,6 +58,7 @@
</string>
<string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
</string>
<string name="main_unified_push_toast">This subscription is managed by %1$s via UnifiedPush</string>
<!-- Add dialog -->
<string name="add_dialog_title">Subscribe to topic</string>
@ -80,10 +83,13 @@
<!-- Detail activity -->
<string name="detail_deep_link_subscribed_toast_message">Subscribed to topic %1$s</string>
<string name="detail_no_notifications_text">You haven\'t received any notifications for this topic yet.</string>
<string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.</string>
<string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.
</string>
<string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string>
<string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
<string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?</string>
<string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
</string>
<string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?
</string>
<string name="detail_clear_dialog_permanently_delete">Permanently delete</string>
<string name="detail_clear_dialog_cancel">Cancel</string>
<string name="detail_delete_dialog_message">
@ -93,7 +99,9 @@
<string name="detail_delete_dialog_permanently_delete">Permanently delete</string>
<string name="detail_delete_dialog_cancel">Cancel</string>
<string name="detail_test_title">Test: You can set a title if you like</string>
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d. If you send another one, it may look different.</string>
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d.
If you send another one, it may look different.
</string>
<string name="detail_test_message_error">Could not send test message: %1$s</string>
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
<string name="detail_instant_delivery_enabled">Instant delivery enabled</string>
@ -135,4 +143,42 @@
<string name="notification_dialog_8h">8 hours</string>
<string name="notification_dialog_tomorrow">Until tomorrow</string>
<string name="notification_dialog_forever">Forever</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_notifications_header">Notifications</string>
<string name="settings_notifications_muted_until_key">MutedUntil</string>
<string name="settings_notifications_muted_until_title">Pause notifications</string>
<string name="settings_notifications_muted_until_enabled">All notifications will be displayed</string>
<string name="settings_notifications_muted_until_disabled_forever">Notifications muted until re-enabled</string>
<string name="settings_notifications_muted_until_disabled_until">Notifications muted until %1$s</string>
<string name="settings_notifications_min_priority_key">MinPriority</string>
<string name="settings_notifications_min_priority_title">Minimum priority</string>
<string name="settings_notifications_min_priority_summary_any">Notifications of all priorities are shown</string>
<string name="settings_notifications_min_priority_summary_x_or_higher">Show notifications if priority is %1$d (%2$s) or higher</string>
<string name="settings_notifications_min_priority_summary_max">Show notifications if priority is 5 (max)</string>
<string name="settings_notifications_min_priority_min">Any priority</string>
<string name="settings_notifications_min_priority_low">Low priority and higher</string>
<string name="settings_notifications_min_priority_default">Default priority and higher</string>
<string name="settings_notifications_min_priority_high">High priority and higher</string>
<string name="settings_notifications_min_priority_max">Only max priority</string>
<string name="settings_unified_push_header">UnifiedPush</string>
<string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
<string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string>
<string name="settings_unified_push_enabled_title">Allow distributor use</string>
<string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string>
<string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string>
<string name="settings_unified_push_base_url_key">UnifiedPushBaseURL</string>
<string name="settings_unified_push_base_url_title">Server URL</string>
<string name="settings_unified_push_base_url_default_summary">%1$s (default)</string>
<string name="settings_advanced_header">Advanced</string>
<string name="settings_advanced_broadcast_key">BroadcastEnabled</string>
<string name="settings_advanced_broadcast_title">Broadcast messages</string>
<string name="settings_advanced_broadcast_summary_enabled">Apps can receive incoming notifications as broadcasts</string>
<string name="settings_advanced_broadcast_summary_disabled">Apps cannot receive notifications as broadcasts</string>
<string name="settings_about_header">About</string>
<string name="settings_about_version_key">Version</string>
<string name="settings_about_version_title">Version</string>
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
<string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string>
</resources>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="settings_notifications_min_priority_entries">
<item>@string/settings_notifications_min_priority_min</item>
<item>@string/settings_notifications_min_priority_low</item>
<item>@string/settings_notifications_min_priority_default</item>
<item>@string/settings_notifications_min_priority_high</item>
<item>@string/settings_notifications_min_priority_max</item>
</string-array>
<string-array name="settings_notifications_min_priority_values">
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
<item>5</item>
</string-array>
</resources>

View file

@ -0,0 +1,41 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android"
app:title="@string/settings_title">
<PreferenceCategory
app:title="@string/settings_notifications_header"
app:layout="@layout/preference_category_material_edited">
<Preference
app:key="@string/settings_notifications_muted_until_key"
app:title="@string/settings_notifications_muted_until_title"/>
<ListPreference
app:key="@string/settings_notifications_min_priority_key"
app:title="@string/settings_notifications_min_priority_title"
app:entries="@array/settings_notifications_min_priority_entries"
app:entryValues="@array/settings_notifications_min_priority_values"
app:defaultValue="1"/>
</PreferenceCategory>
<PreferenceCategory
app:title="@string/settings_unified_push_header"
app:summary="@string/settings_unified_push_header_summary"
app:layout="@layout/preference_category_material_edited">
<SwitchPreference
app:key="@string/settings_unified_push_enabled_key"
app:title="@string/settings_unified_push_enabled_title"
app:enabled="true"/>
<EditTextPreference
app:key="@string/settings_unified_push_base_url_key"
app:title="@string/settings_unified_push_base_url_title"
app:dependency="@string/settings_unified_push_enabled_key"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_advanced_header">
<SwitchPreference
app:key="@string/settings_advanced_broadcast_key"
app:title="@string/settings_advanced_broadcast_title"
app:enabled="true"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_about_header">
<Preference
app:key="@string/settings_about_version_key"
app:title="@string/settings_about_version_title"/>
</PreferenceCategory>
</PreferenceScreen>

View file

@ -7,10 +7,8 @@ import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.BroadcastService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.msg.SubscriberService
import io.heckel.ntfy.msg.*
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.toPriority
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@ -19,9 +17,8 @@ import kotlin.random.Random
class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository }
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
private val job = SupervisorJob()
private val notifier = NotificationService(this)
private val broadcaster = BroadcastService(this)
private val messenger = FirebaseMessenger()
override fun onMessageReceived(remoteMessage: RemoteMessage) {
@ -81,16 +78,9 @@ class FirebaseService : FirebaseMessagingService() {
tags = tags ?: "",
deleted = false
)
val result = repository.addNotification(notification)
// Send notification (only if it's not already known)
if (result.notify) {
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
notifier.send(subscription, notification)
}
if (result.broadcast) {
Log.d(TAG, "Sending broadcast for message: from=${remoteMessage.from}, data=${data}")
broadcaster.send(subscription, notification, result.muted)
if (repository.addNotification(notification)) {
Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, data=${data}")
dispatcher.dispatch(subscription, notification)
}
}
}