Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions app/schemas/io.heckel.ntfy.db.Database/12.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -79,6 +79,12 @@
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "encryptionKey",
"columnName": "encryptionKey",
"affinity": "BLOB",
"notNull": false
}
],
"primaryKey": {
Expand Down Expand Up @@ -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')"
]
}
}
10 changes: 7 additions & 3 deletions app/src/main/java/io/heckel/ntfy/backup/Backuper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
}
}
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
20 changes: 12 additions & 8 deletions app/src/main/java/io/heckel/ntfy/db/Database.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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('')")
}
}
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/io/heckel/ntfy/db/Repository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/io/heckel/ntfy/service/WsConnection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -111,6 +110,7 @@ class DetailSettingsActivity : AppCompatActivity() {
loadMutedUntilPref()
loadMinPriorityPref()
loadAutoDeletePref()
loadPasswordPref()
loadIconSetPref()
loadIconRemovePref()
} else {
Expand Down Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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++
Expand Down
Loading