diff --git a/app/build.gradle b/app/build.gradle index ecf7ff50..477299bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/schemas/io.heckel.ntfy.db.Database/15.json b/app/schemas/io.heckel.ntfy.db.Database/15.json index 5d6d5fb3..c7f29320 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/15.json +++ b/app/schemas/io.heckel.ntfy.db.Database/15.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/schemas/io.heckel.ntfy.db.Database/16.json b/app/schemas/io.heckel.ntfy.db.Database/16.json new file mode 100644 index 00000000..0577c859 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/16.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index f6f029ef..99e58b39 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -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 + + @Query("SELECT * FROM CustomHeader WHERE baseUrl = :baseUrl ORDER BY name") + suspend fun get(baseUrl: String): List + + @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) +} diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 5e7678f3..329780d8 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -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() private val connectionStatesLiveData = MutableLiveData(connectionStates) @@ -432,60 +431,25 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) } } - fun getCustomHeaders(): List { - val json = sharedPrefs.getString(SHARED_PREFS_CUSTOM_HEADERS, null) - return if (json != null) { - try { - val type = object : TypeToken>() {}.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 { + return customHeaderDao.list() } - fun getCustomHeadersForServer(baseUrl: String): List { - return getCustomHeaders().filter { it.baseUrl == baseUrl } + suspend fun getCustomHeaders(baseUrl: String): List { + 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) { - 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 LiveData.combineWith( liveData: LiveData, diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 199c208a..e84f5632 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -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 { + suspend fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List { 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 = 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 } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index d54083d5..69d02025 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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*/ } } diff --git a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index e3f262de..f7189d1a 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -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, private val sinceId: String?, private val stateChangeListener: (Collection, 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)) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt index a699d71c..c26f12a0 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index c720af56..3aa8fb94 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -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 - ) - 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) { - headersByBaseUrl.forEach { serverHeaders -> - val baseUrl = serverHeaders.baseUrl - val headers = serverHeaders.headers + private fun addCustomHeaderPreferences(headersByBaseUrl: Map>) { + headersByBaseUrl.forEach { (baseUrl, headers) -> val preferenceCategory = PreferenceCategory(preferenceScreen.context) preferenceCategory.title = shortUrl(baseUrl) diff --git a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt index 7b619965..7fdfab14 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt @@ -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" diff --git a/fastlane/metadata/android/en-US/changelog/54.txt b/fastlane/metadata/android/en-US/changelog/55.txt similarity index 83% rename from fastlane/metadata/android/en-US/changelog/54.txt rename to fastlane/metadata/android/en-US/changelog/55.txt index 36434ad4..2833b739 100644 --- a/fastlane/metadata/android/en-US/changelog/54.txt +++ b/fastlane/metadata/android/en-US/changelog/55.txt @@ -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) diff --git a/fastlane/metadata/android/en-US/changelog/NEXT.txt b/fastlane/metadata/android/en-US/changelog/NEXT.txt deleted file mode 100644 index 913de553..00000000 --- a/fastlane/metadata/android/en-US/changelog/NEXT.txt +++ /dev/null @@ -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)