commit
91d13bdd13
30 changed files with 1163 additions and 227 deletions
app
build.gradle
schemas/io.heckel.ntfy.data.Database
src
main
AndroidManifest.xml
java/io/heckel/ntfy
data
msg
service
ui
DetailActivity.ktMainActivity.ktMainAdapter.ktMainViewModel.ktNotificationFragment.ktSettingsActivity.ktSubscriberManager.kt
up
util
work
res
play/java/io/heckel/ntfy/firebase
|
@ -12,8 +12,8 @@ android {
|
|||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
|
||||
versionCode 12
|
||||
versionName "1.4.2"
|
||||
versionCode 13
|
||||
versionName "1.5.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
@ -67,6 +67,7 @@ dependencies {
|
|||
|
||||
// WorkManager
|
||||
implementation "androidx.work:work-runtime-ktx:2.6.0"
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
|
||||
// Room (SQLite)
|
||||
def roomVersion = "2.3.0"
|
||||
|
|
158
app/schemas/io.heckel.ntfy.data.Database/5.json
Normal file
158
app/schemas/io.heckel.ntfy.data.Database/5.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
|
||||
<application
|
||||
android:name=".app.Application"
|
||||
|
@ -23,6 +23,7 @@
|
|||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
|
@ -43,27 +44,54 @@
|
|||
android:value=".ui.MainActivity"/>
|
||||
</activity>
|
||||
|
||||
<!-- Settings activity -->
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:parentActivityName=".ui.MainActivity">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".ui.MainActivity"/>
|
||||
</activity>
|
||||
|
||||
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
|
||||
<service android:name=".msg.SubscriberService"/>
|
||||
<service android:name=".service.SubscriberService"/>
|
||||
|
||||
<!-- Subscriber service restart on reboot -->
|
||||
<receiver android:name=".msg.SubscriberService$BootStartReceiver" android:enabled="true">
|
||||
<receiver
|
||||
android:name=".service.SubscriberService$BootStartReceiver"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Subscriber service restart on destruction -->
|
||||
<receiver android:name=".msg.SubscriberService$AutoRestartReceiver" android:enabled="true"
|
||||
android:exported="false"/>
|
||||
<receiver
|
||||
android:name=".service.SubscriberService$AutoRestartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false"/>
|
||||
|
||||
<!-- Broadcast receiver to send messages via intents -->
|
||||
<receiver android:name=".msg.BroadcastService$BroadcastReceiver" android:enabled="true" android:exported="true">
|
||||
<receiver
|
||||
android:name=".msg.BroadcastService$BroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="io.heckel.ntfy.SEND_MESSAGE"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md -->
|
||||
<receiver
|
||||
android:name=".up.BroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.distributor.REGISTER"/>
|
||||
<action android:name="org.unifiedpush.android.distributor.UNREGISTER"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
|
||||
<service
|
||||
android:name=".firebase.FirebaseService"
|
||||
|
@ -72,7 +100,6 @@
|
|||
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<meta-data
|
||||
android:name="firebase_analytics_collection_enabled"
|
||||
android:value="false"/>
|
||||
|
@ -80,5 +107,4 @@
|
|||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_notification"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -6,20 +6,22 @@ import androidx.room.migration.Migration
|
|||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)])
|
||||
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
|
||||
data class Subscription(
|
||||
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
|
||||
@ColumnInfo(name = "baseUrl") val baseUrl: String,
|
||||
@ColumnInfo(name = "topic") val topic: String,
|
||||
@ColumnInfo(name = "instant") val instant: Boolean,
|
||||
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule
|
||||
@ColumnInfo(name = "upAppId") val upAppId: String?,
|
||||
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?,
|
||||
@Ignore val totalCount: Int = 0, // Total notifications
|
||||
@Ignore val newCount: Int = 0, // New notifications
|
||||
@Ignore val lastActive: Long = 0, // Unix timestamp
|
||||
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
|
||||
) {
|
||||
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long) :
|
||||
this(id, baseUrl, topic, instant, mutedUntil, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
|
||||
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, upAppId: String, upConnectorToken: String) :
|
||||
this(id, baseUrl, topic, instant, mutedUntil, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
|
||||
}
|
||||
|
||||
enum class ConnectionState {
|
||||
|
@ -32,6 +34,8 @@ data class SubscriptionWithMetadata(
|
|||
val topic: String,
|
||||
val instant: Boolean,
|
||||
val mutedUntil: Long,
|
||||
val upAppId: String?,
|
||||
val upConnectorToken: String?,
|
||||
val totalCount: Int,
|
||||
val newCount: Int,
|
||||
val lastActive: Long
|
||||
|
@ -50,7 +54,7 @@ data class Notification(
|
|||
@ColumnInfo(name = "deleted") val deleted: Boolean,
|
||||
)
|
||||
|
||||
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 4)
|
||||
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 5)
|
||||
abstract class Database : RoomDatabase() {
|
||||
abstract fun subscriptionDao(): SubscriptionDao
|
||||
abstract fun notificationDao(): NotificationDao
|
||||
|
@ -66,6 +70,7 @@ abstract class Database : RoomDatabase() {
|
|||
.addMigrations(MIGRATION_1_2)
|
||||
.addMigrations(MIGRATION_2_3)
|
||||
.addMigrations(MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_4_5)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
this.instance = instance
|
||||
|
@ -102,6 +107,14 @@ abstract class Database : RoomDatabase() {
|
|||
db.execSQL("ALTER TABLE Notification_New RENAME TO Notification")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT")
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT")
|
||||
db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,33 +122,33 @@ abstract class Database : RoomDatabase() {
|
|||
interface SubscriptionDao {
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
FROM Subscription AS s
|
||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
||||
GROUP BY s.id
|
||||
ORDER BY MAX(n.timestamp) DESC
|
||||
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
|
||||
""")
|
||||
fun listFlow(): Flow<List<SubscriptionWithMetadata>>
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
FROM Subscription AS s
|
||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
||||
GROUP BY s.id
|
||||
ORDER BY MAX(n.timestamp) DESC
|
||||
ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
|
||||
""")
|
||||
fun list(): List<SubscriptionWithMetadata>
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
|
@ -148,17 +161,30 @@ interface SubscriptionDao {
|
|||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
FROM Subscription AS s
|
||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
||||
WHERE s.id = :subscriptionId
|
||||
WHERE s.id = :subscriptionId
|
||||
GROUP BY s.id
|
||||
""")
|
||||
fun get(subscriptionId: Long): SubscriptionWithMetadata?
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.upAppId, s.upConnectorToken,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
FROM Subscription AS s
|
||||
LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
|
||||
WHERE s.upConnectorToken = :connectorToken
|
||||
GROUP BY s.id
|
||||
""")
|
||||
fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata?
|
||||
|
||||
@Insert
|
||||
fun add(subscription: Subscription)
|
||||
|
||||
|
|
|
@ -36,6 +36,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
|||
return toSubscriptionList(subscriptionDao.list())
|
||||
}
|
||||
|
||||
fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
|
||||
return subscriptionDao
|
||||
.list()
|
||||
.map { Pair(it.id, it.instant) }.toSet()
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun getSubscription(subscriptionId: Long): Subscription? {
|
||||
|
@ -48,6 +54,12 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
|||
return toSubscription(subscriptionDao.get(baseUrl, topic))
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun getSubscriptionByConnectorToken(connectorToken: String): Subscription? {
|
||||
return toSubscription(subscriptionDao.getByConnectorToken(connectorToken))
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun addSubscription(subscription: Subscription) {
|
||||
|
@ -85,16 +97,13 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
|||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun addNotification(notification: Notification): NotificationAddResult {
|
||||
suspend fun addNotification(notification: Notification): Boolean {
|
||||
val maybeExistingNotification = notificationDao.get(notification.id)
|
||||
if (maybeExistingNotification == null) {
|
||||
notificationDao.add(notification)
|
||||
val detailsVisible = detailViewSubscriptionId.get() == notification.subscriptionId
|
||||
val muted = isMuted(notification.subscriptionId)
|
||||
val notify = !detailsVisible && !muted
|
||||
return NotificationAddResult(notify = notify, broadcast = true, muted = muted)
|
||||
if (maybeExistingNotification != null) {
|
||||
return false
|
||||
}
|
||||
return NotificationAddResult(notify = false, broadcast = false, muted = false)
|
||||
notificationDao.add(notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
|
@ -133,15 +142,60 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
|||
.apply()
|
||||
}
|
||||
|
||||
private suspend fun isMuted(subscriptionId: Long): Boolean {
|
||||
if (isGlobalMuted()) {
|
||||
return true
|
||||
fun setMinPriority(minPriority: Int) {
|
||||
if (minPriority <= 1) {
|
||||
sharedPrefs.edit()
|
||||
.remove(SHARED_PREFS_MIN_PRIORITY)
|
||||
.apply()
|
||||
} else {
|
||||
sharedPrefs.edit()
|
||||
.putInt(SHARED_PREFS_MIN_PRIORITY, minPriority)
|
||||
.apply()
|
||||
}
|
||||
val s = getSubscription(subscriptionId) ?: return true
|
||||
return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000)
|
||||
}
|
||||
|
||||
private fun isGlobalMuted(): Boolean {
|
||||
fun getMinPriority(): Int {
|
||||
return sharedPrefs.getInt(SHARED_PREFS_MIN_PRIORITY, 1) // 1/low means all priorities
|
||||
}
|
||||
|
||||
fun getBroadcastEnabled(): Boolean {
|
||||
return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default
|
||||
}
|
||||
|
||||
fun setBroadcastEnabled(enabled: Boolean) {
|
||||
sharedPrefs.edit()
|
||||
.putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getUnifiedPushEnabled(): Boolean {
|
||||
return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, true) // Enabled by default
|
||||
}
|
||||
|
||||
fun setUnifiedPushEnabled(enabled: Boolean) {
|
||||
sharedPrefs.edit()
|
||||
.putBoolean(SHARED_PREFS_UNIFIED_PUSH_ENABLED, enabled)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getUnifiedPushBaseUrl(): String? {
|
||||
return sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null)
|
||||
}
|
||||
|
||||
fun setUnifiedPushBaseUrl(baseUrl: String) {
|
||||
if (baseUrl == "") {
|
||||
sharedPrefs
|
||||
.edit()
|
||||
.remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL)
|
||||
.apply()
|
||||
} else {
|
||||
sharedPrefs.edit()
|
||||
.putString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, baseUrl)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun isGlobalMuted(): Boolean {
|
||||
val mutedUntil = getGlobalMutedUntil()
|
||||
return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000)
|
||||
}
|
||||
|
@ -177,6 +231,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
|||
topic = s.topic,
|
||||
instant = s.instant,
|
||||
mutedUntil = s.mutedUntil,
|
||||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
totalCount = s.totalCount,
|
||||
newCount = s.newCount,
|
||||
lastActive = s.lastActive,
|
||||
|
@ -195,6 +251,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
|||
topic = s.topic,
|
||||
instant = s.instant,
|
||||
mutedUntil = s.mutedUntil,
|
||||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
totalCount = s.totalCount,
|
||||
newCount = s.newCount,
|
||||
lastActive = s.lastActive,
|
||||
|
@ -224,17 +282,15 @@ class Repository(private val sharedPrefs: SharedPreferences, private val subscri
|
|||
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
|
||||
}
|
||||
|
||||
data class NotificationAddResult(
|
||||
val notify: Boolean,
|
||||
val broadcast: Boolean,
|
||||
val muted: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val SHARED_PREFS_ID = "MainPreferences"
|
||||
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
|
||||
const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
|
||||
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
|
||||
const val SHARED_PREFS_MIN_PRIORITY = "MinPriority"
|
||||
const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled"
|
||||
const val SHARED_PREFS_UNIFIED_PUSH_ENABLED = "UnifiedPushEnabled"
|
||||
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL"
|
||||
|
||||
private const val TAG = "NtfyRepository"
|
||||
private var instance: Repository? = null
|
||||
|
|
|
@ -13,8 +13,8 @@ import kotlinx.coroutines.GlobalScope
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The broadcast service is responsible for sending and receiving broadcasted intents
|
||||
* in order to facilitate taks app integrations.
|
||||
* The broadcast service is responsible for sending and receiving broadcast intents
|
||||
* in order to facilitate tasks app integrations.
|
||||
*/
|
||||
class BroadcastService(private val ctx: Context) {
|
||||
fun send(subscription: Subscription, notification: Notification, muted: Boolean) {
|
||||
|
@ -36,6 +36,10 @@ class BroadcastService(private val ctx: Context) {
|
|||
ctx.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* This receiver is triggered when the SEND_MESSAGE intent is received.
|
||||
* See AndroidManifest.xml for details.
|
||||
*/
|
||||
class BroadcastReceiver : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "Broadcast received: $intent")
|
||||
|
@ -46,24 +50,20 @@ class BroadcastService(private val ctx: Context) {
|
|||
|
||||
private fun send(ctx: Context, intent: Intent) {
|
||||
val api = ApiService()
|
||||
val baseUrl = intent.getStringExtra("base_url") ?: ctx.getString(R.string.app_base_url)
|
||||
val topic = intent.getStringExtra("topic") ?: return
|
||||
val message = intent.getStringExtra("message") ?: return
|
||||
val title = intent.getStringExtra("title") ?: ""
|
||||
val tags = intent.getStringExtra("tags") ?: ""
|
||||
val priority = if (intent.getStringExtra("priority") != null) {
|
||||
when (intent.getStringExtra("priority")) {
|
||||
"min", "1" -> 1
|
||||
"low", "2" -> 2
|
||||
"default", "3" -> 3
|
||||
"high", "4" -> 4
|
||||
"urgent", "max", "5" -> 5
|
||||
else -> 0
|
||||
}
|
||||
} else {
|
||||
intent.getIntExtra("priority", 0)
|
||||
val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url)
|
||||
val topic = getStringExtra(intent, "topic") ?: return
|
||||
val message = getStringExtra(intent, "message") ?: return
|
||||
val title = getStringExtra(intent, "title") ?: ""
|
||||
val tags = getStringExtra(intent,"tags") ?: ""
|
||||
val priority = when (getStringExtra(intent, "priority")) {
|
||||
"min", "1" -> 1
|
||||
"low", "2" -> 2
|
||||
"default", "3" -> 3
|
||||
"high", "4" -> 4
|
||||
"urgent", "max", "5" -> 5
|
||||
else -> 0
|
||||
}
|
||||
val delay = intent.getStringExtra("delay") ?: ""
|
||||
val delay = getStringExtra(intent,"delay") ?: ""
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
api.publish(
|
||||
baseUrl = baseUrl,
|
||||
|
@ -76,11 +76,26 @@ class BroadcastService(private val ctx: Context) {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an extra as a String value, even if the extra may be an int or a long.
|
||||
*/
|
||||
private fun getStringExtra(intent: Intent, name: String): String? {
|
||||
if (intent.getStringExtra(name) != null) {
|
||||
return intent.getStringExtra(name)
|
||||
} else if (intent.getIntExtra(name, DOES_NOT_EXIST) != DOES_NOT_EXIST) {
|
||||
return intent.getIntExtra(name, DOES_NOT_EXIST).toString()
|
||||
} else if (intent.getLongExtra(name, DOES_NOT_EXIST.toLong()) != DOES_NOT_EXIST.toLong()) {
|
||||
return intent.getLongExtra(name, DOES_NOT_EXIST.toLong()).toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfyBroadcastService"
|
||||
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
|
||||
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE" // If changed, change in manifest too!
|
||||
private const val DOES_NOT_EXIST = -2586000
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,9 +1,10 @@
|
|||
package io.heckel.ntfy.msg
|
||||
package io.heckel.ntfy.service
|
||||
|
||||
import android.util.Log
|
||||
import io.heckel.ntfy.data.ConnectionState
|
||||
import io.heckel.ntfy.data.Notification
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Call
|
|
@ -1,4 +1,4 @@
|
|||
package io.heckel.ntfy.msg
|
||||
package io.heckel.ntfy.service
|
||||
|
||||
import android.app.*
|
||||
import android.content.BroadcastReceiver
|
||||
|
@ -11,8 +11,6 @@ import android.os.SystemClock
|
|||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
|
@ -20,6 +18,8 @@ import io.heckel.ntfy.R
|
|||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.data.ConnectionState
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.ui.MainActivity
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import kotlinx.coroutines.*
|
||||
|
@ -58,10 +58,9 @@ class SubscriberService : Service() {
|
|||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isServiceStarted = false
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
|
||||
private val connections = ConcurrentHashMap<String, SubscriberConnection>() // Base URL -> Connection
|
||||
private val api = ApiService()
|
||||
private val notifier = NotificationService(this)
|
||||
private val broadcaster = BroadcastService(this)
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
|
||||
|
@ -71,8 +70,8 @@ class SubscriberService : Service() {
|
|||
val action = intent.action
|
||||
Log.d(TAG, "using an intent with action $action")
|
||||
when (action) {
|
||||
Actions.START.name -> startService()
|
||||
Actions.STOP.name -> stopService()
|
||||
Action.START.name -> startService()
|
||||
Action.STOP.name -> stopService()
|
||||
else -> Log.e(TAG, "This should never happen. No action in the received intent")
|
||||
}
|
||||
} else {
|
||||
|
@ -201,18 +200,13 @@ class SubscriberService : Service() {
|
|||
repository.updateState(subscriptionIds, state)
|
||||
}
|
||||
|
||||
private fun onNotificationReceived(subscription: Subscription, n: io.heckel.ntfy.data.Notification) {
|
||||
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.data.Notification) {
|
||||
val url = topicUrl(subscription.baseUrl, subscription.topic)
|
||||
Log.d(TAG, "[$url] Received notification: $n")
|
||||
Log.d(TAG, "[$url] Received notification: $notification")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val result = repository.addNotification(n)
|
||||
if (result.notify) {
|
||||
Log.d(TAG, "[$url] Showing notification: $n")
|
||||
notifier.send(subscription, n)
|
||||
}
|
||||
if (result.broadcast) {
|
||||
Log.d(TAG, "[$url] Broadcasting notification: $n")
|
||||
broadcaster.send(subscription, n, result.muted)
|
||||
if (repository.addNotification(notification)) {
|
||||
Log.d(TAG, "[$url] Dispatching notification $notification")
|
||||
dispatcher.dispatch(subscription, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -265,13 +259,7 @@ class SubscriberService : Service() {
|
|||
class BootStartReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "BootStartReceiver: onReceive called")
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED && readServiceState(context) == ServiceState.STARTED) {
|
||||
Intent(context, SubscriberService::class.java).also {
|
||||
it.action = Actions.START.name
|
||||
Log.d(TAG, "BootStartReceiver: Starting subscriber service")
|
||||
ContextCompat.startForegroundService(context, it)
|
||||
}
|
||||
}
|
||||
SubscriberServiceManager.refresh(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -282,27 +270,11 @@ class SubscriberService : Service() {
|
|||
class AutoRestartReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "AutoRestartReceiver: onReceive called")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val startServiceRequest = OneTimeWorkRequest.Builder(AutoRestartWorker::class.java).build()
|
||||
workManager.enqueue(startServiceRequest)
|
||||
SubscriberServiceManager.refresh(context)
|
||||
}
|
||||
}
|
||||
|
||||
class AutoRestartWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
override fun doWork(): Result {
|
||||
Log.d(TAG, "AutoRestartReceiver: doWork called for: " + this.getId())
|
||||
if (readServiceState(context) == ServiceState.STARTED) {
|
||||
Intent(context, SubscriberService::class.java).also {
|
||||
it.action = Actions.START.name
|
||||
Log.d(TAG, "AutoRestartReceiver: Starting subscriber service")
|
||||
ContextCompat.startForegroundService(context, it)
|
||||
}
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Actions {
|
||||
enum class Action {
|
||||
START,
|
||||
STOP
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,9 +4,6 @@ import android.app.AlertDialog
|
|||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_VIEW
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.util.Log
|
||||
|
@ -26,12 +23,12 @@ import io.heckel.ntfy.BuildConfig
|
|||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.data.Notification
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import io.heckel.ntfy.firebase.FirebaseMessenger
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
import io.heckel.ntfy.util.fadeStatusBarColor
|
||||
import io.heckel.ntfy.util.formatDateShort
|
||||
import kotlinx.coroutines.*
|
||||
|
@ -45,7 +42,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
private val repository by lazy { (application as Application).repository }
|
||||
private val api = ApiService()
|
||||
private val messenger = FirebaseMessenger()
|
||||
private var subscriberManager: SubscriberManager? = null // Context-dependent
|
||||
private var notifier: NotificationService? = null // Context-dependent
|
||||
private var appBaseUrl: String? = null // Context-dependent
|
||||
|
||||
|
@ -72,7 +68,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
Log.d(MainActivity.TAG, "Create $this")
|
||||
|
||||
// Dependencies that depend on Context
|
||||
subscriberManager = SubscriberManager(this)
|
||||
notifier = NotificationService(this)
|
||||
appBaseUrl = getString(R.string.app_base_url)
|
||||
|
||||
|
@ -149,7 +144,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
|
||||
// React to changes in fast delivery setting
|
||||
repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) {
|
||||
subscriberManager?.refreshService(it)
|
||||
SubscriberServiceManager.refresh(this)
|
||||
}
|
||||
|
||||
// Mark this subscription as "open" so we don't receive notifications for it
|
||||
|
@ -423,7 +418,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
val formattedDate = formatDateShort(subscriptionMutedUntil)
|
||||
notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,12 +20,11 @@ import io.heckel.ntfy.R
|
|||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.work.PollWorker
|
||||
import io.heckel.ntfy.firebase.FirebaseMessenger
|
||||
import io.heckel.ntfy.msg.BroadcastService
|
||||
import io.heckel.ntfy.msg.SubscriberService
|
||||
import io.heckel.ntfy.msg.*
|
||||
import io.heckel.ntfy.service.SubscriberService
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
import io.heckel.ntfy.util.fadeStatusBarColor
|
||||
import io.heckel.ntfy.util.formatDateShort
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -54,9 +53,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
// Other stuff
|
||||
private var actionMode: ActionMode? = null
|
||||
private var workManager: WorkManager? = null // Context-dependent
|
||||
private var notifier: NotificationService? = null // Context-dependent
|
||||
private var broadcaster: BroadcastService? = null // Context-dependent
|
||||
private var subscriberManager: SubscriberManager? = null // Context-dependent
|
||||
private var dispatcher: NotificationDispatcher? = null // Context-dependent
|
||||
private var appBaseUrl: String? = null // Context-dependent
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -67,9 +64,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
|
||||
// Dependencies that depend on Context
|
||||
workManager = WorkManager.getInstance(this)
|
||||
notifier = NotificationService(this)
|
||||
broadcaster = BroadcastService(this)
|
||||
subscriberManager = SubscriberManager(this)
|
||||
dispatcher = NotificationDispatcher(this, repository)
|
||||
appBaseUrl = getString(R.string.app_base_url)
|
||||
|
||||
// Action bar
|
||||
|
@ -92,7 +87,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) }
|
||||
|
||||
mainList = findViewById(R.id.main_subscriptions_list)
|
||||
adapter = MainAdapter(onSubscriptionClick, onSubscriptionLongClick)
|
||||
adapter = MainAdapter(repository, onSubscriptionClick, onSubscriptionLongClick)
|
||||
mainList.adapter = adapter
|
||||
|
||||
viewModel.list().observe(this) {
|
||||
|
@ -108,20 +103,20 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
}
|
||||
}
|
||||
|
||||
// React to changes in fast delivery setting
|
||||
// React to changes in instant delivery setting
|
||||
viewModel.listIdsWithInstantStatus().observe(this) {
|
||||
subscriberManager?.refreshService(it)
|
||||
SubscriberServiceManager.refresh(this)
|
||||
}
|
||||
|
||||
// Create notification channels right away, so we can configure them immediately after installing the app
|
||||
notifier!!.createNotificationChannels()
|
||||
dispatcher?.init()
|
||||
|
||||
// Subscribe to control Firebase channel (so we can re-start the foreground service if it dies)
|
||||
messenger.subscribe(ApiService.CONTROL_TOPIC)
|
||||
|
||||
// Background things
|
||||
startPeriodicPollWorker()
|
||||
startPeriodicAutoRestartWorker()
|
||||
startPeriodicServiceRefreshWorker()
|
||||
}
|
||||
|
||||
private fun startPeriodicPollWorker() {
|
||||
|
@ -146,7 +141,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
private fun startPeriodicAutoRestartWorker() {
|
||||
private fun startPeriodicServiceRefreshWorker() {
|
||||
val workerVersion = repository.getAutoRestartWorkerVersion()
|
||||
val workPolicy = if (workerVersion == SubscriberService.AUTO_RESTART_WORKER_VERSION) {
|
||||
Log.d(TAG, "Auto restart worker version matches: choosing KEEP as existing work policy")
|
||||
|
@ -156,12 +151,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
repository.setAutoRestartWorkerVersion(SubscriberService.AUTO_RESTART_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SubscriberService.AutoRestartWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES)
|
||||
val work = PeriodicWorkRequestBuilder<SubscriberServiceManager.RefreshWorker>(MINIMUM_PERIODIC_WORKER_INTERVAL, TimeUnit.MINUTES)
|
||||
.addTag(SubscriberService.TAG)
|
||||
.addTag(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "Auto restart worker: Scheduling period work every ${MINIMUM_PERIODIC_WORKER_INTERVAL} minutes")
|
||||
workManager!!.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
Log.d(TAG, "Auto restart worker: Scheduling period work every $MINIMUM_PERIODIC_WORKER_INTERVAL minutes")
|
||||
workManager?.enqueueUniquePeriodicWork(SubscriberService.AUTO_RESTART_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
|
@ -174,7 +169,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
|
||||
private fun startNotificationMutedChecker() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
delay(1000) // Just to be sure we've initialized all the things, we wait a bit ...
|
||||
delay(5000) // Just to be sure we've initialized all the things, we wait a bit ...
|
||||
while (isActive) {
|
||||
Log.d(DetailActivity.TAG, "Checking global and subscription-specific 'muted until' timestamp")
|
||||
|
||||
|
@ -235,6 +230,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
onNotificationSettingsClick(enable = true)
|
||||
true
|
||||
}
|
||||
R.id.main_menu_settings -> {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
true
|
||||
}
|
||||
R.id.main_menu_source -> {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
|
||||
true
|
||||
|
@ -262,6 +261,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
repository.setGlobalMutedUntil(mutedUntilTimestamp)
|
||||
showHideNotificationMenuItems()
|
||||
runOnUiThread {
|
||||
redrawList() // Update the "muted until" icons
|
||||
when (mutedUntilTimestamp) {
|
||||
0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
|
||||
1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
|
||||
|
@ -288,6 +288,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
topic = topic,
|
||||
instant = instant,
|
||||
mutedUntil = 0,
|
||||
upAppId = null,
|
||||
upConnectorToken = null,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = Date().time/1000
|
||||
|
@ -317,11 +319,21 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
private fun onSubscriptionItemClick(subscription: Subscription) {
|
||||
if (actionMode != null) {
|
||||
handleActionModeClick(subscription)
|
||||
} else if (subscription.upAppId != null) { // Not UnifiedPush
|
||||
displayUnifiedPushToast(subscription)
|
||||
} else {
|
||||
startDetailView(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayUnifiedPushToast(subscription: Subscription) {
|
||||
runOnUiThread {
|
||||
val appId = subscription.upAppId ?: return@runOnUiThread
|
||||
val toastMessage = getString(R.string.main_unified_push_toast, appId)
|
||||
Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubscriptionItemLongClick(subscription: Subscription) {
|
||||
if (actionMode == null) {
|
||||
beginActionMode(subscription)
|
||||
|
@ -341,12 +353,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
newNotifications.forEach { notification ->
|
||||
newNotificationsCount++
|
||||
val notificationWithId = notification.copy(notificationId = Random.nextInt())
|
||||
val result = repository.addNotification(notificationWithId)
|
||||
if (result.notify) {
|
||||
notifier?.send(subscription, notificationWithId)
|
||||
}
|
||||
if (result.broadcast) {
|
||||
broadcaster?.send(subscription, notification, result.muted)
|
||||
if (repository.addNotification(notificationWithId)) {
|
||||
dispatcher?.dispatch(subscription, notificationWithId)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -422,7 +430,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
val dialog = builder
|
||||
.setMessage(R.string.main_action_mode_delete_dialog_message)
|
||||
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
|
||||
adapter.selected.map { viewModel.remove(it) }
|
||||
adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) }
|
||||
finishActionMode()
|
||||
}
|
||||
.setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ ->
|
||||
|
|
|
@ -10,12 +10,13 @@ import androidx.recyclerview.widget.ListAdapter
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.data.ConnectionState
|
||||
import io.heckel.ntfy.data.Repository
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
|
||||
class MainAdapter(private val repository: Repository, private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
|
||||
ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
|
||||
val selected = mutableSetOf<Long>() // Subscription IDs
|
||||
|
||||
|
@ -23,7 +24,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.fragment_main_item, parent, false)
|
||||
return SubscriptionViewHolder(view, selected, onClick, onLongClick)
|
||||
return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick)
|
||||
}
|
||||
|
||||
/* Gets current topic and uses it to bind view. */
|
||||
|
@ -41,7 +42,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
|||
}
|
||||
|
||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
||||
class SubscriptionViewHolder(itemView: View, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
|
||||
class SubscriptionViewHolder(itemView: View, private val repository: Repository, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
private var subscription: Subscription? = null
|
||||
private val context: Context = itemView.context
|
||||
|
@ -55,7 +56,10 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
|||
|
||||
fun bind(subscription: Subscription) {
|
||||
this.subscription = subscription
|
||||
var statusMessage = if (subscription.totalCount == 1) {
|
||||
val isUnifiedPush = subscription.upAppId != null
|
||||
var statusMessage = if (isUnifiedPush) {
|
||||
context.getString(R.string.main_item_status_unified_push, subscription.upAppId)
|
||||
} else if (subscription.totalCount == 1) {
|
||||
context.getString(R.string.main_item_status_text_one, subscription.totalCount)
|
||||
} else {
|
||||
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
|
||||
|
@ -76,17 +80,21 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
|
|||
} else {
|
||||
dateStr
|
||||
}
|
||||
val globalMutedUntil = repository.getGlobalMutedUntil()
|
||||
val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && !isUnifiedPush
|
||||
val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && !isUnifiedPush
|
||||
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||
statusView.text = statusMessage
|
||||
dateView.text = dateText
|
||||
notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
|
||||
notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
|
||||
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
|
||||
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
|
||||
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
|
||||
instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
|
||||
if (subscription.newCount > 0) {
|
||||
if (isUnifiedPush || subscription.newCount == 0) {
|
||||
newItemsView.visibility = View.GONE
|
||||
} else {
|
||||
newItemsView.visibility = View.VISIBLE
|
||||
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
|
||||
} else {
|
||||
newItemsView.visibility = View.GONE
|
||||
}
|
||||
itemView.setOnClickListener { onClick(subscription) }
|
||||
itemView.setOnLongClickListener { onLongClick(subscription); true }
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.heckel.ntfy.data.*
|
||||
import io.heckel.ntfy.up.Distributor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.collections.List
|
||||
|
@ -22,7 +24,12 @@ class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
|
|||
repository.addSubscription(subscription)
|
||||
}
|
||||
|
||||
fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
|
||||
fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
|
||||
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
|
||||
if (subscription.upAppId != null && subscription.upConnectorToken != null) {
|
||||
val distributor = Distributor(context)
|
||||
distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken)
|
||||
}
|
||||
repository.removeAllNotifications(subscriptionId)
|
||||
repository.removeSubscription(subscriptionId)
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.app.AlertDialog
|
|||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.RadioButton
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
@ -16,8 +17,9 @@ import kotlinx.coroutines.launch
|
|||
import java.util.*
|
||||
|
||||
class NotificationFragment : DialogFragment() {
|
||||
var settingsListener: NotificationSettingsListener? = null
|
||||
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var settingsListener: NotificationSettingsListener
|
||||
private lateinit var muteFor30minButton: RadioButton
|
||||
private lateinit var muteFor1hButton: RadioButton
|
||||
private lateinit var muteFor2hButton: RadioButton
|
||||
|
@ -31,7 +33,9 @@ class NotificationFragment : DialogFragment() {
|
|||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
settingsListener = activity as NotificationSettingsListener
|
||||
if (settingsListener == null) {
|
||||
settingsListener = activity as NotificationSettingsListener
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
|
@ -85,7 +89,7 @@ class NotificationFragment : DialogFragment() {
|
|||
private fun onClick(mutedUntilTimestamp: Long) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
delay(150) // Another hack: Let the animation finish before dismissing the window
|
||||
settingsListener.onNotificationMutedUntilChanged(mutedUntilTimestamp)
|
||||
settingsListener?.onNotificationMutedUntilChanged(mutedUntilTimestamp)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
|
188
app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
Normal file
188
app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
113
app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
Normal file
113
app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt
Normal 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
|
||||
}
|
||||
}
|
22
app/src/main/java/io/heckel/ntfy/up/Constants.kt
Normal file
22
app/src/main/java/io/heckel/ntfy/up/Constants.kt
Normal 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"
|
53
app/src/main/java/io/heckel/ntfy/up/Distributor.kt
Normal file
53
app/src/main/java/io/heckel/ntfy/up/Distributor.kt
Normal 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"
|
||||
}
|
||||
}
|
|
@ -5,10 +5,12 @@ import android.animation.ValueAnimator
|
|||
import android.view.Window
|
||||
import io.heckel.ntfy.data.Notification
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import java.security.SecureRandom
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
|
||||
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
|
||||
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
|
||||
fun topicUrlJsonPoll(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/json?poll=1"
|
||||
fun topicShortUrl(baseUrl: String, topic: String) =
|
||||
|
@ -26,6 +28,17 @@ fun toPriority(priority: Int?): Int {
|
|||
else return 3
|
||||
}
|
||||
|
||||
fun toPriorityString(priority: Int): String {
|
||||
return when (priority) {
|
||||
1 -> "min"
|
||||
2 -> "low"
|
||||
3 -> "default"
|
||||
4 -> "high"
|
||||
5 -> "max"
|
||||
else -> "default"
|
||||
}
|
||||
}
|
||||
|
||||
fun joinTags(tags: List<String>?): String {
|
||||
return tags?.joinToString(",") ?: ""
|
||||
}
|
||||
|
@ -101,3 +114,15 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
|
|||
}
|
||||
statusBarColorAnimation.start()
|
||||
}
|
||||
|
||||
// Generates a (cryptographically secure) random string of a certain length
|
||||
fun randomString(len: Int): String {
|
||||
val random = SecureRandom()
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray()
|
||||
return (1..len).map { chars[random.nextInt(chars.size)] }.joinToString("")
|
||||
}
|
||||
|
||||
// Allows letting multiple variables at once, see https://stackoverflow.com/a/35522422/1440785
|
||||
inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
|
||||
return if (p1 != null && p2 != null) block(p1, p2) else null
|
||||
}
|
||||
|
|
|
@ -7,8 +7,10 @@ import androidx.work.WorkerParameters
|
|||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.data.Database
|
||||
import io.heckel.ntfy.data.Repository
|
||||
import io.heckel.ntfy.firebase.FirebaseService
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.BroadcastService
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -25,8 +27,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||
val database = Database.getInstance(applicationContext)
|
||||
val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
|
||||
val notifier = NotificationService(applicationContext)
|
||||
val broadcaster = BroadcastService(applicationContext)
|
||||
val dispatcher = NotificationDispatcher(applicationContext, repository)
|
||||
val api = ApiService()
|
||||
|
||||
repository.getSubscriptions().forEach{ subscription ->
|
||||
|
@ -36,12 +37,8 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||
.onlyNewNotifications(subscription.id, notifications)
|
||||
.map { it.copy(notificationId = Random.nextInt()) }
|
||||
newNotifications.forEach { notification ->
|
||||
val result = repository.addNotification(notification)
|
||||
if (result.notify) {
|
||||
notifier.send(subscription, notification)
|
||||
}
|
||||
if (result.broadcast) {
|
||||
broadcaster.send(subscription, notification, result.muted)
|
||||
if (repository.addNotification(notification)) {
|
||||
dispatcher.dispatch(subscription, notification)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
9
app/src/main/res/layout/activity_settings.xml
Normal file
9
app/src/main/res/layout/activity_settings.xml
Normal 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>
|
|
@ -24,12 +24,13 @@
|
|||
android:textColor="@color/primaryTextColor" android:layout_marginTop="10dp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image"/>
|
||||
<TextView
|
||||
android:text="89 notifications"
|
||||
android:layout_width="wrap_content"
|
||||
android:text="89 notifications, reconnecting ... This may wrap in the case of UnifiedPush"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content" android:id="@+id/main_item_status"
|
||||
app:layout_constraintStart_toStartOf="@+id/main_item_text"
|
||||
app:layout_constraintTop_toBottomOf="@+id/main_item_text" app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
android:layout_marginBottom="10dp" app:layout_constrainedWidth="true"
|
||||
app:layout_constraintEnd_toStartOf="@id/main_item_new" android:layout_marginEnd="10dp"/>
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp"
|
||||
|
|
|
@ -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>
|
|
@ -5,6 +5,7 @@
|
|||
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"/>
|
||||
<item android:id="@+id/main_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever"
|
||||
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/>
|
||||
<item android:id="@+id/main_menu_settings" android:title="@string/main_menu_settings_title"/>
|
||||
<item android:id="@+id/main_menu_source" android:title="@string/main_menu_source_title"/>
|
||||
<item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/>
|
||||
</menu>
|
||||
|
|
|
@ -30,8 +30,9 @@
|
|||
<string name="main_menu_notifications_enabled">Notifications enabled</string>
|
||||
<string name="main_menu_notifications_disabled_forever">Notifications disabled</string>
|
||||
<string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</string>
|
||||
<string name="main_menu_settings_title">Settings</string>
|
||||
<string name="main_menu_source_title">Report a bug</string>
|
||||
<string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
|
||||
<string name="main_menu_source_url">https://github.com/binwiederhier/ntfy/issues</string>
|
||||
<string name="main_menu_website_title">Visit ntfy.sh</string>
|
||||
|
||||
<!-- Main activity: Action mode -->
|
||||
|
@ -47,6 +48,7 @@
|
|||
<string name="main_item_status_text_one">%1$d notification</string>
|
||||
<string name="main_item_status_text_not_one">%1$d notifications</string>
|
||||
<string name="main_item_status_reconnecting">reconnecting …</string>
|
||||
<string name="main_item_status_unified_push">%1$s (UnifiedPush)</string>
|
||||
<string name="main_item_date_yesterday">Yesterday</string>
|
||||
<string name="main_add_button_description">Add subscription</string>
|
||||
<string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
|
||||
|
@ -56,6 +58,7 @@
|
|||
</string>
|
||||
<string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
|
||||
</string>
|
||||
<string name="main_unified_push_toast">This subscription is managed by %1$s via UnifiedPush</string>
|
||||
|
||||
<!-- Add dialog -->
|
||||
<string name="add_dialog_title">Subscribe to topic</string>
|
||||
|
@ -80,10 +83,13 @@
|
|||
<!-- Detail activity -->
|
||||
<string name="detail_deep_link_subscribed_toast_message">Subscribed to topic %1$s</string>
|
||||
<string name="detail_no_notifications_text">You haven\'t received any notifications for this topic yet.</string>
|
||||
<string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.</string>
|
||||
<string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.
|
||||
</string>
|
||||
<string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string>
|
||||
<string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
|
||||
<string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?</string>
|
||||
<string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
|
||||
</string>
|
||||
<string name="detail_clear_dialog_message">Do you really want to delete all of the notifications in this topic?
|
||||
</string>
|
||||
<string name="detail_clear_dialog_permanently_delete">Permanently delete</string>
|
||||
<string name="detail_clear_dialog_cancel">Cancel</string>
|
||||
<string name="detail_delete_dialog_message">
|
||||
|
@ -93,7 +99,9 @@
|
|||
<string name="detail_delete_dialog_permanently_delete">Permanently delete</string>
|
||||
<string name="detail_delete_dialog_cancel">Cancel</string>
|
||||
<string name="detail_test_title">Test: You can set a title if you like</string>
|
||||
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d. If you send another one, it may look different.</string>
|
||||
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It has a priority of %1$d.
|
||||
If you send another one, it may look different.
|
||||
</string>
|
||||
<string name="detail_test_message_error">Could not send test message: %1$s</string>
|
||||
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
|
||||
<string name="detail_instant_delivery_enabled">Instant delivery enabled</string>
|
||||
|
@ -135,4 +143,42 @@
|
|||
<string name="notification_dialog_8h">8 hours</string>
|
||||
<string name="notification_dialog_tomorrow">Until tomorrow</string>
|
||||
<string name="notification_dialog_forever">Forever</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_notifications_header">Notifications</string>
|
||||
<string name="settings_notifications_muted_until_key">MutedUntil</string>
|
||||
<string name="settings_notifications_muted_until_title">Pause notifications</string>
|
||||
<string name="settings_notifications_muted_until_enabled">All notifications will be displayed</string>
|
||||
<string name="settings_notifications_muted_until_disabled_forever">Notifications muted until re-enabled</string>
|
||||
<string name="settings_notifications_muted_until_disabled_until">Notifications muted until %1$s</string>
|
||||
<string name="settings_notifications_min_priority_key">MinPriority</string>
|
||||
<string name="settings_notifications_min_priority_title">Minimum priority</string>
|
||||
<string name="settings_notifications_min_priority_summary_any">Notifications of all priorities are shown</string>
|
||||
<string name="settings_notifications_min_priority_summary_x_or_higher">Show notifications if priority is %1$d (%2$s) or higher</string>
|
||||
<string name="settings_notifications_min_priority_summary_max">Show notifications if priority is 5 (max)</string>
|
||||
<string name="settings_notifications_min_priority_min">Any priority</string>
|
||||
<string name="settings_notifications_min_priority_low">Low priority and higher</string>
|
||||
<string name="settings_notifications_min_priority_default">Default priority and higher</string>
|
||||
<string name="settings_notifications_min_priority_high">High priority and higher</string>
|
||||
<string name="settings_notifications_min_priority_max">Only max priority</string>
|
||||
<string name="settings_unified_push_header">UnifiedPush</string>
|
||||
<string name="settings_unified_push_header_summary">Allows other apps to use ntfy as a message distributor. Find out more at unifiedpush.org.</string>
|
||||
<string name="settings_unified_push_enabled_key">UnifiedPushEnabled</string>
|
||||
<string name="settings_unified_push_enabled_title">Allow distributor use</string>
|
||||
<string name="settings_unified_push_enabled_summary_on">Apps can use ntfy as distributor</string>
|
||||
<string name="settings_unified_push_enabled_summary_off">Apps cannot use ntfy as distributor</string>
|
||||
<string name="settings_unified_push_base_url_key">UnifiedPushBaseURL</string>
|
||||
<string name="settings_unified_push_base_url_title">Server URL</string>
|
||||
<string name="settings_unified_push_base_url_default_summary">%1$s (default)</string>
|
||||
<string name="settings_advanced_header">Advanced</string>
|
||||
<string name="settings_advanced_broadcast_key">BroadcastEnabled</string>
|
||||
<string name="settings_advanced_broadcast_title">Broadcast messages</string>
|
||||
<string name="settings_advanced_broadcast_summary_enabled">Apps can receive incoming notifications as broadcasts</string>
|
||||
<string name="settings_advanced_broadcast_summary_disabled">Apps cannot receive notifications as broadcasts</string>
|
||||
<string name="settings_about_header">About</string>
|
||||
<string name="settings_about_version_key">Version</string>
|
||||
<string name="settings_about_version_title">Version</string>
|
||||
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
|
||||
<string name="settings_about_version_copied_to_clipboard_message">Copied to clipboard</string>
|
||||
</resources>
|
||||
|
|
17
app/src/main/res/values/values.xml
Normal file
17
app/src/main/res/values/values.xml
Normal 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>
|
41
app/src/main/res/xml/main_preferences.xml
Normal file
41
app/src/main/res/xml/main_preferences.xml
Normal 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>
|
|
@ -7,10 +7,8 @@ import com.google.firebase.messaging.RemoteMessage
|
|||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.data.Notification
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.BroadcastService
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.msg.SubscriberService
|
||||
import io.heckel.ntfy.msg.*
|
||||
import io.heckel.ntfy.service.SubscriberService
|
||||
import io.heckel.ntfy.util.toPriority
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
@ -19,9 +17,8 @@ import kotlin.random.Random
|
|||
|
||||
class FirebaseService : FirebaseMessagingService() {
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
|
||||
private val job = SupervisorJob()
|
||||
private val notifier = NotificationService(this)
|
||||
private val broadcaster = BroadcastService(this)
|
||||
private val messenger = FirebaseMessenger()
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
|
@ -81,16 +78,9 @@ class FirebaseService : FirebaseMessagingService() {
|
|||
tags = tags ?: "",
|
||||
deleted = false
|
||||
)
|
||||
val result = repository.addNotification(notification)
|
||||
|
||||
// Send notification (only if it's not already known)
|
||||
if (result.notify) {
|
||||
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
|
||||
notifier.send(subscription, notification)
|
||||
}
|
||||
if (result.broadcast) {
|
||||
Log.d(TAG, "Sending broadcast for message: from=${remoteMessage.from}, data=${data}")
|
||||
broadcaster.send(subscription, notification, result.muted)
|
||||
if (repository.addNotification(notification)) {
|
||||
Log.d(TAG, "Dispatching notification for message: from=${remoteMessage.from}, data=${data}")
|
||||
dispatcher.dispatch(subscription, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue