Merge branch 'main' into mtls

This commit is contained in:
Philipp Heckel 2026-01-04 14:16:44 -05:00
commit 4fae9d67be
13 changed files with 589 additions and 121 deletions

View file

@ -17,8 +17,8 @@ android {
minSdkVersion 26
targetSdkVersion 36
versionCode 54
versionName "1.21.0"
versionCode 55
versionName "1.21.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 15,
"identityHash": "405eb445e4bc622f6624984e6769b047",
"identityHash": "1e2fa6a6cd2ed5146905f38f71f3c904",
"entities": [
{
"tableName": "Subscription",
@ -329,6 +329,37 @@
]
}
},
{
"tableName": "CustomHeader",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`baseUrl`, `name`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl",
"name"
]
}
},
{
"tableName": "TrustedCertificate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fingerprint` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`fingerprint`))",
@ -386,7 +417,7 @@
],
"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, '405eb445e4bc622f6624984e6769b047')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1e2fa6a6cd2ed5146905f38f71f3c904')"
]
}
}

View file

@ -0,0 +1,423 @@
{
"formatVersion": 1,
"database": {
"version": 16,
"identityHash": "1e2fa6a6cd2ed5146905f38f71f3c904",
"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, `insistent` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `dedicatedChannels` INTEGER NOT NULL, 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": "minPriority",
"columnName": "minPriority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "autoDelete",
"columnName": "autoDelete",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "insistent",
"columnName": "insistent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT"
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT"
},
{
"fieldPath": "upAppId",
"columnName": "upAppId",
"affinity": "TEXT"
},
{
"fieldPath": "upConnectorToken",
"columnName": "upConnectorToken",
"affinity": "TEXT"
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "dedicatedChannels",
"columnName": "dedicatedChannels",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Subscription_baseUrl_topic",
"unique": true,
"columnNames": [
"baseUrl",
"topic"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
},
{
"name": "index_Subscription_upConnectorToken",
"unique": true,
"columnNames": [
"upConnectorToken"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
}
]
},
{
"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, `contentType` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, 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": "contentType",
"columnName": "contentType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encoding",
"columnName": "encoding",
"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": "click",
"columnName": "click",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actions",
"columnName": "actions",
"affinity": "TEXT"
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "icon.url",
"columnName": "icon_url",
"affinity": "TEXT"
},
{
"fieldPath": "icon.contentUri",
"columnName": "icon_contentUri",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.name",
"columnName": "attachment_name",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.type",
"columnName": "attachment_type",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.size",
"columnName": "attachment_size",
"affinity": "INTEGER"
},
{
"fieldPath": "attachment.expires",
"columnName": "attachment_expires",
"affinity": "INTEGER"
},
{
"fieldPath": "attachment.url",
"columnName": "attachment_url",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.contentUri",
"columnName": "attachment_contentUri",
"affinity": "TEXT"
},
{
"fieldPath": "attachment.progress",
"columnName": "attachment_progress",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id",
"subscriptionId"
]
}
},
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
},
{
"tableName": "Log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tag",
"columnName": "tag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "exception",
"columnName": "exception",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "CustomHeader",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`baseUrl`, `name`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl",
"name"
]
}
},
{
"tableName": "TrustedCertificate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fingerprint` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`fingerprint`))",
"fields": [
{
"fieldPath": "fingerprint",
"columnName": "fingerprint",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pem",
"columnName": "pem",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"fingerprint"
]
}
},
{
"tableName": "ClientCertificate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `p12Base64` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
"fields": [
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "p12Base64",
"columnName": "p12Base64",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"baseUrl"
]
}
}
],
"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, '1e2fa6a6cd2ed5146905f38f71f3c904')"
]
}
}

View file

