Merge branch 'main' into mtls
This commit is contained in:
commit
4fae9d67be
13 changed files with 589 additions and 121 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
423
app/schemas/io.heckel.ntfy.db.Database/16.json
Normal file
423
app/schemas/io.heckel.ntfy.db.Database/16.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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*/
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue