End to end, dabbling

This commit is contained in:
Philipp Heckel 2022-08-18 21:11:29 -04:00
parent 60f90667d9
commit a4461bf47f
18 changed files with 239 additions and 32 deletions

View file

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "9363ad5196e88862acceb1bb9ee91124",
"identityHash": "250db1985385d64d880124071eab96fc",
"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, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `encryptionKey` BLOB, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@ -79,6 +79,12 @@
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "encryptionKey",
"columnName": "encryptionKey",
"affinity": "BLOB",
"notNull": false
}
],
"primaryKey": {
@ -326,7 +332,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9363ad5196e88862acceb1bb9ee91124')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '250db1985385d64d880124071eab96fc')"
]
}
}

View file

@ -2,6 +2,7 @@ package io.heckel.ntfy.backup
import android.content.Context
import android.net.Uri
import android.util.Base64
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.stream.JsonReader
@ -102,6 +103,7 @@ class Backuper(val context: Context) {
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
displayName = s.displayName,
encryptionKey = Base64.decode(s.encryptionKey, Base64.DEFAULT)
))
} catch (e: Exception) {
Log.w(TAG, "Unable to restore subscription ${s.id} (${topicUrl(s.baseUrl, s.topic)}): ${e.message}. Ignoring.", e)
@ -226,7 +228,8 @@ class Backuper(val context: Context) {
icon = s.icon,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
displayName = s.displayName
displayName = s.displayName,
encryptionKey = if (s.encryptionKey != null) Base64.encodeToString(s.encryptionKey, Base64.DEFAULT) else null
)
}
}
@ -334,7 +337,8 @@ data class Subscription(
val icon: String?,
val upAppId: String?,
val upConnectorToken: String?,
val displayName: String?
val displayName: String?,
val encryptionKey: String? // as base64
)
data class Notification(
@ -343,7 +347,7 @@ data class Notification(
val timestamp: Long,
val title: String,
val message: String,
val encoding: String, // "base64" or ""
val encoding: String, // "base64", "jwe", or ""
val priority: Int, // 1=min, 3=default, 5=max
val tags: String,
val click: String, // URL/intent to open on notification click

View file

@ -23,13 +23,14 @@ data class Subscription(
@ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
@ColumnInfo(name = "displayName") val displayName: String?,
@ColumnInfo(name = "encryptionKey") val encryptionKey: ByteArray?,
@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, minPriority: Int, autoDelete: Long, lastNotificationId: String, icon: String, upAppId: String, upConnectorToken: String, displayName: String?) :
this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, lastNotificationId, icon, upAppId, upConnectorToken, displayName, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, minPriority: Int, autoDelete: Long, lastNotificationId: String, icon: String, upAppId: String, upConnectorToken: String, displayName: String?, encryptionKey: ByteArray?) :
this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, lastNotificationId, icon, upAppId, upConnectorToken, displayName, encryptionKey, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
}
enum class ConnectionState {
@ -49,6 +50,7 @@ data class SubscriptionWithMetadata(
val upAppId: String?,
val upConnectorToken: String?,
val displayName: String?,
val encryptionKey: ByteArray?,
val totalCount: Int,
val newCount: Int,
val lastActive: Long
@ -61,7 +63,7 @@ data class Notification(
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "message") val message: String,
@ColumnInfo(name = "encoding") val encoding: String, // "base64" or ""
@ColumnInfo(name = "encoding") val encoding: String, // "" (raw UTF-8), "base64", or "jwe" (encryption)
@ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
@ColumnInfo(name = "tags") val tags: String,
@ -269,6 +271,8 @@ abstract class Database : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT")
db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT")
db.execSQL("ALTER TABLE Subscription ADD COLUMN encryptionKey BLOB")
db.execSQL("ALTER TABLE Notification ADD COLUMN encryption TEXT NOT NULL DEFAULT('')")
}
}
}
@ -278,7 +282,7 @@ abstract class Database : RoomDatabase() {
interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -291,7 +295,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -304,7 +308,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -317,7 +321,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -330,7 +334,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive

View file

@ -385,6 +385,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
displayName = s.displayName,
encryptionKey = s.encryptionKey,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,
@ -410,6 +411,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
displayName = s.displayName,
encryptionKey = s.encryptionKey,
totalCount = s.totalCount,
newCount = s.newCount,
lastActive = s.lastActive,

View file

@ -3,6 +3,7 @@ package io.heckel.ntfy.service
import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.*
import okhttp3.Call
@ -42,7 +43,8 @@ class JsonConnection(
since = notification.id
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify
val subscription = repository.getSubscription(subscriptionId) ?: return@notify
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
val notificationDecrypted = Encryption.maybeDecrypt(subscription, notification)
val notificationWithSubscriptionId = notificationDecrypted.copy(subscriptionId = subscription.id)
notificationListener(subscription, notificationWithSubscriptionId)
}
val failed = AtomicBoolean(false)

View file

@ -7,6 +7,7 @@ import android.os.Looper
import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.ApiService.Companion.requestBuilder
import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.util.topicUrlWs
@ -144,7 +145,8 @@ class WsConnection(
val notification = notificationWithTopic.notification
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@synchronize
val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
val notificationDecrypted = Encryption.maybeDecrypt(subscription, notification)
val notificationWithSubscriptionId = notificationDecrypted.copy(subscriptionId = subscription.id)
notificationListener(subscription, notificationWithSubscriptionId)
since.set(notification.id)
}

View file

@ -118,6 +118,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
upAppId = null,
upConnectorToken = null,
displayName = null,
encryptionKey = null,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
@ -133,7 +134,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
// Fetch cached messages
try {
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
val notifications = api
.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
.map { n -> Encryption.maybeDecrypt(subscription, n) }
notifications.forEach { notification -> repository.addNotification(notification) }
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
@ -466,7 +469,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
try {
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
val notifications = api
.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
.map { n -> Encryption.maybeDecrypt(subscription, n) }
val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
val toastMessage = if (newNotifications.isEmpty()) {
getString(R.string.refresh_message_no_results)

View file

@ -4,7 +4,6 @@ import android.content.ContentResolver
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.text.TextUtils
@ -111,6 +110,7 @@ class DetailSettingsActivity : AppCompatActivity() {
loadMutedUntilPref()
loadMinPriorityPref()
loadAutoDeletePref()
loadPasswordPref()
loadIconSetPref()
loadIconRemovePref()
} else {
@ -254,6 +254,30 @@ class DetailSettingsActivity : AppCompatActivity() {
}
}
private fun loadPasswordPref() {
val prefId = context?.getString(R.string.detail_settings_notifications_password_key) ?: return
val pref: EditTextPreference? = findPreference(prefId)
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
pref?.text = ""
pref?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val newPassword = value ?: return
val encryptionKey = if (newPassword.trim().isEmpty()) null else Encryption.deriveKey(newPassword, topicUrl(subscription))
save(subscription.copy(encryptionKey = encryptionKey))
}
override fun getString(key: String?, defValue: String?): String {
return ""
}
}
pref?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref ->
if (TextUtils.isEmpty(pref.text)) {
"No password set"
} else {
"Password saved"
}
}
}
private fun loadIconSetPref() {
val prefId = context?.getString(R.string.detail_settings_appearance_icon_set_key) ?: return
iconSetPref = findPreference(prefId) ?: return

View file

@ -43,7 +43,6 @@ import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.security.SecureRandom
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.random.Random
@ -206,6 +205,20 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
schedulePeriodicPollWorker()
schedulePeriodicServiceRestartWorker()
schedulePeriodicDeleteWorker()
testenc()
}
fun testenc() {
try {
val key = Encryption.deriveKey("secr3t password", "https://ntfy.sh/mysecret")
Log.d("encryption", "key ${key.toHex()}")
val ciphertext = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..vbe1Qv_-mKYbUgce.EfmOUIUi7lxXZG_o4bqXZ9pmpr1Rzs4Y5QLE2XD2_aw_SQ.y2hadrN5b2LEw7_PJHhbcA"
val plaintext = Encryption.decrypt(ciphertext, key)
Log.d("encryption", "decryptString: $plaintext")
} catch (e: Exception) {
Log.e("encryption", "failed", e)
}
}
override fun onResume() {
@ -439,6 +452,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
upAppId = null,
upConnectorToken = null,
displayName = null,
encryptionKey = null,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000
@ -455,7 +469,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
lifecycleScope.launch(Dispatchers.IO) {
try {
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
val notifications = api
.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
.map { n -> Encryption.maybeDecrypt(subscription, n) }
notifications.forEach { notification -> repository.addNotification(notification) }
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
@ -492,7 +508,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
Log.d(TAG, "subscription: ${subscription}")
try {
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
val notifications = api
.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
.map { n -> Encryption.maybeDecrypt(subscription, n) }
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
newNotificationsCount++

View file

@ -80,6 +80,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
upAppId = appId,
upConnectorToken = connectorToken,
displayName = null,
encryptionKey = null,
totalCount = 0,
newCount = 0,
lastActive = Date().time/1000

View file

@ -0,0 +1,68 @@
package io.heckel.ntfy.util
import android.util.Base64
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.msg.NotificationParser
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
object Encryption {
private const val TAG = "NtfyEncryption"
private const val keyDerivIter = 50000
private const val keyLenBits = 256
private const val gcmTagLenBits = 128
private const val encodingJwe = "jwe"
private val parser = NotificationParser()
fun maybeDecrypt(subscription: Subscription, notification: Notification): Notification {
if (notification.encoding != encodingJwe) {
return notification
} else if (subscription.encryptionKey == null) {
Log.w(TAG, "Notification is encrypted, but key is missing: $notification; leaving encrypted message intact")
return notification
}
return try {
val plaintext = decrypt(notification.message, subscription.encryptionKey)
val decryptedNotification = parser.parse(plaintext) ?: throw Exception("Cannot parse decrypted message: $plaintext")
if (decryptedNotification.id != notification.id || decryptedNotification.timestamp != notification.timestamp) {
throw Exception("Message ID or timestamp mismatch in decrypted message: $plaintext")
}
decryptedNotification
} catch (e: Exception) {
Log.w(TAG, "Unable to decrypt message, falling back to original", e)
notification
}
}
fun deriveKey(password: String, topicUrl: String): ByteArray {
val salt = MessageDigest.getInstance("SHA-256").digest(topicUrl.toByteArray())
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = PBEKeySpec(password.toCharArray(), salt, keyDerivIter, keyLenBits)
return factory.generateSecret(spec).encoded
}
fun decrypt(input: String, key: ByteArray): String {
val parts = input.split(".")
if (parts.size != 5 || parts[1] != "") {
throw Exception("Unexpected format")
}
val encodedHeader = parts[0]
val iv = Base64.decode(parts[2], Base64.URL_SAFE)
val ciphertext = Base64.decode(parts[3], Base64.URL_SAFE)
val tag = Base64.decode(parts[4], Base64.URL_SAFE)
val ciphertextWithTag = ciphertext + tag
val secretKeySpec = SecretKeySpec(key, "AES")
val gcmParameterSpec = GCMParameterSpec(gcmTagLenBits, iv)
val cipher: Cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmParameterSpec)
cipher.updateAAD(encodedHeader.toByteArray())
return String(cipher.doFinal(ciphertextWithTag))
}
}

View file

@ -46,6 +46,7 @@ import kotlin.math.abs
import kotlin.math.absoluteValue
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
fun topicUrl(subscription: Subscription) = topicUrl(subscription.baseUrl, subscription.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 topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"

View file

@ -7,6 +7,7 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -40,13 +41,15 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
subscriptions.forEach{ subscription ->
try {
val user = repository.getUser(subscription.baseUrl)
val notifications = api.poll(
subscriptionId = subscription.id,
baseUrl = subscription.baseUrl,
topic = subscription.topic,
user = user,
since = subscription.lastNotificationId
)
val notifications = api
.poll(
subscriptionId = subscription.id,
baseUrl = subscription.baseUrl,
topic = subscription.topic,
user = user,
since = subscription.lastNotificationId
)
.map { n -> Encryption.maybeDecrypt(subscription, n) }
val newNotifications = repository
.onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) }

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
ntfy EDIT:
This is a slightly edited copy of the original Android project layout
to reduce the marginBottom of the message to something reasonable (was: 48dp).
~ 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
-->
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="48dp"
android:layout_marginBottom="48dp"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@android:id/message"
style="?android:attr/textAppearanceSmall"
android:layout_marginLeft="24dp"
android:layout_marginRight="24dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="2dp"
android:layout_marginTop="4dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorSecondary"/>
<EditText
android:id="@android:id/edit"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight = "48dp"
android:inputType="textPassword"/>
</LinearLayout>
</ScrollView>

View file

@ -347,6 +347,8 @@
<string name="detail_settings_notifications_instant_title">Instant delivery</string>
<string name="detail_settings_notifications_instant_summary_on">Notifications are delivered instantly. Requires a foreground service and consumes more battery.</string>
<string name="detail_settings_notifications_instant_summary_off">Notifications are delivered using Firebase. Delivery may be delayed, but consumes less battery.</string>
<string name="detail_settings_notifications_password_title">Password</string>
<string name="detail_settings_notifications_password_dialog_summary">Choose a password to use to decrypt incoming end-to-end encrypted notifications. The derived key will be stored locally. If the password is incorrect, messages will not decrypt correctly.</string>
<string name="detail_settings_appearance_header">Appearance</string>
<string name="detail_settings_appearance_icon_set_title">Subscription icon</string>
<string name="detail_settings_appearance_icon_set_summary">Set an icon to be displayed in notifications</string>

View file

@ -35,6 +35,7 @@
<string name="detail_settings_notifications_muted_until_key" translatable="false">SubscriptionMutedUntil</string>
<string name="detail_settings_notifications_min_priority_key" translatable="false">SubscriptionMinPriority</string>
<string name="detail_settings_notifications_auto_delete_key" translatable="false">SubscriptionAutoDelete</string>
<string name="detail_settings_notifications_password_key" translatable="false">SubscriptionPassword</string>
<string name="detail_settings_appearance_header_key" translatable="false">SubscriptionAppearance</string>
<string name="detail_settings_appearance_icon_set_key" translatable="false">SubscriptionIconSet</string>
<string name="detail_settings_appearance_icon_remove_key" translatable="false">SubscriptionIconRemove</string>

View file

@ -28,6 +28,11 @@
app:entryValues="@array/detail_settings_notifications_auto_delete_values"
app:defaultValue="-1"
app:isPreferenceVisible="false"/> <!-- Same as Repository.AUTO_DELETE_USE_GLOBAL -->
<EditTextPreference
app:key="@string/detail_settings_notifications_password_key"
app:title="@string/detail_settings_notifications_password_title"
app:dialogLayout="@layout/preference_dialog_editpass_edited"
app:dialogMessage="@string/detail_settings_notifications_password_dialog_summary"/>
</PreferenceCategory>
<PreferenceCategory
app:key="@string/detail_settings_appearance_header_key"

View file

@ -1,7 +1,6 @@
package io.heckel.ntfy.firebase
import android.content.Intent
import android.util.Base64
import androidx.work.*
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
@ -11,10 +10,10 @@ import io.heckel.ntfy.db.Attachment
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.toPriority
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.work.PollWorker
@ -124,7 +123,7 @@ class FirebaseService : FirebaseMessagingService() {
url = attachmentUrl,
)
} else null
val notification = Notification(
val notificationOriginal = Notification(
id = id,
subscriptionId = subscription.id,
timestamp = timestamp,
@ -139,6 +138,7 @@ class FirebaseService : FirebaseMessagingService() {
notificationId = Random.nextInt(),
deleted = false
)
val notification = Encryption.maybeDecrypt(subscription, notificationOriginal)
if (repository.addNotification(notification)) {
Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
dispatcher.dispatch(subscription, notification)