@ -201,6 +201,13 @@ data class ClientCertificate(
@ColumnInfo(name = "password") val password: String
)
@Entity(primaryKeys = ["baseUrl", "name"])
data class CustomHeader(
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "value") val value: String
)
@Entity(tableName = "Log")
data class LogEntry(
@PrimaryKey(autoGenerate = true) val id: Long, // Internal ID, only used in Repository and activities
@ -214,12 +221,24 @@ data class LogEntry(
this(0, timestamp, tag, level, message, exception)
}
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class, TrustedCertificate::class, ClientCertificate::class], version = 15)
@androidx.room.Database(
version = 16,
entities = [
Subscription::class,
Notification::class,
User::class,
LogEntry::class,
CustomHeader::class,
TrustedCertificate::class,
ClientCertificate::class
]
)
@TypeConverters(Converters::class)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
abstract fun userDao(): UserDao
abstract fun customHeaderDao(): CustomHeaderDao
abstract fun logDao(): LogDao
abstract fun trustedCertificateDao(): TrustedCertificateDao
abstract fun clientCertificateDao(): ClientCertificateDao
@ -246,6 +265,7 @@ abstract class Database : RoomDatabase() {
.addMigrations(MIGRATION_12_13)
.addMigrations(MIGRATION_13_14)
.addMigrations(MIGRATION_14_15)
.addMigrations(MIGRATION_15_16)
.fallbackToDestructiveMigration(true)
.build()
this.instance = instance
@ -359,6 +379,12 @@ abstract class Database : RoomDatabase() {
}
private val MIGRATION_14_15 = object : Migration(14, 15) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE CustomHeader (baseUrl TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(baseUrl, name))")
}
}
private val MIGRATION_15_16 = object : Migration(15, 16) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE TrustedCertificate (fingerprint TEXT NOT NULL, pem TEXT NOT NULL, PRIMARY KEY(fingerprint))")
db.execSQL("CREATE TABLE ClientCertificate (baseUrl TEXT NOT NULL, p12Base64 TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))")
@ -560,3 +586,20 @@ interface ClientCertificateDao {
@Query("DELETE FROM ClientCertificate WHERE baseUrl = :baseUrl")
suspend fun delete(baseUrl: String)
}
interface CustomHeaderDao {
@Query("SELECT * FROM CustomHeader ORDER BY baseUrl, name")
suspend fun list(): List<CustomHeader>
@Query("SELECT * FROM CustomHeader WHERE baseUrl = :baseUrl ORDER BY name")
suspend fun get(baseUrl: String): List<CustomHeader>
@Insert
suspend fun insert(header: CustomHeader)
@Update
suspend fun update(header: CustomHeader)
@Query("DELETE FROM CustomHeader WHERE baseUrl = :baseUrl AND name = :name")
suspend fun delete(baseUrl: String, name: String)
}

View file

