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 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 30
versionCode 12 versionCode 13
versionName "1.4.2" versionName "1.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -67,6 +67,7 @@ dependencies {
// WorkManager // WorkManager
implementation "androidx.work:work-runtime-ktx:2.6.0" implementation "androidx.work:work-runtime-ktx:2.6.0"
implementation 'androidx.preference:preference:1.1.1'
// Room (SQLite) // Room (SQLite)
def roomVersion = "2.3.0" 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.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE"/>
<application <application
android:name=".app.Application" android:name=".app.Application"
@ -23,6 +23,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<!-- Main activity --> <!-- Main activity -->
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
@ -43,27 +44,54 @@
android:value=".ui.MainActivity"/> android:value=".ui.MainActivity"/>
</activity> </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 --> <!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".msg.SubscriberService"/> <service android:name=".service.SubscriberService"/>
<!-- Subscriber service restart on reboot --> <!-- 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> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Subscriber service restart on destruction --> <!-- Subscriber service restart on destruction -->
<receiver android:name=".msg.SubscriberService$AutoRestartReceiver" android:enabled="true" <receiver
android:exported="false"/> android:name=".service.SubscriberService$AutoRestartReceiver"
android:enabled="true"
android:exported="false"/>
<!-- Broadcast receiver to send messages via intents --> <!-- 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> <intent-filter>
<action android:name="io.heckel.ntfy.SEND_MESSAGE"/> <action android:name="io.heckel.ntfy.SEND_MESSAGE"/>
</intent-filter> </intent-filter>
</receiver> </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) --> <!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
<service <service
android:name=".firebase.FirebaseService" android:name=".firebase.FirebaseService"
@ -72,7 +100,6 @@
<action android:name="com.google.firebase.MESSAGING_EVENT"/> <action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter> </intent-filter>
</service> </service>
<meta-data <meta-data
android:name="firebase_analytics_collection_enabled" android:name="firebase_analytics_collection_enabled"
android:value="false"/> android:value="false"/>
@ -80,5 +107,4 @@
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification"/> android:resource="@drawable/ic_notification"/>
</application> </application>
</manifest> </manifest>

View file

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

View file

