From 746bd63fa7fed0f7bdfbe2f03f858e9326e318c7 Mon Sep 17 00:00:00 2001 From: MichaelArkh Date: Wed, 24 Apr 2024 22:34:11 -0400 Subject: [PATCH 1/2] Add gif preview support --- app/build.gradle | 5 +++++ app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt | 12 +++++++----- app/src/main/java/io/heckel/ntfy/util/Util.kt | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 90dc1edb..3588321b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -128,4 +128,9 @@ dependencies { // Image viewer implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1' + + //Glide + implementation 'com.github.bumptech.glide:glide:4.16.0' + kapt 'com.github.bumptech.glide:compiler:4.16.0' + } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 0b568038..717f9c62 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -23,6 +23,7 @@ import androidx.core.view.allViews import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import com.google.android.material.button.MaterialButton import com.stfalcon.imageviewer.StfalconImageViewer import io.heckel.ntfy.R @@ -176,7 +177,7 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: val attachment = notification.attachment val image = attachment.contentUri != null && supportedImage(attachment.type) && previewableImage(attachmentFileStat) val bitmap = if (image) attachment.contentUri?.readBitmapFromUriOrNull(context) else null - maybeRenderAttachmentImage(context, bitmap) + maybeRenderAttachmentImage(context, bitmap, attachment) maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, bitmap) } @@ -351,16 +352,17 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } } - private fun maybeRenderAttachmentImage(context: Context, bitmap: Bitmap?) { + private fun maybeRenderAttachmentImage(context: Context, bitmap: Bitmap?, attachment: Attachment) { if (bitmap == null) { attachmentImageView.visibility = View.GONE return } try { - attachmentImageView.setImageBitmap(bitmap) + Glide.with(context).load(attachment.contentUri).fitCenter().into(attachmentImageView) attachmentImageView.setOnClickListener { - val loadImage = { view: ImageView, image: Bitmap -> view.setImageBitmap(image) } - StfalconImageViewer.Builder(context, listOf(bitmap), loadImage) + StfalconImageViewer.Builder(context, listOf(bitmap)) { imageView, image -> + Glide.with(context).load(attachment.contentUri).into(imageView) + } .allowZooming(true) .withTransitionFrom(attachmentImageView) .withHiddenStatusBar(false) diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 2ffbb766..20c7f9f7 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -333,7 +333,7 @@ fun mimeTypeToIconResource(mimeType: String?): Int { } fun supportedImage(mimeType: String?): Boolean { - return listOf("image/jpeg", "image/png").contains(mimeType) + return listOf("image/jpeg", "image/png", "image/gif").contains(mimeType) } // Google Play doesn't allow us to install received .apk files anymore. From 901f29503767021a737686cf60994beb6c3e1545 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 19 Sep 2025 13:10:13 -0400 Subject: [PATCH 2/2] GIF support --- app/build.gradle | 64 ++- app/proguard-rules.pro | 1 - .../io.heckel.ntfy.db.Database/14.json | 362 ++++++++++++++++ app/src/debug/res/values/values.xml | 4 + app/src/main/AndroidManifest.xml | 13 +- .../java/io/heckel/ntfy/backup/Backuper.kt | 3 + .../main/java/io/heckel/ntfy/db/Database.kt | 20 +- .../main/java/io/heckel/ntfy/db/Repository.kt | 14 + .../java/io/heckel/ntfy/msg/ApiService.kt | 8 +- .../io/heckel/ntfy/msg/BroadcastService.kt | 1 + .../main/java/io/heckel/ntfy/msg/Message.kt | 2 + .../io/heckel/ntfy/msg/NotificationParser.kt | 1 + .../io/heckel/ntfy/msg/NotificationService.kt | 16 +- .../java/io/heckel/ntfy/service/Connection.kt | 3 +- .../io/heckel/ntfy/service/JsonConnection.kt | 4 +- .../heckel/ntfy/service/SubscriberService.kt | 11 +- .../io/heckel/ntfy/service/WsConnection.kt | 26 +- app/src/main/java/io/heckel/ntfy/ui/Colors.kt | 2 +- .../java/io/heckel/ntfy/ui/DetailAdapter.kt | 35 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 48 ++- .../io/heckel/ntfy/ui/SettingsActivity.kt | 29 ++ .../io/heckel/ntfy/util/MarkwonFactory.kt | 102 +++++ app/src/main/java/io/heckel/ntfy/util/Util.kt | 13 +- app/src/main/res/layout/activity_main.xml | 69 +++- app/src/main/res/values-af/strings.xml | 37 ++ app/src/main/res/values-ar/strings.xml | 58 ++- app/src/main/res/values-bg/strings.xml | 13 +- app/src/main/res/values-bn/strings.xml | 12 + app/src/main/res/values-ca/strings.xml | 299 +++++++++++++- app/src/main/res/values-cs/strings.xml | 4 +- app/src/main/res/values-da/strings.xml | 20 + app/src/main/res/values-de/strings.xml | 35 +- app/src/main/res/values-el/strings.xml | 36 +- app/src/main/res/values-eo/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 11 +- app/src/main/res/values-et/strings.xml | 277 +++++++++++++ app/src/main/res/values-fa/strings.xml | 19 +- app/src/main/res/values-fi/strings.xml | 343 +++++++++++++++ app/src/main/res/values-fr/strings.xml | 13 +- app/src/main/res/values-gl/strings.xml | 11 +- app/src/main/res/values-hi/strings.xml | 97 ++++- app/src/main/res/values-hr/strings.xml | 89 +++- app/src/main/res/values-hu/strings.xml | 24 +- app/src/main/res/values-in/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 26 +- app/src/main/res/values-iw/strings.xml | 389 +++++++++++++++--- app/src/main/res/values-ja/strings.xml | 11 +- app/src/main/res/values-ko/strings.xml | 5 +- app/src/main/res/values-ms/strings.xml | 55 +++ app/src/main/res/values-nb-rNO/strings.xml | 7 +- app/src/main/res/values-night/styles.xml | 3 +- app/src/main/res/values-nl/strings.xml | 8 +- app/src/main/res/values-pl/strings.xml | 46 ++- app/src/main/res/values-pt-rBR/strings.xml | 241 ++++++----- app/src/main/res/values-pt/strings.xml | 9 +- app/src/main/res/values-ro/strings.xml | 13 +- app/src/main/res/values-ru/strings.xml | 62 +-- app/src/main/res/values-sk/strings.xml | 346 ++++++++++++++++ app/src/main/res/values-sv/strings.xml | 4 +- app/src/main/res/values-ta/strings.xml | 328 ++++++++++++++- app/src/main/res/values-th/strings.xml | 57 +++ app/src/main/res/values-tr/strings.xml | 4 +- app/src/main/res/values-uk/strings.xml | 14 +- app/src/main/res/values-uz/strings.xml | 346 ++++++++++++++++ app/src/main/res/values-vi/strings.xml | 62 ++- app/src/main/res/values-xh/strings.xml | 9 + app/src/main/res/values-zh-rCN/strings.xml | 19 +- app/src/main/res/values-zh-rTW/strings.xml | 77 +++- app/src/main/res/values/strings.xml | 9 + app/src/main/res/values/styles.xml | 3 +- app/src/main/res/values/values.xml | 1 + app/src/main/res/xml/main_preferences.xml | 3 + .../heckel/ntfy/firebase/FirebaseService.kt | 2 + assets/logo_with_text.svg | 265 ++++++++++++ build.gradle | 10 +- .../metadata/android/ar/full_description.txt | 17 + fastlane/metadata/android/ar/title.txt | 1 + .../metadata/android/bg/full_description.txt | 2 +- .../android/da-DK/full_description.txt | 17 + .../android/da-DK/short_description.txt | 1 + fastlane/metadata/android/da-DK/title.txt | 1 + .../en-US/changelog/{33.txt => 41.txt} | 7 +- .../metadata/android/et/full_description.txt | 17 + .../metadata/android/et/short_description.txt | 1 + fastlane/metadata/android/et/title.txt | 1 + .../android/fi-FI/full_description.txt | 17 + .../android/fi-FI/short_description.txt | 1 + fastlane/metadata/android/fi-FI/title.txt | 1 + .../android/hu-HU/short_description.txt | 1 + fastlane/metadata/android/hu-HU/title.txt | 1 + .../android/pt-BR/full_description.txt | 8 +- .../android/pt-BR/short_description.txt | 2 +- fastlane/metadata/android/pt-BR/title.txt | 2 +- .../metadata/android/ro/full_description.txt | 18 + .../metadata/android/ro/short_description.txt | 1 + fastlane/metadata/android/ro/title.txt | 1 + .../metadata/android/sk/full_description.txt | 17 + .../metadata/android/sk/short_description.txt | 1 + fastlane/metadata/android/sk/title.txt | 1 + .../android/ta-IN/full_description.txt | 17 + .../android/ta-IN/short_description.txt | 1 + fastlane/metadata/android/ta-IN/title.txt | 1 + .../metadata/android/th/full_description.txt | 17 + .../metadata/android/th/short_description.txt | 1 + fastlane/metadata/android/th/title.txt | 1 + .../metadata/android/uz/full_description.txt | 17 + .../metadata/android/uz/short_description.txt | 1 + fastlane/metadata/android/uz/title.txt | 1 + .../metadata/android/vi/full_description.txt | 17 + .../metadata/android/vi/short_description.txt | 1 + fastlane/metadata/android/vi/title.txt | 1 + gradle/wrapper/gradle-wrapper.properties | 4 +- 112 files changed, 4528 insertions(+), 378 deletions(-) delete mode 100644 app/proguard-rules.pro create mode 100644 app/schemas/io.heckel.ntfy.db.Database/14.json create mode 100644 app/src/debug/res/values/values.xml create mode 100644 app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt create mode 100644 app/src/main/res/values-af/strings.xml create mode 100644 app/src/main/res/values-bn/strings.xml create mode 100644 app/src/main/res/values-da/strings.xml create mode 100644 app/src/main/res/values-eo/strings.xml create mode 100644 app/src/main/res/values-et/strings.xml create mode 100644 app/src/main/res/values-fi/strings.xml create mode 100644 app/src/main/res/values-ms/strings.xml create mode 100644 app/src/main/res/values-sk/strings.xml create mode 100644 app/src/main/res/values-th/strings.xml create mode 100644 app/src/main/res/values-uz/strings.xml create mode 100644 app/src/main/res/values-xh/strings.xml create mode 100644 assets/logo_with_text.svg create mode 100644 fastlane/metadata/android/ar/full_description.txt create mode 100644 fastlane/metadata/android/ar/title.txt create mode 100644 fastlane/metadata/android/da-DK/full_description.txt create mode 100644 fastlane/metadata/android/da-DK/short_description.txt create mode 100644 fastlane/metadata/android/da-DK/title.txt rename fastlane/metadata/android/en-US/changelog/{33.txt => 41.txt} (68%) create mode 100644 fastlane/metadata/android/et/full_description.txt create mode 100644 fastlane/metadata/android/et/short_description.txt create mode 100644 fastlane/metadata/android/et/title.txt create mode 100644 fastlane/metadata/android/fi-FI/full_description.txt create mode 100644 fastlane/metadata/android/fi-FI/short_description.txt create mode 100644 fastlane/metadata/android/fi-FI/title.txt create mode 100644 fastlane/metadata/android/hu-HU/short_description.txt create mode 100644 fastlane/metadata/android/hu-HU/title.txt create mode 100644 fastlane/metadata/android/ro/full_description.txt create mode 100644 fastlane/metadata/android/ro/short_description.txt create mode 100644 fastlane/metadata/android/ro/title.txt create mode 100644 fastlane/metadata/android/sk/full_description.txt create mode 100644 fastlane/metadata/android/sk/short_description.txt create mode 100644 fastlane/metadata/android/sk/title.txt create mode 100644 fastlane/metadata/android/ta-IN/full_description.txt create mode 100644 fastlane/metadata/android/ta-IN/short_description.txt create mode 100644 fastlane/metadata/android/ta-IN/title.txt create mode 100644 fastlane/metadata/android/th/full_description.txt create mode 100644 fastlane/metadata/android/th/short_description.txt create mode 100644 fastlane/metadata/android/th/title.txt create mode 100644 fastlane/metadata/android/uz/full_description.txt create mode 100644 fastlane/metadata/android/uz/short_description.txt create mode 100644 fastlane/metadata/android/uz/title.txt create mode 100644 fastlane/metadata/android/vi/full_description.txt create mode 100644 fastlane/metadata/android/vi/short_description.txt create mode 100644 fastlane/metadata/android/vi/title.txt diff --git a/app/build.gradle b/app/build.gradle index 3588321b..81557712 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,41 +1,50 @@ +plugins { + id 'com.google.devtools.ksp' +} + repositories { mavenCentral() } apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' apply plugin: 'com.google.gms.google-services' android { - compileSdkVersion 33 + namespace "io.heckel.ntfy" + compileSdkVersion 35 defaultConfig { applicationId "io.heckel.ntfy" minSdkVersion 21 - targetSdkVersion 33 + targetSdkVersion 35 - versionCode 33 - versionName "1.17.0" + versionCode 41 + versionName "1.17.8" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" /* Required for Room schema migrations */ - javaCompileOptions { - annotationProcessorOptions { - arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] - } + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } + + buildFeatures { + buildConfig true } } buildTypes { release { - minifyEnabled true + minifyEnabled false + shrinkResources false debuggable false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } debug { minifyEnabled false + shrinkResources false debuggable true + applicationIdSuffix ".debug" + versionNameSuffix "-debug" } } @@ -54,12 +63,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_17.toString() freeCompilerArgs += [ '-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785 ] @@ -103,9 +112,10 @@ dependencies { implementation 'com.google.code.gson:gson:2.10' // Room (SQLite) - def room_version = "2.5.1" + def room_version = "2.6.1" + implementation "androidx.room:room-runtime:$room_version" + ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" - kapt "androidx.room:room-compiler:$room_version" // OkHttp (HTTP library) implementation 'com.squareup.okhttp3:okhttp:4.10.0' @@ -127,10 +137,24 @@ dependencies { implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Image viewer - implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1' + implementation 'com.github.stfalcon-studio:StfalconImageViewer:1.0.1' - //Glide - implementation 'com.github.bumptech.glide:glide:4.16.0' - kapt 'com.github.bumptech.glide:compiler:4.16.0' + // Glide (GIF support) + implementation 'com.github.bumptech.glide:glide:5.0.5' + ksp 'com.github.bumptech.glide:ksp:5.0.5' + // Better click handling for links + implementation 'me.saket:better-link-movement-method:2.2.0' + + // Markdown + implementation 'io.noties.markwon:core:4.6.2' + implementation 'io.noties.markwon:image-picasso:4.6.2' + implementation 'io.noties.markwon:image:4.6.2' + implementation 'io.noties.markwon:linkify:4.6.2' + implementation 'io.noties.markwon:ext-tables:4.6.2' + implementation 'io.noties.markwon:ext-strikethrough:4.6.2' + + // Used by Markdown library, R8 complains if these are not here + implementation "pl.droidsonroids.gif:android-gif-drawable:1.2.29" + implementation "com.caverock:androidsvg:1.4" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index e9caf635..00000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ --dontobfuscate diff --git a/app/schemas/io.heckel.ntfy.db.Database/14.json b/app/schemas/io.heckel.ntfy.db.Database/14.json new file mode 100644 index 00000000..5b3e0d91 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/14.json @@ -0,0 +1,362 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "208f16743f21d9c374f1314878eb93cb", + "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", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "upAppId", + "columnName": "upAppId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "upConnectorToken", + "columnName": "upConnectorToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "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`)" + } + ], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon.url", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.contentUri", + "columnName": "icon_contentUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.name", + "columnName": "attachment_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.type", + "columnName": "attachment_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.size", + "columnName": "attachment_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachment.expires", + "columnName": "attachment_expires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachment.url", + "columnName": "attachment_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.contentUri", + "columnName": "attachment_contentUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachment.progress", + "columnName": "attachment_progress", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "subscriptionId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '208f16743f21d9c374f1314878eb93cb')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/values/values.xml b/app/src/debug/res/values/values.xml new file mode 100644 index 00000000..674f6be3 --- /dev/null +++ b/app/src/debug/res/values/values.xml @@ -0,0 +1,4 @@ + + + ntfy (debug) + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0950b404..cb991fa1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,10 @@ - + + @@ -95,7 +95,13 @@ - + + + + diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 89460c4b..a939fbe0 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -183,6 +183,7 @@ class Backuper(val context: Context) { timestamp = n.timestamp, title = n.title, message = n.message, + contentType = n.contentType, encoding = n.encoding, notificationId = 0, priority = n.priority, @@ -312,6 +313,7 @@ class Backuper(val context: Context) { timestamp = n.timestamp, title = n.title, message = n.message, + contentType = n.contentType, encoding = n.encoding, priority = n.priority, tags = n.tags, @@ -386,6 +388,7 @@ data class Notification( val timestamp: Long, val title: String, val message: String, + val contentType: String, // "" or "text/markdown" (empty assumes "text/plain") val encoding: String, // "base64" or "" val priority: Int, // 1=min, 3=default, 5=max val tags: String, 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 5f397969..665f210c 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -99,6 +99,7 @@ data class Notification( @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "contentType") val contentType: String, // "" or "text/markdown" (empty assume text/plain) @ColumnInfo(name = "encoding") val encoding: String, // "base64" or "" @ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID @ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max @@ -110,6 +111,10 @@ data class Notification( @ColumnInfo(name = "deleted") val deleted: Boolean, ) +fun Notification.isMarkdown(): Boolean { + return contentType == "text/markdown" +} + @Entity data class Attachment( @ColumnInfo(name = "name") val name: String, // Filename @@ -120,7 +125,7 @@ data class Attachment( @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location @ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded ) { - constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) : + @Ignore constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) : this(name, type, size, expires, url, null, ATTACHMENT_PROGRESS_NONE) } @@ -135,7 +140,7 @@ data class Icon( @ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server) @ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location ) { - constructor(url:String) : + @Ignore constructor(url:String) : this(url, null) } @@ -192,11 +197,11 @@ data class LogEntry( @ColumnInfo(name = "message") val message: String, @ColumnInfo(name = "exception") val exception: String? ) { - constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) : + @Ignore constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) : this(0, timestamp, tag, level, message, exception) } -@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 13) +@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 14) @TypeConverters(Converters::class) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao @@ -224,6 +229,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_10_11) .addMigrations(MIGRATION_11_12) .addMigrations(MIGRATION_12_13) + .addMigrations(MIGRATION_13_14) .fallbackToDestructiveMigration() .build() this.instance = instance @@ -329,6 +335,12 @@ abstract class Database : RoomDatabase() { db.execSQL("ALTER TABLE Subscription ADD COLUMN dedicatedChannels INTEGER NOT NULL DEFAULT (0)") } } + + private val MIGRATION_13_14 = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Notification ADD COLUMN contentType TEXT NOT NULL DEFAULT ('')") + } + } } } 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 71b03092..15f76db7 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -342,6 +342,16 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas .apply() } + fun getWebSocketReconnectRemindTime(): Long { + return sharedPrefs.getLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS) + } + + fun setWebSocketReconnectRemindTime(timeMillis: Long) { + sharedPrefs.edit() + .putLong(SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME, timeMillis) + .apply() + } + fun getDefaultBaseUrl(): String? { return sharedPrefs.getString(SHARED_PREFS_DEFAULT_BASE_URL, null) ?: sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) // Fall back to UP URL, removed when default is set! @@ -492,6 +502,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs" const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime" const val SHARED_PREFS_WEBSOCKET_REMIND_TIME = "JsonStreamRemindTime" // "Use WebSocket" banner (used to be JSON stream deprecation banner) + const val SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME = "WebSocketReconnectRemindTime" 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" @@ -532,6 +543,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val WEBSOCKET_REMIND_TIME_ALWAYS = 1L const val WEBSOCKET_REMIND_TIME_NEVER = Long.MAX_VALUE + const val WEBSOCKET_RECONNECT_REMIND_TIME_ALWAYS = 1L + const val WEBSOCKET_RECONNECT_REMIND_TIME_NEVER = Long.MAX_VALUE + private const val TAG = "NtfyRepository" private var instance: Repository? = null 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 2692c849..54c48ea9 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -108,7 +108,6 @@ class ApiService { fun subscribe( baseUrl: String, topics: String, - unifiedPushTopics: String, since: String?, user: User?, notify: (topic: String, Notification) -> Unit, @@ -117,7 +116,7 @@ class ApiService { val sinceVal = since ?: "all" val url = topicUrlJson(baseUrl, topics, sinceVal) Log.d(TAG, "Opening subscription connection to $url") - val request = requestBuilder(url, user, unifiedPushTopics).build() + val request = requestBuilder(url, user).build() val call = subscriberClient.newCall(request) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { @@ -179,16 +178,13 @@ class ApiService { const val EVENT_KEEPALIVE = "keepalive" const val EVENT_POLL_REQUEST = "poll_request" - fun requestBuilder(url: String, user: User?, unifiedPushTopics: String? = null): Request.Builder { + fun requestBuilder(url: String, user: User?): 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 (unifiedPushTopics != null) { - builder.addHeader("Rate-Topics", unifiedPushTopics) - } return builder } } diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt index 6ad65b26..fb7f6d47 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -28,6 +28,7 @@ class BroadcastService(private val ctx: Context) { intent.putExtra("message", decodeMessage(notification)) intent.putExtra("message_bytes", decodeBytesMessage(notification)) intent.putExtra("message_encoding", notification.encoding) + intent.putExtra("content_type", notification.contentType) intent.putExtra("tags", notification.tags) intent.putExtra("tags_map", joinTagsMap(splitTags(notification.tags))) intent.putExtra("priority", notification.priority) diff --git a/app/src/main/java/io/heckel/ntfy/msg/Message.kt b/app/src/main/java/io/heckel/ntfy/msg/Message.kt index 8de0ab85..b34965f2 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/Message.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/Message.kt @@ -1,6 +1,7 @@ package io.heckel.ntfy.msg import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName /* This annotation ensures that proguard still works in production builds, * see https://stackoverflow.com/a/62753300/1440785 */ @@ -17,6 +18,7 @@ data class Message( val actions: List?, val title: String?, val message: String, + @SerializedName("content_type") val contentType: String?, val encoding: String?, val attachment: MessageAttachment?, ) diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt index 81a60dba..5af07418 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationParser.kt @@ -57,6 +57,7 @@ class NotificationParser { timestamp = message.time, title = message.title ?: "", message = message.message, + contentType = message.contentType ?: "", encoding = message.encoding ?: "", priority = toPriority(message.priority), tags = joinTags(message.tags), diff --git a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt index eec1e59b..48067073 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/NotificationService.kt @@ -11,6 +11,8 @@ import android.media.RingtoneManager import android.net.Uri import android.os.Build import android.os.Bundle +import android.text.SpannedString +import android.text.style.CharacterStyle import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -26,6 +28,7 @@ import java.util.* class NotificationService(val context: Context) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val repository = Repository.getInstance(context) + private val markwon = MarkwonFactory.createForNotification(context) fun display(subscription: Subscription, notification: Notification) { Log.d(TAG, "Displaying notification $notification") @@ -147,7 +150,7 @@ class NotificationService(val context: Context) { try { val attachmentBitmap = contentUri.readBitmapFromUri(context) builder - .setContentText(maybeAppendActionErrors(formatMessage(notification), notification)) + .setContentText(maybeAppendActionErrors(maybeMarkdown(formatMessage(notification), notification), notification)) .setLargeIcon(attachmentBitmap) .setStyle(NotificationCompat.BigPictureStyle() .bigPicture(attachmentBitmap) @@ -167,8 +170,8 @@ class NotificationService(val context: Context) { } } - private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): String { - val message = formatMessage(notification) + private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): CharSequence { + val message = maybeMarkdown(formatMessage(notification), notification) val attachment = notification.attachment ?: return message val attachmentInfos = if (attachment.size != null) { "${attachment.name}, ${formatBytes(attachment.size)}" @@ -514,6 +517,13 @@ class NotificationService(val context: Context) { } } + private fun maybeMarkdown(message: String, notification: Notification): CharSequence { + if (notification.contentType == "text/markdown") { + return markwon.toMarkdown(message) + } + return message + } + companion object { const val ACTION_VIEW = "view" const val ACTION_HTTP = "http" diff --git a/app/src/main/java/io/heckel/ntfy/service/Connection.kt b/app/src/main/java/io/heckel/ntfy/service/Connection.kt index dc151e50..71a98be4 100644 --- a/app/src/main/java/io/heckel/ntfy/service/Connection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/Connection.kt @@ -8,6 +8,5 @@ interface Connection { data class ConnectionId( val baseUrl: String, - val topicsToSubscriptionIds: Map, - val topicIsUnifiedPush: Map + val topicsToSubscriptionIds: Map ) diff --git a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index 39fa0088..8bca6883 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -21,10 +21,8 @@ class JsonConnection( ) : Connection { private val baseUrl = connectionId.baseUrl private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds - private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush private val subscriptionIds = topicsToSubscriptionIds.values private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") - private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",") private val url = topicUrl(baseUrl, topicsStr) private var since: String? = sinceId @@ -58,7 +56,7 @@ class JsonConnection( // Call /json subscribe endpoint and loop until the call fails, is canceled, // or the job or service are cancelled/stopped try { - call = api.subscribe(baseUrl, topicsStr, unifiedPushTopicsStr, since, user, notify, fail) + call = api.subscribe(baseUrl, topicsStr, since, user, notify, fail) while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) { stateChangeListener(subscriptionIds, ConnectionState.CONNECTED) Log.d(TAG,"[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=${serviceActive()}") 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 192cfc9f..60fbb477 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -4,6 +4,7 @@ import android.app.* import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.PowerManager @@ -98,7 +99,11 @@ class SubscriberService : Service() { notificationManager = createNotificationChannel() serviceNotification = createNotification(title, text) - startForeground(NOTIFICATION_SERVICE_ID, serviceNotification) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_SERVICE_ID, serviceNotification!!, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(NOTIFICATION_SERVICE_ID, serviceNotification) + } } override fun onDestroy() { @@ -172,8 +177,8 @@ class SubscriberService : Service() { .filter { s -> s.instant } val activeConnectionIds = connections.keys().toList().toSet() val desiredConnectionIds = instantSubscriptions // Set - .groupBy { s -> ConnectionId(s.baseUrl, emptyMap(), emptyMap()) } - .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }, topicIsUnifiedPush = entry.value.associate { s -> s.topic to (s.upConnectorToken != null) }) } + .groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) } + .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) } .toSet() val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds) val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds) 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 080e8482..3b34081b 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -56,10 +56,8 @@ class WsConnection( private val since = AtomicReference(sinceId) private val baseUrl = connectionId.baseUrl private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds - private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush private val subscriptionIds = topicsToSubscriptionIds.values private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") - private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",") private val shortUrl = topicShortUrl(baseUrl, topicsStr) init { @@ -80,7 +78,7 @@ class WsConnection( val sinceId = since.get() val sinceVal = sinceId ?: "all" val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal) - val request = requestBuilder(urlWithSince, user, unifiedPushTopicsStr).build() + val request = requestBuilder(urlWithSince, user).build() Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...") webSocket = client.newWebSocket(request, Listener(nextListenerId)) } @@ -114,7 +112,27 @@ class WsConnection( Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)") val reconnectTime = Calendar.getInstance() reconnectTime.add(Calendar.SECOND, seconds) - alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + reconnectTime.timeInMillis, + RECONNECT_TAG, + { start() }, + null + ) + } else { + Log.d(TAG, "SCHEDULE_EXACT_ALARM permission denied: Failed to reschedule websocket connection") + } + } else { + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + reconnectTime.timeInMillis, + RECONNECT_TAG, + { start() }, + null + ) + } } else { Log.d(TAG, "$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via handler)") val handler = Handler(Looper.getMainLooper()) diff --git a/app/src/main/java/io/heckel/ntfy/ui/Colors.kt b/app/src/main/java/io/heckel/ntfy/ui/Colors.kt index ada14cbf..47e8e164 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/Colors.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/Colors.kt @@ -7,7 +7,7 @@ import io.heckel.ntfy.util.isDarkThemeOn class Colors { companion object { - const val refreshProgressIndicator = R.color.teal + val refreshProgressIndicator = R.color.teal fun notificationIcon(context: Context): Int { return if (isDarkThemeOn(context)) R.color.teal_light else R.color.teal diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt index 717f9c62..0a0c6556 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt @@ -9,6 +9,8 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.text.method.LinkMovementMethod +import android.text.util.Linkify import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -34,20 +36,23 @@ import io.heckel.ntfy.msg.DownloadType import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW import io.heckel.ntfy.util.* +import io.noties.markwon.Markwon import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import me.saket.bettermovementmethod.BetterLinkMovementMethod class DetailAdapter(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, private val onClick: (Notification) -> Unit, private val onLongClick: (Notification) -> Unit) : ListAdapter(TopicDiffCallback) { + private val markwon: Markwon = MarkwonFactory.createForMessage(activity) val selected = mutableSetOf() // Notification IDs /* Creates and inflates view and return TopicViewHolder. */ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.fragment_detail_item, parent, false) - return DetailViewHolder(activity, lifecycleScope, repository, view, selected, onClick, onLongClick) + return DetailViewHolder(activity, lifecycleScope, repository, markwon, view, selected, onClick, onLongClick) } /* Gets current topic and uses it to bind view. */ @@ -74,7 +79,16 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: } /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */ - class DetailViewHolder(private val activity: Activity, private val lifecycleScope: CoroutineScope, private val repository: Repository, itemView: View, private val selected: Set, val onClick: (Notification) -> Unit, val onLongClick: (Notification) -> Unit) : + class DetailViewHolder( + private val activity: Activity, + private val lifecycleScope: CoroutineScope, + private val repository: Repository, + private val markwon: Markwon, + itemView: View, + private val selected: Set, + val onClick: (Notification) -> Unit, + val onLongClick: (Notification) -> Unit + ) : RecyclerView.ViewHolder(itemView) { private var notification: Notification? = null private val layout: View = itemView.findViewById(R.id.detail_item_layout) @@ -99,9 +113,17 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: val context = itemView.context val unmatchedTags = unmatchedTags(splitTags(notification.tags)) + val message = maybeAppendActionErrors(formatMessage(notification), notification) dateView.text = formatDateShort(notification.timestamp) - messageView.text = maybeAppendActionErrors(formatMessage(notification), notification) + if (notification.isMarkdown()) { + messageView.autoLinkMask = 0 + markwon.setMarkdown(messageView, message.toString()) + } else { + messageView.autoLinkMask = Linkify.WEB_URLS + messageView.text = message + } + messageView.movementMethod = BetterLinkMovementMethod.getInstance() messageView.setOnClickListener { // Click & Long-click listeners on the text as well, because "autoLink=web" makes them // clickable, and so we cannot rely on the underlying card to perform the action. @@ -144,6 +166,13 @@ class DetailAdapter(private val activity: Activity, private val lifecycleScope: maybeRenderActions(context, notification) } + private fun maybeMarkdown(message: String, notification: Notification): CharSequence { + if (notification.isMarkdown()) { + return markwon.toMarkdown(message) + } + return message + } + private fun renderPriority(context: Context, notification: Notification) { when (notification.priority) { PRIORITY_MIN -> { diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index e131f32c..1c4b0f52 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -3,6 +3,7 @@ package io.heckel.ntfy.ui import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter +import android.app.AlarmManager import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Intent @@ -11,6 +12,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings +import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM import android.text.method.LinkMovementMethod import android.view.ActionMode import android.view.Menu @@ -125,9 +127,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc Log.addScrubTerm(s.topic) } - // Update banner + WebSocket banner + // Update battery banner + WebSocket banner + websocket reconnect banner showHideBatteryBanner(subscriptions) showHideWebSocketBanner(subscriptions) + showHideWebSocketReconnectBanner(subscriptions) } } @@ -194,6 +197,34 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc repository.setConnectionProtocol(Repository.CONNECTION_PROTOCOL_WS) SubscriberServiceManager(this).restart() wsBanner.visibility = View.GONE + + // Maybe show WebSocketReconnectBanner + viewModel.list().observe(this) { + it?.let { subscriptions -> + showHideWebSocketReconnectBanner(subscriptions) + } + } + } + + // WebSocket Reconnect banner + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val wsReconnectBanner = findViewById(R.id.main_banner_websocket_reconnect) + val wsReconnectText = findViewById(R.id.main_banner_websocket_reconnect_text) + val wsReconnectDismissButton = findViewById