@ -12,8 +12,6 @@ import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.validUrl
import java.util.concurrent.ConcurrentHashMap
@ -25,6 +23,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
private val userDao = database.userDao()
private val trustedCertificateDao = database.trustedCertificateDao()
private val clientCertificateDao = database.clientCertificateDao()
private val customHeaderDao = database.customHeaderDao()
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
private val connectionStatesLiveData = MutableLiveData(connectionStates)
@ -432,60 +431,25 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
}
}
fun getCustomHeaders(): List<CustomHeader> {
val json = sharedPrefs.getString(SHARED_PREFS_CUSTOM_HEADERS, null)
return if (json != null) {
try {
val type = object : TypeToken<List<CustomHeader>>() {}.type
Gson().fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
Log.w(TAG, "Failed to parse custom headers", e)
emptyList()
}
} else {
emptyList()
}
suspend fun getCustomHeaders(): List<CustomHeader> {
return customHeaderDao.list()
}
fun getCustomHeadersForServer(baseUrl: String): List<CustomHeader> {
return getCustomHeaders().filter { it.baseUrl == baseUrl }
suspend fun getCustomHeaders(baseUrl: String): List<CustomHeader> {
return customHeaderDao.get(baseUrl)
}
fun addCustomHeader(header: CustomHeader) {
val currentHeaders = getCustomHeaders().toMutableList()
currentHeaders.add(header)
setCustomHeaders(currentHeaders)
suspend fun addCustomHeader(header: CustomHeader) {
customHeaderDao.insert(header)
}
fun updateCustomHeader(oldHeader: CustomHeader, newHeader: CustomHeader) {
val currentHeaders = getCustomHeaders().toMutableList()
val index = currentHeaders.indexOfFirst {
it.baseUrl == oldHeader.baseUrl && it.name == oldHeader.name
}
if (index >= 0) {
currentHeaders[index] = newHeader
setCustomHeaders(currentHeaders)
}
suspend fun updateCustomHeader(oldHeader: CustomHeader, newHeader: CustomHeader) {
customHeaderDao.delete(oldHeader.baseUrl, oldHeader.name)
customHeaderDao.insert(newHeader)
}
fun deleteCustomHeader(header: CustomHeader) {
val currentHeaders = getCustomHeaders().toMutableList()
currentHeaders.removeAll {
it.baseUrl == header.baseUrl && it.name == header.name
}
setCustomHeaders(currentHeaders)
}
private fun setCustomHeaders(headers: List<CustomHeader>) {
if (headers.isEmpty()) {
sharedPrefs.edit {
remove(SHARED_PREFS_CUSTOM_HEADERS)
}
} else {
sharedPrefs.edit {
putString(SHARED_PREFS_CUSTOM_HEADERS, Gson().toJson(headers))
}
}
suspend fun deleteCustomHeader(header: CustomHeader) {
customHeaderDao.delete(header.baseUrl, header.name)
}
fun isGlobalMuted(): Boolean {
@ -624,7 +588,6 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL
const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL"
const val SHARED_PREFS_LAST_TOPICS = "LastTopics"
const val SHARED_PREFS_CUSTOM_HEADERS = "CustomHeaders"
private const val LAST_TOPICS_COUNT = 3
@ -684,12 +647,6 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
}
}
data class CustomHeader(
val baseUrl: String,
val name: String,
val value: String
)
/* https://stackoverflow.com/a/57079290/1440785 */
fun <T, K, R> LiveData<T>.combineWith(
liveData: LiveData<K>,

View file

@ -4,13 +4,26 @@ import android.content.Context
import android.os.Build
import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.CustomHeader
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.util.ALL_PRIORITIES
import io.heckel.ntfy.util.CertUtil
import io.heckel.ntfy.util.*
import okhttp3.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.PRIORITY_DEFAULT
import io.heckel.ntfy.util.topicUrl
import io.heckel.ntfy.util.topicUrlAuth
import io.heckel.ntfy.util.topicUrlJson
import io.heckel.ntfy.util.topicUrlJsonPoll
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets.UTF_8
@ -22,7 +35,7 @@ class ApiService(private val context: Context) {
private val sslManager = CertUtil.getInstance(context)
private val gson = Gson()
private val parser = NotificationParser()
private fun createClient(baseUrl: String): OkHttpClient {
return sslManager.getOkHttpClientBuilder(baseUrl)
.callTimeout(15, TimeUnit.SECONDS)
@ -31,7 +44,7 @@ class ApiService(private val context: Context) {
.writeTimeout(15, TimeUnit.SECONDS)
.build()
}
private fun createPublishClient(baseUrl: String): OkHttpClient {
return sslManager.getOkHttpClientBuilder(baseUrl)
.callTimeout(5, TimeUnit.MINUTES)
@ -40,14 +53,14 @@ class ApiService(private val context: Context) {
.writeTimeout(15, TimeUnit.SECONDS)
.build()
}
private fun createSubscriberClient(baseUrl: String): OkHttpClient {
return sslManager.getOkHttpClientBuilder(baseUrl)
.readTimeout(77, TimeUnit.SECONDS)
.build()
}
fun publish(
suspend fun publish(
baseUrl: String,
topic: String,
user: User? = null,
@ -105,7 +118,8 @@ class ApiService(private val context: Context) {
} else {
url
}
val request = requestBuilder(urlWithQuery, user, repository)
val customHeaders = repository.getCustomHeaders(baseUrl)
val request = requestBuilder(urlWithQuery, user, customHeaders)
.put(body ?: message.toRequestBody())
.build()
Log.d(TAG, "Publishing to $request")
@ -133,12 +147,13 @@ class ApiService(private val context: Context) {
}
}
fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List<Notification> {
suspend fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List<Notification> {
val sinceVal = since ?: "all"
val url = topicUrlJsonPoll(baseUrl, topic, sinceVal)
Log.d(TAG, "Polling topic $url")
val request = requestBuilder(url, user, repository).build()
val customHeaders = repository.getCustomHeaders(baseUrl)
val request = requestBuilder(url, user, customHeaders).build()
createClient(baseUrl).newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Unexpected response ${response.code} when polling topic $url")
@ -154,7 +169,7 @@ class ApiService(private val context: Context) {
}
}
fun subscribe(
suspend fun subscribe(
baseUrl: String,
topics: String,
since: String?,
@ -165,7 +180,8 @@ class ApiService(private val context: Context) {
val sinceVal = since ?: "all"
val url = topicUrlJson(baseUrl, topics, sinceVal)
Log.d(TAG, "Opening subscription connection to $url")
val request = requestBuilder(url, user, repository).build()
val customHeaders = repository.getCustomHeaders(baseUrl)
val request = requestBuilder(url, user, customHeaders).build()
val call = createSubscriberClient(baseUrl).newCall(request)
call.enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
@ -176,7 +192,8 @@ class ApiService(private val context: Context) {
val source = response.body.source()
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null")
val notification = parser.parseWithTopic(line, notificationId = Random.nextInt(), subscriptionId = 0) // subscriptionId to be set downstream
val notification =
parser.parseWithTopic(line, notificationId = Random.nextInt(), subscriptionId = 0) // subscriptionId to be set downstream
if (notification != null) {
notify(notification.topic, notification.notification)
}
@ -186,6 +203,7 @@ class ApiService(private val context: Context) {
fail(e)
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e(TAG, "Connection to $url failed (2): ${e.message}", e)
fail(e)
@ -194,14 +212,15 @@ class ApiService(private val context: Context) {
return call
}
fun checkAuth(baseUrl: String, topic: String, user: User?): Boolean {
suspend fun checkAuth(baseUrl: String, topic: String, user: User?): Boolean {
if (user == null) {
Log.d(TAG, "Checking anonymous read against ${topicUrl(baseUrl, topic)}")
} else {
Log.d(TAG, "Checking read access for user ${user.username} against ${topicUrl(baseUrl, topic)}")
}
val url = topicUrlAuth(baseUrl, topic)
val request = requestBuilder(url, user, repository).build()
val customHeaders = repository.getCustomHeaders(baseUrl)
val request = requestBuilder(url, user, customHeaders).build()
createClient(baseUrl).newCall(request).execute().use { response ->
if (response.isSuccessful) {
return true
@ -234,18 +253,15 @@ class ApiService(private val context: Context) {
const val EVENT_KEEPALIVE = "keepalive"
const val EVENT_POLL_REQUEST = "poll_request"
fun requestBuilder(url: String, user: User?, repository: Repository? = null): Request.Builder {
fun requestBuilder(url: String, user: User?, customHeaders: List<CustomHeader> = emptyList()): Request.Builder {
val builder = Request.Builder()
.url(url)
.addHeader("User-Agent", USER_AGENT)
if (user != null) {
builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8))
}
if (repository != null) {
val baseUrl = extractBaseUrl(url)
repository.getCustomHeadersForServer(baseUrl).forEach { header ->
builder.addHeader(header.name, header.value)
}
customHeaders.forEach { header ->
builder.addHeader(header.name, header.value)
}
return builder
}

View file

@ -28,7 +28,6 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.util.concurrent.ConcurrentHashMap
import androidx.core.content.edit
/**
* The subscriber service manages the foreground service for instant delivery.
@ -41,7 +40,6 @@ import androidx.core.content.edit
* - Incoming notifications are immediately forwarded and broadcasted
*
* "Trying to keep the service running" cliff notes:
* - Manages the service SHOULD-BE state in a SharedPref, so that we know whether or not to restart the service
* - The foreground service is STICKY, so it is restarted by Android if it's killed
* - On destroy (onDestroy), we send a broadcast to AutoRestartReceiver (see AndroidManifest.xml) which will schedule
* a one-off AutoRestartWorker to restart the service (this is weird, but necessary because services started from
@ -215,7 +213,7 @@ class SubscriberService : Service() {
// connection protocol (JSON/WS), the user or the custom headers are updated, that we kill existing
// connections and start new ones.
val credentialsHash = repository.getUser(baseUrl)?.let { "${it.username}:${it.password}".hashCode() } ?: 0
val headersHash = repository.getCustomHeadersForServer(baseUrl)
val headersHash = repository.getCustomHeaders(baseUrl)
.sortedBy { "${it.name}:${it.value}" }
.joinToString(",") { "${it.name}:${it.value}" }
.hashCode()
@ -255,9 +253,10 @@ class SubscriberService : Service() {
val since = sinceByBaseUrl[connectionId.baseUrl] ?: "none"
val serviceActive = { isServiceStarted }
val user = repository.getUser(connectionId.baseUrl)
val customHeaders = repository.getCustomHeaders(connectionId.baseUrl)
val connection = if (connectionId.connectionProtocol == Repository.CONNECTION_PROTOCOL_WS) {
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
WsConnection(this, connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager)
WsConnection(this, connectionId, repository, user, customHeaders, since, ::onStateChanged, ::onNotificationReceived, alarmManager)
} else {
JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive)
}
@ -361,7 +360,8 @@ class SubscriberService : Service() {
val restartServiceIntent = Intent(applicationContext, SubscriberService::class.java).also {
it.setPackage(packageName)
}
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)
val restartServicePendingIntent: PendingIntent =
PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)
applicationContext.getSystemService(ALARM_SERVICE)
val alarmService: AlarmManager = applicationContext.getSystemService(ALARM_SERVICE) as AlarmManager
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent)
@ -400,6 +400,6 @@ class SubscriberService : Service() {
private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
private const val NOTIFICATION_GROUP_ID = "io.heckel.ntfy.NOTIFICATION_GROUP_SERVICE"
private const val NOTIFICATION_SERVICE_ID = 2586
private const val NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS = 10*60*1000L /*10 minutes*/
private const val NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS = 10 * 60 * 1000L /*10 minutes*/
}
}

View file

@ -4,6 +4,7 @@ import android.app.AlarmManager
import android.content.Context
import android.os.Build
import io.heckel.ntfy.db.ConnectionState
import io.heckel.ntfy.db.CustomHeader
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
@ -39,6 +40,7 @@ class WsConnection(
private val connectionId: ConnectionId,
private val repository: Repository,
private val user: User?,
private val customHeaders: List<CustomHeader>,
private val sinceId: String?,
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
private val notificationListener: (Subscription, Notification) -> Unit,
@ -86,7 +88,7 @@ class WsConnection(
val sinceId = since.get()
val sinceVal = sinceId ?: "all"
val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal)
val request = requestBuilder(urlWithSince, user, repository).build()
val request = requestBuilder(urlWithSince, user, customHeaders).build()
Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...")
webSocket = client.newWebSocket(request, Listener(nextListenerId))
}

View file

@ -250,7 +250,7 @@ class CustomHeaderFragment : DialogFragment() {
return false
}
val existingHeaders = withContext(Dispatchers.IO) {
repository.getCustomHeadersForServer(baseUrl)
repository.getCustomHeaders(baseUrl)
}
return existingHeaders.any { existingHeader ->
// When editing, exclude the current header being edited

View file

@ -32,6 +32,7 @@ import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.backup.Backuper
import io.heckel.ntfy.db.CustomHeader
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.service.SubscriberServiceManager
@ -963,31 +964,20 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
reload()
}
data class CustomHeaderWithMetadata(
val baseUrl: String,
val headers: List<io.heckel.ntfy.db.CustomHeader>
)
fun reload() {
preferenceScreen.removeAll()
lifecycleScope.launch(Dispatchers.IO) {
val headersByBaseUrl = repository.getCustomHeaders()
.groupBy { it.baseUrl }
.map { entry ->
CustomHeaderWithMetadata(entry.key, entry.value)
}
.sortedBy { it.baseUrl }
.toSortedMap()
activity?.runOnUiThread {
addCustomHeaderPreferences(headersByBaseUrl)
}
}
}
private fun addCustomHeaderPreferences(headersByBaseUrl: List<CustomHeaderWithMetadata>) {
headersByBaseUrl.forEach { serverHeaders ->
val baseUrl = serverHeaders.baseUrl
val headers = serverHeaders.headers
private fun addCustomHeaderPreferences(headersByBaseUrl: Map<String, List<CustomHeader>>) {
headersByBaseUrl.forEach { (baseUrl, headers) ->
val preferenceCategory = PreferenceCategory(preferenceScreen.context)
preferenceCategory.title = shortUrl(baseUrl)

View file

@ -17,6 +17,10 @@ import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.util.AfterChangedTextWatcher
import io.heckel.ntfy.util.validUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class UserFragment : DialogFragment() {
private var user: User? = null
@ -185,27 +189,31 @@ class UserFragment : DialogFragment() {
baseUrlViewLayout.error = null
if (user == null) {
// Check if Authorization header already exists in custom headers
val hasAuthorizationHeader = if (this::repository.isInitialized && validUrl(baseUrl)) {
repository.getCustomHeadersForServer(baseUrl)
.any { it.name.equals("Authorization", ignoreCase = true) }
} else {
false
CoroutineScope(Dispatchers.Main).launch {
val hasAuthorizationHeader = hasAuthorizationHeader(baseUrl)
if (hasAuthorizationHeader) {
baseUrlViewLayout.error = getString(R.string.user_dialog_base_url_error_authorization_header_exists)
}
saveMenuItem.isEnabled = validUrl(baseUrl)
&& !baseUrlsInUse.contains(baseUrl)
&& !hasAuthorizationHeader
&& username.isNotEmpty() && password.isNotEmpty()
}
if (hasAuthorizationHeader) {
baseUrlViewLayout.error = getString(R.string.user_dialog_base_url_error_authorization_header_exists)
}
saveMenuItem.isEnabled = validUrl(baseUrl)
&& !baseUrlsInUse.contains(baseUrl)
&& !hasAuthorizationHeader
&& username.isNotEmpty() && password.isNotEmpty()
} else {
saveMenuItem.isEnabled = username.isNotEmpty() // Unchanged if left blank
}
}
private suspend fun hasAuthorizationHeader(baseUrl: String): Boolean {
if (!this::repository.isInitialized || !validUrl(baseUrl)) {
return false
}
return withContext(Dispatchers.IO) {
repository.getCustomHeaders(baseUrl)
.any { it.name.equals("Authorization", ignoreCase = true) }
}
}
companion object {
const val TAG = "NtfyUserFragment"
private const val BUNDLE_BASE_URL = "baseUrl"

View file

@ -10,4 +10,5 @@ Maintenance + bug fixes:
* Add support for (technically incorrect) 'image/jpg' MIME type (ntfy-android#142, thanks to @Murilobeluco)
* Unify "copy to clipboard" notifications, use Android 13 style (ntfy-android#61, thanks to @thgoebel)
* Fix crash in user add dialog (onAddUser)
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see #1520)
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2+3, see #1520)
* Hide "Exact alarms" setting if battery optimization exemption has been granted (#1456, thanks for reporting @HappyLer)

View file

@ -1,3 +0,0 @@
Maintenance + bug fixes:
* Hide "Exact alarms" setting if battery optimization exemption has been granted (#1456, thanks for reporting @HappyLer)
* Fix ForegroundServiceDidNotStartInTimeException (#1520, attempt 3, d064e75)