@ -36,6 +36,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
return toSubscriptionList(subscriptionDao.list()) return toSubscriptionList(subscriptionDao.list())
} }
fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
return subscriptionDao
.list()
.map { Pair(it.id, it.instant) }.toSet()
}
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun getSubscription(subscriptionId: Long): Subscription? { 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)) return toSubscription(subscriptionDao.get(baseUrl, topic))
} }
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun getSubscriptionByConnectorToken(connectorToken: String): Subscription? {
return toSubscription(subscriptionDao.getByConnectorToken(connectorToken))
}
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun addSubscription(subscription: Subscription) { suspend fun addSubscription(subscription: Subscription) {
@ -85,16 +97,13 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun addNotification(notification: Notification): NotificationAddResult { 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) return false
val detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId
val muted = isMuted(notification.subscriptionId)
val notify = !detailsVisible && !muted
return NotificationAddResult(notify = notify, broadcast = true, muted = muted)
} }
return NotificationAddResult(notify = false, broadcast = false, muted = false) notificationDao.add(notification)
return true
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@ -133,15 +142,60 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
.apply() .apply()
} }
private suspend fun isMuted(subscriptionId: Long): Boolean { fun setMinPriority(minPriority: Int) {
if (isGlobalMuted()) { if (minPriority <= 1) {
return true 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() val mutedUntil = getGlobalMutedUntil()
return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000) 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, topic = s.topic,
instant = s.instant, instant = s.instant,
mutedUntil = s.mutedUntil, mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
@ -195,6 +251,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
topic = s.topic, topic = s.topic,
instant = s.instant, instant = s.instant,
mutedUntil = s.mutedUntil, mutedUntil = s.mutedUntil,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
@ -224,17 +282,15 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE } return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
} }
data class NotificationAddResult(
val notify: Boolean,
val broadcast: Boolean,
val muted: Boolean,
)
companion object { companion object {
const val SHARED_PREFS_ID = "MainPreferences" const val SHARED_PREFS_ID = "MainPreferences"
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion" const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil" 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 const val TAG = "NtfyRepository"
private var instance: Repository? = null private var instance: Repository? = null

View file

@ -13,8 +13,8 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* The broadcast service is responsible for sending and receiving broadcasted intents * The broadcast service is responsible for sending and receiving broadcast intents
* in order to facilitate taks app integrations. * in order to facilitate tasks app integrations.
*/ */
class BroadcastService(private val ctx: Context) { class BroadcastService(private val ctx: Context) {
fun send(subscription: Subscription, notification: Notification, muted: Boolean) { fun send(subscription: Subscription, notification: Notification, muted: Boolean) {
@ -36,6 +36,10 @@ class BroadcastService(private val ctx: Context) {
ctx.sendBroadcast(intent) ctx.sendBroadcast(intent)
} }
/**
* This receiver is triggered when the SEND_MESSAGE intent is received.
* See AndroidManifest.xml for details.
*/
class BroadcastReceiver : android.content.BroadcastReceiver() { class BroadcastReceiver : android.content.BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Broadcast received: $intent") Log.d(TAG, "Broadcast received: $intent")
@ -46,24 +50,20 @@ class BroadcastService(private val ctx: Context) {
private fun send(ctx: Context, intent: Intent) { private fun send(ctx: Context, intent: Intent) {
val api = ApiService() val api = ApiService()
val baseUrl = intent.getStringExtra("base_url") ?: ctx.getString(R.string.app_base_url) val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url)
val topic = intent.getStringExtra("topic") ?: return val topic = getStringExtra(intent, "topic") ?: return
val message = intent.getStringExtra("message") ?: return val message = getStringExtra(intent, "message") ?: return
val title = intent.getStringExtra("title") ?: "" val title = getStringExtra(intent, "title") ?: ""
val tags = intent.getStringExtra("tags") ?: "" val tags = getStringExtra(intent,"tags") ?: ""
val priority = if (intent.getStringExtra("priority") != null) { val priority = when (getStringExtra(intent, "priority")) {
when (intent.getStringExtra("priority")) { "min", "1" -> 1
"min", "1" -> 1 "low", "2" -> 2
"low", "2" -> 2 "default", "3" -> 3
"default", "3" -> 3 "high", "4" -> 4
"high", "4" -> 4 "urgent", "max", "5" -> 5
"urgent", "max", "5" -> 5 else -> 0
else -> 0
}
} else {
intent.getIntExtra("priority", 0)
} }
val delay = intent.getStringExtra("delay") ?: "" val delay = getStringExtra(intent,"delay") ?: ""
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
api.publish( api.publish(
baseUrl = baseUrl, 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 { companion object {
private const val TAG = "NtfyBroadcastService" private const val TAG = "NtfyBroadcastService"
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED" 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 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 android.util.Log
import io.heckel.ntfy.data.ConnectionState import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.Call import okhttp3.Call

View file

@ -1,4 +1,4 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.service
import android.app.* import android.app.*
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -11,8 +11,6 @@ import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
@ -20,6 +18,8 @@ import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.ConnectionState import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Subscription 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.ui.MainActivity
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -58,10 +58,9 @@ class SubscriberService : Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false private var isServiceStarted = false
private val repository by lazy { (application as Application).repository } 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 connections = ConcurrentHashMap<String, SubscriberConnection>() // Base URL -> Connection
private val api = ApiService() private val api = ApiService()
private val notifier = NotificationService(this)
private val broadcaster = BroadcastService(this)
private var notificationManager: NotificationManager? = null private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null private var serviceNotification: Notification? = null
@ -71,8 +70,8 @@ class SubscriberService : Service() {
val action = intent.action val action = intent.action
Log.d(TAG, "using an intent with action $action") Log.d(TAG, "using an intent with action $action")
when (action) { when (action) {
Actions.START.name -> startService() Action.START.name -> startService()
Actions.STOP.name -> stopService() Action.STOP.name -> stopService()
else -> Log.e(TAG, "This should never happen. No action in the received intent") else -> Log.e(TAG, "This should never happen. No action in the received intent")
} }
} else { } else {
@ -201,18 +200,13 @@ class SubscriberService : Service() {
repository.updateState(subscriptionIds, state) 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) 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) { GlobalScope.launch(Dispatchers.IO) {
val result = repository.addNotification(n) if (repository.addNotification(notification)) {
if (result.notify) { Log.d(TAG, "[$url] Dispatching notification $notification")
Log.d(TAG, "[$url] Showing notification: $n") dispatcher.dispatch(subscription, notification)
notifier.send(subscription, n)
}
if (result.broadcast) {
Log.d(TAG, "[$url] Broadcasting notification: $n")
broadcaster.send(subscription, n, result.muted)
} }
} }
} }
@ -265,13 +259,7 @@ class SubscriberService : Service() {
class BootStartReceiver : BroadcastReceiver() { class BootStartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "BootStartReceiver: onReceive called") Log.d(TAG, "BootStartReceiver: onReceive called")
if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) { SubscriberServiceManager.refresh(context)
Intent(context, SubscriberService::class.java).also {
it.action = Actions.START.name
Log.d(TAG, "BootStartReceiver: Starting subscriber service")
ContextCompat.startForegroundService(context, it)
}
}
} }
} }
@ -282,27 +270,11 @@ class SubscriberService : Service() {
class AutoRestartReceiver : BroadcastReceiver() { class AutoRestartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "AutoRestartReceiver: onReceive called") Log.d(TAG, "AutoRestartReceiver: onReceive called")
val workManager = WorkManager.getInstance(context) SubscriberServiceManager.refresh(context)
val startServiceRequest = OneTimeWorkRequest.Builder(AutoRestartWorker::class.java).build()
workManager.enqueue(startServiceRequest)
} }
} }
class AutoRestartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) { enum class Action {
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 {
START, START,
STOP 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.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.Html import android.text.Html
import android.util.Log import android.util.Log
@ -26,12 +23,12 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.firebase.FirebaseMessenger
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.service.SubscriberServiceManager
import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort import io.heckel.ntfy.util.formatDateShort
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -45,7 +42,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService() private val api = ApiService()
private val messenger = FirebaseMessenger() private val messenger = FirebaseMessenger()
private var subscriberManager: SubscriberManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent
private var appBaseUrl: String? = 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") Log.d(MainActivity.TAG, "Create $this")
// Dependencies that depend on Context // Dependencies that depend on Context
subscriberManager = SubscriberManager(this)
notifier = NotificationService(this) notifier = NotificationService(this)
appBaseUrl = getString(R.string.app_base_url) appBaseUrl = getString(R.string.app_base_url)
@ -149,7 +144,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
// React to changes in fast delivery setting // React to changes in fast delivery setting
repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) { repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
subscriberManager?.refreshService(it) SubscriberServiceManager.refresh(this)
} }
// Mark this subscription as "open" so we don't receive notifications for it // 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) val formattedDate = formatDateShort(subscriptionMutedUntil)
notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate) 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.app.Application
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl 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.work.PollWorker
import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.firebase.FirebaseMessenger
import io.heckel.ntfy.msg.BroadcastService import io.heckel.ntfy.msg.*
import io.heckel.ntfy.msg.SubscriberService import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.fadeStatusBarColor import io.heckel.ntfy.util.fadeStatusBarColor
import io.heckel.ntfy.util.formatDateShort import io.heckel.ntfy.util.formatDateShort
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -54,9 +53,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Other stuff // Other stuff
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent private var workManager: WorkManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent private var dispatcher: NotificationDispatcher? = null // Context-dependent
private var broadcaster: BroadcastService? = null // Context-dependent
private var subscriberManager: SubscriberManager? = null // Context-dependent
private var appBaseUrl: String? = null // Context-dependent private var appBaseUrl: String? = null // Context-dependent
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -67,9 +64,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Dependencies that depend on Context // Dependencies that depend on Context
workManager = WorkManager.getInstance(this) workManager = WorkManager.getInstance(this)
notifier = NotificationService(this) dispatcher = NotificationDispatcher(this, repository)
broadcaster = BroadcastService(this)
subscriberManager = SubscriberManager(this)
appBaseUrl = getString(R.string.app_base_url) appBaseUrl = getString(R.string.app_base_url)
// Action bar // Action bar
@ -92,7 +87,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) } val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) }
mainList = findViewById(R.id.main_subscriptions_list) mainList = findViewById(R.id.main_subscriptions_list)
adapter = MainAdapter(onSubscriptionClick, onSubscriptionLongClick) adapter = MainAdapter(repository, onSubscriptionClick, onSubscriptionLongClick)
mainList.adapter = adapter mainList.adapter = adapter
viewModel.list().observe(this) { 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) { 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 // 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) // Subscribe to control Firebase channel (so we can re-start the foreground service if it dies)
messenger.subscribe(ApiService.CONTROL_TOPIC) messenger.subscribe(ApiService.CONTROL_TOPIC)
// Background things // Background things
startPeriodicPollWorker() startPeriodicPollWorker()
startPeriodicAutoRestartWorker() startPeriodicServiceRefreshWorker()
} }
private fun startPeriodicPollWorker() { private fun startPeriodicPollWorker() {
@ -146,7 +141,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work) workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work)
} }
private fun startPeriodicAutoRestartWorker() { private fun startPeriodicServiceRefreshWorker() {
val workerVersion = repository.getAutoRestartWorkerVersion() val workerVersion = repository.getAutoRestartWorkerVersion()
val workPolicy = if (workerVersion == SubscriberService.AUTO_RESTART_WORKER_VERSION) { val workPolicy = if (workerVersion == SubscriberService.AUTO_RESTART_WORKER_VERSION) {
Log.d(TAG, "Auto restart worker version matches: choosing KEEP as existing work policy") 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) repository.setAutoRestartWorkerVersion(SubscriberService.AUTO_RESTART_WORKER_VERSION)
ExistingPeriodicWorkPolicy.REPLACE 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.TAG)
.addTag(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC) .addTag(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC)
.build() .build()
Log.d(TAG, "Auto restart worker: Scheduling period work every ${MINIMUM_PERIODIC_WORKER_INTERVAL} minutes") 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) workManager?.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -174,7 +169,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
private fun startNotificationMutedChecker() { private fun startNotificationMutedChecker() {
lifecycleScope.launch(Dispatchers.IO) { 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) { while (isActive) {
Log.d(DetailActivity.TAG, "Checking global and subscription-specific 'muted until' timestamp") 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) onNotificationSettingsClick(enable = true)
true true
} }
R.id.main_menu_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.main_menu_source -> { R.id.main_menu_source -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url)))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
true true
@ -262,6 +261,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
repository.setGlobalMutedUntil(mutedUntilTimestamp) repository.setGlobalMutedUntil(mutedUntilTimestamp)
showHideNotificationMenuItems() showHideNotificationMenuItems()
runOnUiThread { runOnUiThread {
redrawList() // Update the "muted until" icons
when (mutedUntilTimestamp) { when (mutedUntilTimestamp) {
0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show() 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() 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, topic = topic,
instant = instant, instant = instant,
mutedUntil = 0, mutedUntil = 0,
upAppId = null,
upConnectorToken = null,
totalCount = 0, totalCount = 0,
newCount = 0, newCount = 0,
lastActive = Date().time/1000 lastActive = Date().time/1000
@ -317,11 +319,21 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
private fun onSubscriptionItemClick(subscription: Subscription) { private fun onSubscriptionItemClick(subscription: Subscription) {
if (actionMode != null) { if (actionMode != null) {
handleActionModeClick(subscription) handleActionModeClick(subscription)
} else if (subscription.upAppId != null) { // Not UnifiedPush
displayUnifiedPushToast(subscription)
} else { } else {
startDetailView(subscription) 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) { private fun onSubscriptionItemLongClick(subscription: Subscription) {
if (actionMode == null) { if (actionMode == null) {
beginActionMode(subscription) beginActionMode(subscription)
@ -341,12 +353,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
newNotifications.forEach { notification -> newNotifications.forEach { notification ->
newNotificationsCount++ newNotificationsCount++
val notificationWithId = notification.copy(notificationId = Random.nextInt()) val notificationWithId = notification.copy(notificationId = Random.nextInt())
val result = repository.addNotification(notificationWithId) if (repository.addNotification(notificationWithId)) {
if (result.notify) { dispatcher?.dispatch(subscription, notificationWithId)
notifier?.send(subscription, notificationWithId)
}
if (result.broadcast) {
broadcaster?.send(subscription, notification, result.muted)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -422,7 +430,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val dialog = builder val dialog = builder
.setMessage(R.string.main_action_mode_delete_dialog_message) .setMessage(R.string.main_action_mode_delete_dialog_message)
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ -> .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() finishActionMode()
} }
.setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ -> .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 androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.ConnectionState import io.heckel.ntfy.data.ConnectionState
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
import java.text.DateFormat import java.text.DateFormat
import java.util.* 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) { ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
val selected = mutableSetOf<Long>() // Subscription IDs 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_main_item, parent, false) .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. */ /* 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. */ /* 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) { RecyclerView.ViewHolder(itemView) {
private var subscription: Subscription? = null private var subscription: Subscription? = null
private val context: Context = itemView.context private val context: Context = itemView.context
@ -55,7 +56,10 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
fun bind(subscription: Subscription) { fun bind(subscription: Subscription) {
this.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) context.getString(R.string.main_item_status_text_one, subscription.totalCount)
} else { } else {
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount) 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 { } else {
dateStr 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) nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage statusView.text = statusMessage
dateView.text = dateText dateView.text = dateText
notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE 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 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.visibility = View.VISIBLE
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+" newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
} else {
newItemsView.visibility = View.GONE
} }
itemView.setOnClickListener { onClick(subscription) } itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true } itemView.setOnLongClickListener { onLongClick(subscription); true }

View file

@ -1,10 +1,12 @@
package io.heckel.ntfy.ui package io.heckel.ntfy.ui
import android.content.Context
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.* import io.heckel.ntfy.data.*
import io.heckel.ntfy.up.Distributor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.collections.List import kotlin.collections.List
@ -22,7 +24,12 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
repository.addSubscription(subscription) 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.removeAllNotifications(subscriptionId)
repository.removeSubscription(subscriptionId) repository.removeSubscription(subscriptionId)
} }

View file

@ -4,6 +4,7 @@ import android.app.AlertDialog
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.RadioButton import android.widget.RadioButton
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -16,8 +17,9 @@ import kotlinx.coroutines.launch
import java.util.* import java.util.*
class NotificationFragment : DialogFragment() { class NotificationFragment : DialogFragment() {
var settingsListener: NotificationSettingsListener? = null
private lateinit var repository: Repository private lateinit var repository: Repository
private lateinit var settingsListener: NotificationSettingsListener
private lateinit var muteFor30minButton: RadioButton private lateinit var muteFor30minButton: RadioButton
private lateinit var muteFor1hButton: RadioButton private lateinit var muteFor1hButton: RadioButton
private lateinit var muteFor2hButton: RadioButton private lateinit var muteFor2hButton: RadioButton
@ -31,7 +33,9 @@ class NotificationFragment : DialogFragment() {
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
settingsListener = activity as NotificationSettingsListener if (settingsListener == null) {
settingsListener = activity as NotificationSettingsListener
}
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@ -85,7 +89,7 @@ class NotificationFragment : DialogFragment() {
private fun onClick(mutedUntilTimestamp: Long) { private fun onClick(mutedUntilTimestamp: Long) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
delay(150) // Another hack: Let the animation finish before dismissing the window delay(150) // Another hack: Let the animation finish before dismissing the window
settingsListener.onNotificationMutedUntilChanged(mutedUntilTimestamp) settingsListener?.onNotificationMutedUntilChanged(mutedUntilTimestamp)
dismiss() 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 android.view.Window
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import java.security.SecureRandom
import java.text.DateFormat import java.text.DateFormat
import java.util.* import java.util.*
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" 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 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) =
@ -26,6 +28,17 @@ fun toPriority(priority: Int?): Int {
else return 3 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 { fun joinTags(tags: List<String>?): String {
return tags?.joinToString(",") ?: "" return tags?.joinToString(",") ?: ""
} }
@ -101,3 +114,15 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
} }
statusBarColorAnimation.start() 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.BuildConfig
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.firebase.FirebaseService
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.BroadcastService import io.heckel.ntfy.msg.BroadcastService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -25,8 +27,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
val database = Database.getInstance(applicationContext) val database = Database.getInstance(applicationContext)
val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE) val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao()) val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
val notifier = NotificationService(applicationContext) val dispatcher = NotificationDispatcher(applicationContext, repository)
val broadcaster = BroadcastService(applicationContext)
val api = ApiService() val api = ApiService()
repository.getSubscriptions().forEach{ subscription -> repository.getSubscriptions().forEach{ subscription ->
@ -36,12 +37,8 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
.onlyNewNotifications(subscription.id, notifications) .onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) } .map { it.copy(notificationId = Random.nextInt()) }
newNotifications.forEach { notification -> newNotifications.forEach { notification ->
val result = repository.addNotification(notification) if (repository.addNotification(notification)) {
if (result.notify) { dispatcher.dispatch(subscription, notification)
notifier.send(subscription, notification)
}
if (result.broadcast) {
broadcaster.send(subscription, notification, result.muted)
} }
} }
} catch (e: Exception) { } 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" android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp"
app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/> app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/>
<TextView <TextView
android:text="89 notifications" android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:id="@+id/main_item_status" android:layout_height="wrap_content" android:id="@+id/main_item_status"
app:layout_constraintStart_toStartOf="@+id/main_item_text" app:layout_constraintStart_toStartOf="@+id/main_item_text"
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent" 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 <ImageView
android:layout_width="20dp" android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp" 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"/> 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" <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"/> 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_source" android:title="@string/main_menu_source_title"/>
<item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/> <item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/>
</menu> </menu>

View file

@ -30,8 +30,9 @@
<string name="main_menu_notifications_enabled">Notifications enabled</string> <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_forever">Notifications disabled</string>
<string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</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_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> <string name="main_menu_website_title">Visit ntfy.sh</string>
<!-- Main activity: Action mode --> <!-- 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_one">%1$d notification</string>
<string name="main_item_status_text_not_one">%1$d notifications</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_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_item_date_yesterday">Yesterday</string>
<string name="main_add_button_description">Add subscription</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> <string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
@ -56,6 +58,7 @@
</string> </string>
<string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation. <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
</string> </string>
<string name="main_unified_push_toast">This subscription is managed by %1$s via UnifiedPush</string>
<!-- Add dialog --> <!-- Add dialog -->
<string name="add_dialog_title">Subscribe to topic</string> <string name="add_dialog_title">Subscribe to topic</string>
@ -80,10 +83,13 @@
<!-- Detail activity --> <!-- Detail activity -->
<string name="detail_deep_link_subscribed_toast_message">Subscribed to topic %1$s</string> <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_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_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_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
<string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?</string> </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_permanently_delete">Permanently delete</string>
<string name="detail_clear_dialog_cancel">Cancel</string> <string name="detail_clear_dialog_cancel">Cancel</string>
<string name="detail_delete_dialog_message"> <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_permanently_delete">Permanently delete</string>
<string name="detail_delete_dialog_cancel">Cancel</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_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_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_copied_to_clipboard_message">Copied to clipboard</string>
<string name="detail_instant_delivery_enabled">Instant delivery enabled</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_8h">8 hours</string>
<string name="notification_dialog_tomorrow">Until tomorrow</string> <string name="notification_dialog_tomorrow">Until tomorrow</string>
<string name="notification_dialog_forever">Forever</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> </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.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.*
import io.heckel.ntfy.msg.BroadcastService import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.msg.SubscriberService
import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.toPriority
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -19,9 +17,8 @@ import kotlin.random.Random
class FirebaseService : FirebaseMessagingService() { class FirebaseService : FirebaseMessagingService() {
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
private val job = SupervisorJob() private val job = SupervisorJob()
private val notifier = NotificationService(this)
private val broadcaster = BroadcastService(this)
private val messenger = FirebaseMessenger() private val messenger = FirebaseMessenger()
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {
@ -81,16 +78,9 @@ class FirebaseService : FirebaseMessagingService() {
tags = tags ?: "", tags = tags ?: "",
deleted = false deleted = false
) )
val result = repository.addNotification(notification) if (repository.addNotification(notification)) {
Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, data=${data}")
// Send notification (only if it's not already known) dispatcher.dispatch(subscription, notification)
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)
} }
} }
} }