From bc53a6cae25b213e7c3b298978963eeec4af6702 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 15 Jan 2026 18:43:46 -0800 Subject: [PATCH] refining the android app --- .gitignore | 1 + NOTICE | 30 + android/app/build.gradle.kts | 78 +- .../com.lonecloud.sup.db.Database/17.json | 216 +++++ android/app/src/main/AndroidManifest.xml | 131 ++- .../com/lonecloud/sup/DistributorService.kt | 3 - .../java/com/lonecloud/sup/MainActivity.kt | 52 -- .../sup/SignalNotificationListener.kt | 194 +++- .../java/com/lonecloud/sup/app/Application.kt | 23 + .../java/com/lonecloud/sup/db/Database.kt | 440 +++++++++ .../java/com/lonecloud/sup/db/Repository.kt | 515 ++++++++++ .../java/com/lonecloud/sup/msg/ApiService.kt | 193 ++++ .../com/lonecloud/sup/msg/BroadcastService.kt | 109 +++ .../java/com/lonecloud/sup/msg/Message.kt | 49 + .../sup/msg/NotificationDispatcher.kt | 79 ++ .../lonecloud/sup/msg/NotificationParser.kt | 37 + .../lonecloud/sup/msg/NotificationService.kt | 232 +++++ .../com/lonecloud/sup/service/Exceptions.kt | 12 + .../sup/service/SignalListenerService.kt | 88 ++ .../java/com/lonecloud/sup/ui/AddFragment.kt | 466 ++++++++++ .../main/java/com/lonecloud/sup/ui/BaseUrl.kt | 68 ++ .../main/java/com/lonecloud/sup/ui/Colors.kt | 86 ++ .../com/lonecloud/sup/ui/DetailActivity.kt | 877 ++++++++++++++++++ .../com/lonecloud/sup/ui/DetailAdapter.kt | 245 +++++ .../com/lonecloud/sup/ui/DetailViewModel.kt | 31 + .../java/com/lonecloud/sup/ui/MainActivity.kt | 738 +++++++++++++++ .../java/com/lonecloud/sup/ui/MainAdapter.kt | 154 +++ .../com/lonecloud/sup/ui/MainViewModel.kt | 53 ++ .../com/lonecloud/sup/ui/PriorityAdapter.kt | 58 ++ .../com/lonecloud/sup/up/BroadcastReceiver.kt | 221 +++++ .../java/com/lonecloud/sup/up/Constants.kt | 21 + .../java/com/lonecloud/sup/up/Distributor.kt | 51 + .../java/com/lonecloud/sup/up/FailedReason.kt | 23 + .../java/com/lonecloud/sup/up/LinkActivity.kt | 36 + .../lonecloud/sup/up/RaiseAppToForeground.kt | 311 +++++++ .../sup/up/RaiseAppToForegroundFactory.kt | 39 + .../java/com/lonecloud/sup/util/CertUtil.kt | 98 ++ .../java/com/lonecloud/sup/util/Constants.kt | 11 + .../java/com/lonecloud/sup/util/Emoji.java | 29 + .../com/lonecloud/sup/util/EmojiLoader.java | 70 ++ .../com/lonecloud/sup/util/EmojiManager.java | 46 + .../java/com/lonecloud/sup/util/HttpUtil.kt | 75 ++ .../main/java/com/lonecloud/sup/util/Log.kt | 204 ++++ .../com/lonecloud/sup/util/MarkwonFactory.kt | 100 ++ .../lonecloud/sup/util/ProgressRequestBody.kt | 37 + .../main/java/com/lonecloud/sup/util/Util.kt | 456 +++++++++ .../com/lonecloud/sup/work/DeleteWorker.kt | 76 ++ .../app/src/main/res/anim/slide_in_bottom.xml | 9 + .../src/main/res/anim/slide_out_bottom.xml | 9 + .../main/res/drawable/ic_add_black_24dp.xml | 9 + .../drawable/ic_announcement_orange_24dp.xml | 9 + .../drawable/ic_battery_alert_red_24dp.xml | 9 + .../main/res/drawable/ic_bolt_gray_24dp.xml | 9 + .../drawable/ic_bolt_outline_white_24dp.xml | 10 + .../main/res/drawable/ic_bolt_white_24dp.xml | 10 + .../main/res/drawable/ic_cancel_gray_24dp.xml | 28 + .../app/src/main/res/drawable/ic_circle.xml | 5 + .../main/res/drawable/ic_close_white_24dp.xml | 11 + .../drawable/ic_content_copy_white_24dp.xml | 9 + .../res/drawable/ic_create_white_24dp.xml | 9 + .../res/drawable/ic_delete_white_20dp.xml | 10 + .../res/drawable/ic_drop_down_gray_24dp.xml | 27 + .../res/drawable/ic_drop_up_gray_24dp.xml | 28 + .../main/res/drawable/ic_error_red_24dp.xml | 9 + .../res/drawable/ic_expand_less_gray_24dp.xml | 10 + .../res/drawable/ic_file_app_gray_24dp.xml | 9 + .../drawable/ic_file_audio_purple_24dp.xml | 9 + .../drawable/ic_file_document_blue_24dp.xml | 9 + .../res/drawable/ic_file_image_red_24dp.xml | 9 + .../drawable/ic_file_video_orange_24dp.xml | 9 + .../res/drawable/ic_launcher_monochrome.xml | 31 + .../res/drawable/ic_more_horiz_gray_24dp.xml | 9 + .../src/main/res/drawable/ic_notification.xml | 31 + ...ic_notifications_off_gray_outline_24dp.xml | 12 + ...tifications_off_time_gray_outline_24dp.xml | 15 + ...ifications_off_time_white_outline_24dp.xml | 16 + ...c_notifications_off_white_outline_24dp.xml | 13 + .../drawable/ic_notifications_white_24dp.xml | 10 + .../main/res/drawable/ic_priority_1_24dp.xml | 26 + .../main/res/drawable/ic_priority_2_24dp.xml | 19 + .../main/res/drawable/ic_priority_3_24dp.xml | 13 + .../main/res/drawable/ic_priority_4_24dp.xml | 20 + .../main/res/drawable/ic_priority_5_24dp.xml | 26 + .../main/res/drawable/ic_send_white_24dp.xml | 9 + .../main/res/drawable/ic_sms_gray_24dp.xml | 18 + .../main/res/drawable/ic_sms_gray_48dp.xml | 18 + .../res/drawable/ic_warning_amber_24dp.xml | 10 + .../res/drawable/ic_warning_white_24dp.xml | 9 + .../src/main/res/layout/activity_detail.xml | 120 +++ .../app/src/main/res/layout/activity_main.xml | 374 ++++++-- .../src/main/res/layout/app_bar_drawer.xml | 17 + .../app/src/main/res/layout/button_action.xml | 21 + .../main/res/layout/fragment_add_dialog.xml | 342 +++++++ .../fragment_add_dialog_dropdown_item.xml | 14 + .../main/res/layout/fragment_detail_item.xml | 210 +++++ .../main/res/layout/fragment_main_item.xml | 83 ++ .../res/layout/item_priority_dropdown.xml | 28 + .../src/main/res/layout/view_message_bar.xml | 83 ++ .../res/layout/view_preference_switch.xml | 5 + .../app/src/main/res/menu/menu_add_dialog.xml | 10 + .../main/res/menu/menu_detail_action_bar.xml | 50 + .../main/res/menu/menu_detail_action_mode.xml | 14 + .../main/res/menu/menu_detail_attachment.xml | 10 + .../main/res/menu/menu_main_action_bar.xml | 37 + .../main/res/menu/menu_main_action_mode.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 13 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 9154 -> 9154 bytes .../mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 299 bytes ...r_round.png => ic_launcher_foreground.png} | Bin 9154 -> 9154 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 5211 -> 5211 bytes .../mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 297 bytes ...r_round.png => ic_launcher_foreground.png} | Bin 5211 -> 5211 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 13884 -> 13884 bytes .../mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 302 bytes ...r_round.png => ic_launcher_foreground.png} | Bin 13884 -> 13884 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 24977 -> 24977 bytes .../mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 310 bytes ...r_round.png => ic_launcher_foreground.png} | Bin 24977 -> 24977 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 37854 -> 37854 bytes .../mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 312 bytes ...r_round.png => ic_launcher_foreground.png} | Bin 37854 -> 37854 bytes android/app/src/main/res/values/colors.xml | 59 +- android/app/src/main/res/values/dimens.xml | 4 + .../res/values/ic_launcher_background.xml | 4 + android/app/src/main/res/values/strings.xml | 162 +++- android/app/src/main/res/values/themes.xml | 125 ++- android/app/src/main/res/xml/file_paths.xml | 5 + .../main/res/xml/network_security_config.xml | 9 + android/build.gradle.kts | 2 +- biome.json | 10 +- package.json | 17 +- scripts/check-android-deps.ts | 24 +- scripts/release-android.ts | 30 +- scripts/test-android-build.ts | 37 +- scripts/update-android-lockfile.ts | 12 + server/constants/server.ts | 3 + server/index.ts | 188 +--- server/routes/health.ts | 13 + server/routes/link.ts | 69 ++ server/routes/notify.ts | 116 +++ server/routes/unifiedpush.ts | 59 ++ server/store.ts | 52 ++ 142 files changed, 9776 insertions(+), 396 deletions(-) create mode 100644 NOTICE create mode 100644 android/app/schemas/com.lonecloud.sup.db.Database/17.json delete mode 100644 android/app/src/main/java/com/lonecloud/sup/MainActivity.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/app/Application.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/db/Database.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/db/Repository.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/msg/ApiService.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/msg/BroadcastService.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/msg/Message.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/msg/NotificationDispatcher.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/msg/NotificationParser.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/service/Exceptions.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/AddFragment.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/BaseUrl.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/Colors.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/DetailActivity.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/DetailAdapter.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/DetailViewModel.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/MainAdapter.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/MainViewModel.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/ui/PriorityAdapter.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/up/BroadcastReceiver.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/up/Constants.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/up/Distributor.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/up/FailedReason.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/up/LinkActivity.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/up/RaiseAppToForeground.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/up/RaiseAppToForegroundFactory.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/CertUtil.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/Constants.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/Emoji.java create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/EmojiLoader.java create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/EmojiManager.java create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/HttpUtil.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/Log.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/MarkwonFactory.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/ProgressRequestBody.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/util/Util.kt create mode 100644 android/app/src/main/java/com/lonecloud/sup/work/DeleteWorker.kt create mode 100644 android/app/src/main/res/anim/slide_in_bottom.xml create mode 100644 android/app/src/main/res/anim/slide_out_bottom.xml create mode 100644 android/app/src/main/res/drawable/ic_add_black_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_announcement_orange_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_battery_alert_red_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_bolt_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_bolt_outline_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_bolt_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_cancel_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_circle.xml create mode 100644 android/app/src/main/res/drawable/ic_close_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_content_copy_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_create_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_delete_white_20dp.xml create mode 100644 android/app/src/main/res/drawable/ic_drop_down_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_drop_up_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_error_red_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_expand_less_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_file_app_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_file_audio_purple_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_file_document_blue_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_file_image_red_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_file_video_orange_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 android/app/src/main/res/drawable/ic_more_horiz_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/app/src/main/res/drawable/ic_notifications_off_gray_outline_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_notifications_off_time_gray_outline_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_notifications_off_white_outline_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_notifications_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_priority_1_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_priority_2_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_priority_3_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_priority_4_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_priority_5_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_send_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_sms_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_sms_gray_48dp.xml create mode 100644 android/app/src/main/res/drawable/ic_warning_amber_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_warning_white_24dp.xml create mode 100644 android/app/src/main/res/layout/activity_detail.xml create mode 100644 android/app/src/main/res/layout/app_bar_drawer.xml create mode 100644 android/app/src/main/res/layout/button_action.xml create mode 100644 android/app/src/main/res/layout/fragment_add_dialog.xml create mode 100644 android/app/src/main/res/layout/fragment_add_dialog_dropdown_item.xml create mode 100644 android/app/src/main/res/layout/fragment_detail_item.xml create mode 100644 android/app/src/main/res/layout/fragment_main_item.xml create mode 100644 android/app/src/main/res/layout/item_priority_dropdown.xml create mode 100644 android/app/src/main/res/layout/view_message_bar.xml create mode 100644 android/app/src/main/res/layout/view_preference_switch.xml create mode 100644 android/app/src/main/res/menu/menu_add_dialog.xml create mode 100644 android/app/src/main/res/menu/menu_detail_action_bar.xml create mode 100644 android/app/src/main/res/menu/menu_detail_action_mode.xml create mode 100644 android/app/src/main/res/menu/menu_detail_attachment.xml create mode 100644 android/app/src/main/res/menu/menu_main_action_bar.xml create mode 100644 android/app/src/main/res/menu/menu_main_action_mode.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png rename android/app/src/main/res/mipmap-hdpi/{ic_launcher_round.png => ic_launcher_foreground.png} (97%) create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png rename android/app/src/main/res/mipmap-mdpi/{ic_launcher_round.png => ic_launcher_foreground.png} (96%) create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png rename android/app/src/main/res/mipmap-xhdpi/{ic_launcher_round.png => ic_launcher_foreground.png} (98%) create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png rename android/app/src/main/res/mipmap-xxhdpi/{ic_launcher_round.png => ic_launcher_foreground.png} (99%) create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png rename android/app/src/main/res/mipmap-xxxhdpi/{ic_launcher_round.png => ic_launcher_foreground.png} (99%) create mode 100644 android/app/src/main/res/values/dimens.xml create mode 100644 android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 scripts/update-android-lockfile.ts create mode 100644 server/routes/health.ts create mode 100644 server/routes/link.ts create mode 100644 server/routes/notify.ts create mode 100644 server/routes/unifiedpush.ts diff --git a/.gitignore b/.gitignore index c79d063..3a6760f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ android/.externalNativeBuild android/.cxx android/*.keystore android/*.jks +android/.kotlin diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..f8ad45a --- /dev/null +++ b/NOTICE @@ -0,0 +1,30 @@ +# SUP - Signal Unified Push + +Copyright (c) 2026 LoneCloud + +This product includes software developed by Philipp C. Heckel (ntfy) +Licensed under the Apache License 2.0 + +The Android application (android/) contains modified code from: + ntfy-android (https://github.com/binwiederhier/ntfy-android) + Copyright (c) 2021-2024 Philipp C. Heckel + Licensed under Apache License 2.0 + +Major modifications: + +- Replaced HTTP polling with Signal-based message delivery for privacy +- Removed web-based features and simplified UI +- Intended for self-hosted, low-volume personal use + +The original ntfy-android license is included below: + +================================================================================ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + [Full Apache 2.0 license text would go here] + +================================================================================ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 35f68a5..a4b1172 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") version "2.1.0-1.0.29" } android { @@ -49,23 +50,86 @@ android { buildFeatures { viewBinding = true + buildConfig = true } lint { checkReleaseBuilds = false abortOnError = false } + + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } + + defaultConfig { + // Build config fields + buildConfigField("boolean", "FIREBASE_AVAILABLE", "false") + buildConfigField("boolean", "RATE_APP_AVAILABLE", "false") + buildConfigField("boolean", "PAYMENT_LINKS_AVAILABLE", "false") + buildConfigField("String", "FLAVOR", "\"sup\"") + } } dependencies { - implementation("androidx.core:core-ktx:1.15.0") + // AndroidX Core implementation("androidx.appcompat:appcompat:1.7.1") - implementation("com.google.android.material:material:1.12.0") // Skip alpha + implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.constraintlayout:constraintlayout:2.2.1") - + implementation("androidx.activity:activity-ktx:1.12.2") + implementation("androidx.fragment:fragment-ktx:1.8.9") + implementation("androidx.work:work-runtime-ktx:2.11.0") + implementation("androidx.preference:preference-ktx:1.2.1") + + // JSON (Gson) + implementation("com.google.code.gson:gson:2.13.2") + + // Room (SQLite) + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + ksp("androidx.room:room-compiler:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + + // OkHttp + implementation("com.squareup.okhttp3:okhttp:5.3.2") + + // RecyclerView + implementation("androidx.recyclerview:recyclerview:1.4.0") + + // Swipe to refresh + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0") + + // Material Design + implementation("com.google.android.material:material:1.13.0") + + // LiveData + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.10.0") + implementation("androidx.legacy:legacy-support-v4:1.0.0") + + // Image viewer + implementation("com.github.stfalcon-studio:StfalconImageViewer:1.0.1") + + // Glide (GIF support) + val glideVersion = "5.0.5" + implementation("com.github.bumptech.glide:glide:$glideVersion") + ksp("com.github.bumptech.glide:ksp:$glideVersion") + + // 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") + + // Markdown dependencies (R8 requirements) + implementation("pl.droidsonroids.gif:android-gif-drawable:1.2.29") + implementation("com.caverock:androidsvg:1.4") + + // UnifiedPush implementation("com.github.UnifiedPush:android-connector:3.0.10") - - implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.16") - - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") } + diff --git a/android/app/schemas/com.lonecloud.sup.db.Database/17.json b/android/app/schemas/com.lonecloud.sup.db.Database/17.json new file mode 100644 index 0000000..39be46d --- /dev/null +++ b/android/app/schemas/com.lonecloud.sup.db.Database/17.json @@ -0,0 +1,216 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "35364cf175ffcf5aa672eabaabe12397", + "entities": [ + { + "tableName": "Subscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, 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": "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": "upAppId", + "columnName": "upAppId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "upConnectorToken", + "columnName": "upConnectorToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + } + ], + "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, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `deleted` INTEGER NOT NULL, 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": "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": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": ["id", "subscriptionId"] + }, + "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, '35364cf175ffcf5aa672eabaabe12397')" + ] + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 08fcb8e..dad32e5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,47 +1,114 @@ - - + + + + + + + + + - - + android:name=".app.Application" + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher" + android:supportsRtl="true" + android:theme="@style/AppTheme" + android:networkSecurityConfig="@xml/network_security_config" + android:usesCleartextTraffic="true"> + - - + + - - - - - - - + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt b/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt index bb4df5f..a469b8c 100644 --- a/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt +++ b/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt @@ -63,7 +63,6 @@ class DistributorService : Service() { val jsonResponse = JSONObject(responseBody) val endpoint = jsonResponse.getString("endpoint") - // Store mapping prefs.edit() .putString("endpoint_$appId", endpoint) .putString("token_$appId", token) @@ -85,7 +84,6 @@ class DistributorService : Service() { val token = intent.getStringExtra("token") ?: return Log.d("SUP", "Unregistering: token=$token") - // Find and remove mapping val allPrefs = prefs.all for ((key, value) in allPrefs) { if (key.startsWith("token_") && value == token) { @@ -129,7 +127,6 @@ class DistributorService : Service() { } private fun getAppPackageFromToken(token: String): String { - // Token format is typically package:randomId return token.split(":").firstOrNull() ?: "" } } diff --git a/android/app/src/main/java/com/lonecloud/sup/MainActivity.kt b/android/app/src/main/java/com/lonecloud/sup/MainActivity.kt deleted file mode 100644 index eb80bb4..0000000 --- a/android/app/src/main/java/com/lonecloud/sup/MainActivity.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.lonecloud.sup - -import android.content.Intent -import android.os.Bundle -import android.provider.Settings -import android.widget.Button -import android.widget.EditText -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity - -class MainActivity : AppCompatActivity() { - - private lateinit var serverUrlInput: EditText - private lateinit var apiKeyInput: EditText - private lateinit var saveButton: Button - private lateinit var enableListenerButton: Button - - private val prefs by lazy { - getSharedPreferences("sup_prefs", MODE_PRIVATE) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - serverUrlInput = findViewById(R.id.server_url) - apiKeyInput = findViewById(R.id.api_key) - saveButton = findViewById(R.id.save_button) - enableListenerButton = findViewById(R.id.enable_listener_button) - - // Load saved settings - serverUrlInput.setText(prefs.getString("server_url", "")) - apiKeyInput.setText(prefs.getString("api_key", "")) - - saveButton.setOnClickListener { - val serverUrl = serverUrlInput.text.toString().trim() - val apiKey = apiKeyInput.text.toString().trim() - - prefs.edit() - .putString("server_url", serverUrl) - .putString("api_key", apiKey) - .apply() - - Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show() - } - - enableListenerButton.setOnClickListener { - val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) - startActivity(intent) - } - } -} diff --git a/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt b/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt index 91f1b66..19f2f1a 100644 --- a/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt +++ b/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt @@ -1,18 +1,47 @@ package com.lonecloud.sup +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context import android.content.Intent +import android.net.Uri import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification import android.util.Log +import androidx.core.app.NotificationCompat +import com.lonecloud.sup.db.Database +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.ui.MainActivity +import kotlinx.coroutines.* +import kotlin.random.Random class SignalNotificationListener : NotificationListenerService() { + private val TAG = "SUP_Listener" private val prefs by lazy { getSharedPreferences("sup_prefs", MODE_PRIVATE) } + private val db by lazy { Database.getInstance(this) } + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + companion object { + private const val CHANNEL_ID = "sup_notifications" + private const val CHANNEL_NAME = "SUP Notifications" + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } override fun onNotificationPosted(sbn: StatusBarNotification?) { - if (sbn?.packageName != "org.thoughtcrime.securesms") return // Signal package + if (sbn?.packageName != "org.thoughtcrime.securesms") return val notification = sbn.notification val extras = notification.extras @@ -20,31 +49,73 @@ class SignalNotificationListener : NotificationListenerService() { val title = extras.getString("android.title") ?: "" val text = extras.getCharSequence("android.text")?.toString() ?: "" - Log.d("SUP", "Signal notification: title=$title, text=$text") + Log.d(TAG, "Signal notification: title=$title, text=$text") - // Parse SUP message format: **Title**\nbody\nJSON - if (title.startsWith("SUP - ")) { - val appName = title.removePrefix("SUP - ") - parseAndDeliver(appName, text) + when { + title.startsWith("SUP - ") && !title.contains("(UP)") -> { + // Direct notification channel + val topic = title.removePrefix("SUP - ") + parseAndDisplayNotification(topic, text) + } + title.startsWith("SUP - ") && title.contains("(UP)") -> { + // UnifiedPush notification + val appName = title.removePrefix("SUP - ").substringBefore(" (UP)") + parseAndDeliverUnifiedPush(appName, text) + } } } - private fun parseAndDeliver(appName: String, message: String) { + private fun parseAndDisplayNotification(topic: String, message: String) { + serviceScope.launch { + try { + val subscription = db.subscriptionDao().get( + prefs.getString("server_url", "") ?: "", + topic + ) ?: return@launch + + if (subscription.mutedUntil > System.currentTimeMillis() / 1000) { + Log.d(TAG, "Subscription $topic is muted") + return@launch + } + + val lines = message.lines() + val (title, body, priority, clickUrl) = parseNotificationMessage(lines) + + val notif = Notification( + id = "${System.currentTimeMillis()}-${Random.nextInt()}", + subscriptionId = subscription.id, + timestamp = System.currentTimeMillis() / 1000, + title = title ?: topic, + message = body, + notificationId = Random.nextInt(Int.MAX_VALUE), + priority = priority, + tags = "", + deleted = false + ) + + db.notificationDao().add(notif) + displayNotification(subscription.displayName ?: topic, notif) + + Log.d(TAG, "Displayed notification for topic: $topic") + } catch (e: Exception) { + Log.e(TAG, "Failed to display notification", e) + } + } + } + + private fun parseAndDeliverUnifiedPush(appName: String, message: String) { try { - // Find the endpoint for this app val endpoint = prefs.getString("endpoint_$appName", null) val token = prefs.getString("token_$appName", null) if (endpoint == null || token == null) { - Log.w("SUP", "No mapping found for app: $appName") + Log.w(TAG, "No mapping found for app: $appName") return } - // Extract message body (skip the formatted parts) val lines = message.lines() - val body = lines.getOrNull(1) ?: message + val body = lines.drop(1).joinToString("\n").trim() - // Send to app val intent = Intent("org.unifiedpush.android.connector.MESSAGE").apply { putExtra("token", token) putExtra("message", body) @@ -52,13 +123,108 @@ class SignalNotificationListener : NotificationListenerService() { } sendBroadcast(intent) - Log.d("SUP", "Delivered notification to $appName") + Log.d(TAG, "Delivered UnifiedPush notification to $appName") } catch (e: Exception) { - Log.e("SUP", "Failed to parse/deliver notification", e) + Log.e(TAG, "Failed to parse/deliver UnifiedPush notification", e) } } + private fun parseNotificationMessage(lines: List): NotificationData { + var title: String? = null + var body = "" + var priority = 3 // default + var clickUrl: String? = null + + for (line in lines) { + when { + line.startsWith("🚨") || line.startsWith("⚠️") || line.startsWith("πŸ””") || + line.startsWith("πŸ”‰") || line.startsWith("πŸ”•") -> { + // Parse priority from emoji + priority = when { + line.startsWith("🚨") -> 5 // urgent + line.startsWith("⚠️") -> 4 // high + line.startsWith("πŸ””") -> 3 // default + line.startsWith("πŸ”‰") -> 2 // low + line.startsWith("πŸ”•") -> 1 // min + else -> 3 + } + // Extract title (remove emoji and **markdown**) + title = line.substring(2).trim() + .removePrefix("**").removeSuffix("**").trim() + } + line.startsWith("πŸ”—") -> { + clickUrl = line.removePrefix("πŸ”—").trim() + } + line.startsWith("_Tags:") -> { + // Ignore tags line for now + } + line.isNotBlank() && title != null -> { + // Body content + if (body.isNotEmpty()) body += "\n" + body += line + } + } + } + + return NotificationData(title, body.ifBlank { lines.joinToString("\n") }, priority, clickUrl) + } + + private fun displayNotification(topicName: String, notification: Notification) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val intent = Intent(this, MainActivity::class.java) + + val pendingIntent = PendingIntent.getActivity( + this, + notification.notificationId, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(notification.title) + .setContentText(notification.message) + .setStyle(NotificationCompat.BigTextStyle().bigText(notification.message)) + .setPriority(mapPriorityToAndroid(notification.priority)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + + notificationManager.notify(notification.notificationId, builder.build()) + } + + private fun mapPriorityToAndroid(priority: Int): Int { + return when (priority) { + 1 -> NotificationCompat.PRIORITY_MIN + 2 -> NotificationCompat.PRIORITY_LOW + 3 -> NotificationCompat.PRIORITY_DEFAULT + 4 -> NotificationCompat.PRIORITY_HIGH + 5 -> NotificationCompat.PRIORITY_MAX + else -> NotificationCompat.PRIORITY_DEFAULT + } + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notifications from SUP topics" + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + private fun getAppPackageFromToken(token: String): String { return token.split(":").firstOrNull() ?: "" } + + private data class NotificationData( + val title: String?, + val body: String, + val priority: Int, + val clickUrl: String? + ) } diff --git a/android/app/src/main/java/com/lonecloud/sup/app/Application.kt b/android/app/src/main/java/com/lonecloud/sup/app/Application.kt new file mode 100644 index 0000000..08230d4 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/app/Application.kt @@ -0,0 +1,23 @@ +package com.lonecloud.sup.app + +import android.app.Application +import com.google.android.material.color.DynamicColors +import com.lonecloud.sup.db.Repository +import com.lonecloud.sup.util.Log + +class Application : Application() { + val repository by lazy { + val repository = Repository.getInstance(applicationContext) + if (repository.getRecordLogs()) { + Log.setRecord(true) + } + repository + } + + override fun onCreate() { + super.onCreate() + if (repository.getDynamicColorsEnabled()) { + DynamicColors.applyToActivitiesIfAvailable(this) + } + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/db/Database.kt b/android/app/src/main/java/com/lonecloud/sup/db/Database.kt new file mode 100644 index 0000000..dbeaaf7 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/db/Database.kt @@ -0,0 +1,440 @@ +package com.lonecloud.sup.db + +import android.content.Context +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Update +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.lonecloud.sup.service.NotAuthorizedException +import com.lonecloud.sup.service.hasCause +import kotlinx.coroutines.flow.Flow +import java.net.ConnectException + +@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)]) +data class Subscription( + @PrimaryKey val id: Long, + @ColumnInfo(name = "baseUrl") val baseUrl: String, + @ColumnInfo(name = "topic") val topic: String, + @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, + @ColumnInfo(name = "minPriority") val minPriority: Int, + @ColumnInfo(name = "autoDelete") val autoDelete: Long, // Seconds + @ColumnInfo(name = "insistent") val insistent: Int, // Ring constantly for max priority notifications (-1 = use global, 0 = off, 1 = on) + @ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name + @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token + @ColumnInfo(name = "displayName") val displayName: String?, + @Ignore val totalCount: Int = 0, // Total notifications + @Ignore val newCount: Int = 0, // New notifications + @Ignore val lastActive: Long = 0, // Unix timestamp + @Ignore val connectionDetails: ConnectionDetails = ConnectionDetails() +) { + constructor( + id: Long, + baseUrl: String, + topic: String, + mutedUntil: Long, + minPriority: Int, + autoDelete: Long, + insistent: Int, + upAppId: String?, + upConnectorToken: String?, + displayName: String? + ) : + this( + id, + baseUrl, + topic, + mutedUntil, + minPriority, + autoDelete, + insistent, + upAppId, + upConnectorToken, + displayName, + totalCount = 0, + newCount = 0, + lastActive = 0, + connectionDetails = ConnectionDetails() + ) +} + +enum class ConnectionState { + NOT_APPLICABLE, CONNECTING, CONNECTED +} + +data class ConnectionDetails( + val state: ConnectionState = ConnectionState.NOT_APPLICABLE, + val error: Throwable? = null, + val nextRetryTime: Long = 0L +) { + fun getStackTraceString(): String { + return error?.stackTraceToString() ?: "" + } + + fun hasError(): Boolean { + return error != null + } + + fun isConnectionRefused(): Boolean { + return error?.hasCause(ConnectException::class.java) ?: false + } + + fun isNotAuthorized(): Boolean { + return error?.hasCause(NotAuthorizedException::class.java) ?: false + } +} + +data class SubscriptionWithMetadata( + val id: Long, + val baseUrl: String, + val topic: String, + val mutedUntil: Long, + val autoDelete: Long, + val minPriority: Int, + val insistent: Int, + val upAppId: String?, + val upConnectorToken: String?, + val displayName: String?, + val totalCount: Int, + val newCount: Int, + val lastActive: Long +) + +@Entity(primaryKeys = ["id", "subscriptionId"]) +data class Notification( + @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "subscriptionId") val subscriptionId: Long, + @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp + @ColumnInfo(name = "title") val title: String, + @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID + @ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max + @ColumnInfo(name = "tags") val tags: String, + @ColumnInfo(name = "deleted") val deleted: Boolean, +) + +@Entity(tableName = "Log") +data class LogEntry( + @PrimaryKey(autoGenerate = true) val id: Long, + @ColumnInfo(name = "timestamp") val timestamp: Long, + @ColumnInfo(name = "tag") val tag: String, + @ColumnInfo(name = "level") val level: Int, + @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "exception") val exception: String? +) { + @Ignore constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) : + this(0, timestamp, tag, level, message, exception) +} + +@androidx.room.Database( + version = 17, + entities = [ + Subscription::class, + Notification::class, + LogEntry::class + ] +) +abstract class Database : RoomDatabase() { + abstract fun subscriptionDao(): SubscriptionDao + abstract fun notificationDao(): NotificationDao + abstract fun logDao(): LogDao + + companion object { + @Volatile + private var instance: Database? = null + + fun getInstance(context: Context): Database { + return instance ?: synchronized(this) { + val instance = Room + .databaseBuilder(context.applicationContext, Database::class.java, "AppDatabase") + .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_3_4) + .addMigrations(MIGRATION_4_5) + .addMigrations(MIGRATION_5_6) + .addMigrations(MIGRATION_6_7) + .addMigrations(MIGRATION_7_8) + .addMigrations(MIGRATION_8_9) + .addMigrations(MIGRATION_9_10) + .addMigrations(MIGRATION_10_11) + .addMigrations(MIGRATION_11_12) + .addMigrations(MIGRATION_12_13) + .addMigrations(MIGRATION_13_14) + .addMigrations(MIGRATION_14_15) + .addMigrations(MIGRATION_15_16) + .addMigrations(MIGRATION_16_17) + .fallbackToDestructiveMigration(true) + .build() + this.instance = instance + instance + } + } + + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE Subscription_New (id INTEGER NOT NULL, baseUrl TEXT NOT NULL, topic TEXT NOT NULL, instant INTEGER NOT NULL DEFAULT('0'), PRIMARY KEY(id))") + db.execSQL("INSERT INTO Subscription_New SELECT id, baseUrl, topic, 0 FROM Subscription") + db.execSQL("DROP TABLE Subscription") + db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription") + db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)") + + db.execSQL("ALTER TABLE Notification ADD COLUMN notificationId INTEGER NOT NULL DEFAULT('0')") + db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')") + } + } + + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Subscription ADD COLUMN mutedUntil INTEGER NOT NULL DEFAULT('0')") + } + } + + private val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE Notification_New (id TEXT NOT NULL, subscriptionId INTEGER NOT NULL, timestamp INTEGER NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, notificationId INTEGER NOT NULL, priority INTEGER NOT NULL DEFAULT(3), tags TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(id, subscriptionId))") + db.execSQL("INSERT INTO Notification_New SELECT id, subscriptionId, timestamp, '', message, notificationId, 3, '', deleted FROM Notification") + db.execSQL("DROP TABLE Notification") + db.execSQL("ALTER TABLE Notification_New RENAME TO Notification") + } + } + + private val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT") + db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT") + db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)") + } + } + + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Notification ADD COLUMN click TEXT NOT NULL DEFAULT('')") + db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_name TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_type TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_size INT") + db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_expires INT") + db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_url TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_contentUri TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_progress INT") + } + } + + private val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE Log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp INT NOT NULL, tag TEXT NOT NULL, level INT NOT NULL, message TEXT NOT NULL, exception TEXT)") + } + } + + private val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE User (baseUrl TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))") + } + } + + private val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Notification ADD COLUMN encoding TEXT NOT NULL DEFAULT('')") + } + } + + private val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT") + } + } + + private val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Subscription ADD COLUMN minPriority INT NOT NULL DEFAULT (0)") + db.execSQL("ALTER TABLE Subscription ADD COLUMN autoDelete INT NOT NULL DEFAULT (-1)") + db.execSQL("ALTER TABLE Subscription ADD COLUMN icon TEXT") + } + } + + private val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT") + db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN icon_url TEXT") + db.execSQL("ALTER TABLE Notification ADD COLUMN icon_contentUri TEXT") + } + } + + private val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Subscription ADD COLUMN insistent INTEGER NOT NULL DEFAULT (-1)") + 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 ('')") + } + } + + 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 (baseUrl TEXT NOT NULL, pem TEXT NOT NULL, PRIMARY KEY(baseUrl))") + db.execSQL("CREATE TABLE ClientCertificate (baseUrl TEXT NOT NULL, p12Base64 TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))") + } + } + + private val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE Notification SET icon_contentUri = NULL WHERE icon_url IS NULL AND icon_contentUri IS NOT NULL") + } + } + + + } +} + +@Dao +interface SubscriptionDao { + @Query(""" + SELECT + s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName, + COUNT(n.id) totalCount, + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, + IFNULL(MAX(n.timestamp),0) AS lastActive + FROM Subscription AS s + LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 + GROUP BY s.id + ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC + """) + fun listFlow(): Flow> + + @Query(""" + SELECT + s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName, + COUNT(n.id) totalCount, + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, + IFNULL(MAX(n.timestamp),0) AS lastActive + FROM Subscription AS s + LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 + GROUP BY s.id + ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC + """) + suspend fun list(): List + + @Query(""" + SELECT + s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName, + COUNT(n.id) totalCount, + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, + IFNULL(MAX(n.timestamp),0) AS lastActive + FROM Subscription AS s + LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 + WHERE s.baseUrl = :baseUrl AND s.topic = :topic + GROUP BY s.id + """) + fun get(baseUrl: String, topic: String): SubscriptionWithMetadata? + + @Query(""" + SELECT + s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName, + COUNT(n.id) totalCount, + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, + IFNULL(MAX(n.timestamp),0) AS lastActive + FROM Subscription AS s + LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 + WHERE s.id = :subscriptionId + GROUP BY s.id + """) + fun get(subscriptionId: Long): SubscriptionWithMetadata? + + @Query(""" + SELECT + s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName, + COUNT(n.id) totalCount, + COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, + IFNULL(MAX(n.timestamp),0) AS lastActive + FROM Subscription AS s + LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1 + WHERE s.upConnectorToken = :connectorToken + GROUP BY s.id + """) + fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata? + + @Insert + fun add(subscription: Subscription) + + @Update + fun update(subscription: Subscription) + + @Query("DELETE FROM subscription WHERE id = :subscriptionId") + fun remove(subscriptionId: Long) +} + +@Dao +interface NotificationDao { + @Query("SELECT * FROM notification") + suspend fun list(): List + + @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC") + fun listFlow(subscriptionId: Long): Flow> + + @Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") + fun listIds(subscriptionId: Long): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun add(notification: Notification) + + @Update(onConflict = OnConflictStrategy.IGNORE) + fun update(notification: Notification) + + @Query("SELECT * FROM notification WHERE id = :notificationId") + fun get(notificationId: String): Notification? + + @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId") + fun clearAllNotificationIds(subscriptionId: Long) + + @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId") + fun markAsDeleted(notificationId: String) + + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId") + fun markAllAsDeleted(subscriptionId: Long) + + @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND timestamp < :olderThanTimestamp") + fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) + + @Query("UPDATE notification SET deleted = 0 WHERE id = :notificationId") + fun undelete(notificationId: String) + + @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId AND timestamp < :olderThanTimestamp") + fun removeIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) + + @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId") + fun removeAll(subscriptionId: Long) +} + +@Dao +interface LogDao { + @Insert + suspend fun insert(entry: LogEntry) + + @Query("DELETE FROM log WHERE id NOT IN (SELECT id FROM log ORDER BY timestamp DESC, id DESC LIMIT :keepCount)") + suspend fun prune(keepCount: Int) + + @Query("SELECT * FROM log ORDER BY timestamp ASC, id ASC") + fun getAll(): List + + @Query("DELETE FROM log") + fun deleteAll() +} diff --git a/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt b/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt new file mode 100644 index 0000000..a510692 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt @@ -0,0 +1,515 @@ +package com.lonecloud.sup.db + +import android.content.Context +import android.content.SharedPreferences +import android.media.MediaPlayer +import android.os.Build +import androidx.annotation.WorkerThread +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asLiveData +import androidx.lifecycle.map +import com.lonecloud.sup.util.Log +import com.lonecloud.sup.util.validUrl +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +class Repository(private val sharedPrefs: SharedPreferences, database: Database) { + private val subscriptionDao = database.subscriptionDao() + private val notificationDao = database.notificationDao() + + private val connectionDetails = ConcurrentHashMap() + private val connectionDetailsLiveData = MutableLiveData>(connectionDetails) + private val connectionForceReconnectVersions = ConcurrentHashMap() + + // TODO Move these into an ApplicationState singleton + val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ... + val mediaPlayer = MediaPlayer() + + init { + Log.d(TAG, "Created $this") + } + + fun getSubscriptionsLiveData(): LiveData> { + return subscriptionDao + .listFlow() + .asLiveData() + .combineWith(connectionDetailsLiveData) { subscriptionsWithMetadata, _ -> + toSubscriptionList(subscriptionsWithMetadata.orEmpty()) + } + } + + fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData>> { + return subscriptionDao + .listFlow() + .asLiveData() + .map { list -> list.map { Pair(it.id, false) }.toSet() } + } + + suspend fun getSubscriptions(): List { + return toSubscriptionList(subscriptionDao.list()) + } + + fun getSubscription(subscriptionId: Long): Subscription? { + return toSubscription(subscriptionDao.get(subscriptionId)) + } + + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun getSubscription(baseUrl: String, topic: String): Subscription? { + return toSubscription(subscriptionDao.get(baseUrl, topic)) + } + + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun getSubscriptionByConnectorToken(connectorToken: String): Subscription? { + return toSubscription(subscriptionDao.getByConnectorToken(connectorToken)) + } + + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun addSubscription(subscription: Subscription) { + subscriptionDao.add(subscription) + } + + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun updateSubscription(subscription: Subscription) { + subscriptionDao.update(subscription) + } + + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun removeSubscription(subscriptionId: Long) { + subscriptionDao.remove(subscriptionId) + } + + suspend fun getNotifications(): List { + return notificationDao.list() + } + + fun getNotificationsLiveData(subscriptionId: Long): LiveData> { + return notificationDao.listFlow(subscriptionId).asLiveData() + } + + fun clearAllNotificationIds(subscriptionId: Long) { + return notificationDao.clearAllNotificationIds(subscriptionId) + } + + fun getNotification(notificationId: String): Notification? { + return notificationDao.get(notificationId) + } + + fun onlyNewNotifications(subscriptionId: Long, notifications: List): List { + val existingIds = notificationDao.listIds(subscriptionId) + return notifications.filterNot { existingIds.contains(it.id) } + } + + @Suppress("RedundantSuspendModifier") + @WorkerThread + suspend fun addNotification(notification: Notification): Boolean { + val maybeExistingNotification = notificationDao.get(notification.id) + if (maybeExistingNotification != null) { + return false + } + notificationDao.add(notification) + return true + } + + fun updateNotification(notification: Notification) { + notificationDao.update(notification) + } + + fun undeleteNotification(notificationId: String) { + notificationDao.undelete(notificationId) + } + + fun markAsDeleted(notificationId: String) { + notificationDao.markAsDeleted(notificationId) + } + + fun markAllAsDeleted(subscriptionId: Long) { + notificationDao.markAllAsDeleted(subscriptionId) + } + + fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) { + notificationDao.markAsDeletedIfOlderThan(subscriptionId, olderThanTimestamp) + } + + fun removeNotificationsIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long) { + notificationDao.removeIfOlderThan(subscriptionId, olderThanTimestamp) + } + + fun removeAllNotifications(subscriptionId: Long) { + notificationDao.removeAll(subscriptionId) + } + + fun getDeleteWorkerVersion(): Int { + return sharedPrefs.getInt(SHARED_PREFS_DELETE_WORKER_VERSION, 0) + } + + fun setDeleteWorkerVersion(version: Int) { + sharedPrefs.edit { + putInt(SHARED_PREFS_DELETE_WORKER_VERSION, version) + } + } + + fun getAutoRestartWorkerVersion(): Int { + return sharedPrefs.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) + } + + fun setAutoRestartWorkerVersion(version: Int) { + sharedPrefs.edit { + putInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, version) + } + } + + fun setMinPriority(minPriority: Int) { + if (minPriority <= MIN_PRIORITY_ANY) { + sharedPrefs.edit { + remove(SHARED_PREFS_MIN_PRIORITY) + } + } else { + sharedPrefs.edit { + putInt(SHARED_PREFS_MIN_PRIORITY, minPriority) + } + } + } + + fun getMinPriority(): Int { + return sharedPrefs.getInt(SHARED_PREFS_MIN_PRIORITY, MIN_PRIORITY_ANY) + } + + fun getAutoDownloadMaxSize(): Long { + val defaultValue = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + AUTO_DOWNLOAD_NEVER // Need to request permission on older versions + } else { + AUTO_DOWNLOAD_DEFAULT + } + return sharedPrefs.getLong(SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE, defaultValue) + } + + fun setAutoDownloadMaxSize(maxSize: Long) { + sharedPrefs.edit { + putLong(SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE, maxSize) + } + } + + fun getAutoDeleteSeconds(): Long { + return sharedPrefs.getLong(SHARED_PREFS_AUTO_DELETE_SECONDS, AUTO_DELETE_DEFAULT_SECONDS) + } + + fun setAutoDeleteSeconds(seconds: Long) { + sharedPrefs.edit { + putLong(SHARED_PREFS_AUTO_DELETE_SECONDS, seconds) + } + } + + fun setDarkMode(mode: Int) { + if (mode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) { + sharedPrefs.edit { + remove(SHARED_PREFS_DARK_MODE) + } + } else { + sharedPrefs.edit { + putInt(SHARED_PREFS_DARK_MODE, mode) + } + } + } + + fun getDarkMode(): Int { + return sharedPrefs.getInt(SHARED_PREFS_DARK_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + + fun setDynamicColorsEnabled(enabled: Boolean) { + sharedPrefs.edit(commit = true) { + putBoolean(SHARED_PREFS_DYNAMIC_COLORS, enabled) + } + } + + fun getDynamicColorsEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_DYNAMIC_COLORS, false) + } + + fun getBroadcastEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_BROADCAST_ENABLED, true) // Enabled by default + } + + fun setBroadcastEnabled(enabled: Boolean) { + sharedPrefs.edit { + putBoolean(SHARED_PREFS_BROADCAST_ENABLED, enabled) + } + } + + fun getUnifiedPushEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_UNIFIEDPUSH_ENABLED, true) // Enabled by default + } + + fun setUnifiedPushEnabled(enabled: Boolean) { + sharedPrefs.edit { + putBoolean(SHARED_PREFS_UNIFIEDPUSH_ENABLED, enabled) + } + } + + fun getInsistentMaxPriorityEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, false) // Disabled by default + } + + fun setInsistentMaxPriorityEnabled(enabled: Boolean) { + sharedPrefs.edit { + putBoolean(SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED, enabled) + } + } + + fun getRecordLogs(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, false) // Disabled by default + } + + fun setRecordLogsEnabled(enabled: Boolean) { + sharedPrefs.edit { + putBoolean(SHARED_PREFS_RECORD_LOGS_ENABLED, enabled) + } + } + + fun getMessageBarEnabled(): Boolean { + return sharedPrefs.getBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, true) // Enabled by default + } + + fun setMessageBarEnabled(enabled: Boolean) { + sharedPrefs.edit { + putBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, enabled) + } + } + + fun getBatteryOptimizationsRemindTime(): Long { + return sharedPrefs.getLong(SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME, BATTERY_OPTIMIZATIONS_REMIND_TIME_ALWAYS) + } + + fun setBatteryOptimizationsRemindTime(timeMillis: Long) { + sharedPrefs.edit { + putLong(SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME, timeMillis) + } + } + + 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! + } + + fun setDefaultBaseUrl(baseUrl: String) { + if (baseUrl == "") { + sharedPrefs.edit { + remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key + .remove(SHARED_PREFS_DEFAULT_BASE_URL) + } + } else { + sharedPrefs.edit { + remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key + .putString(SHARED_PREFS_DEFAULT_BASE_URL, baseUrl) + } + } + } + + fun isGlobalMuted(): Boolean { + val mutedUntil = getGlobalMutedUntil() + return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000) + } + + fun getGlobalMutedUntil(): Long { + return sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L) + } + + fun setGlobalMutedUntil(mutedUntilTimestamp: Long) { + sharedPrefs.edit { + putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, mutedUntilTimestamp) + } + } + + fun checkGlobalMutedUntil(): Boolean { + val mutedUntil = sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L) + val expired = mutedUntil > 1L && System.currentTimeMillis()/1000 > mutedUntil + if (expired) { + sharedPrefs.edit { + putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L) + } + return true + } + return false + } + + fun getLastShareTopics(): List { + val topics = sharedPrefs.getString(SHARED_PREFS_LAST_TOPICS, "") ?: "" + return topics.split("\n").filter { validUrl(it) } + } + + fun addLastShareTopic(topic: String) { + val topics = (getLastShareTopics().filterNot { it == topic } + topic).takeLast(LAST_TOPICS_COUNT) + sharedPrefs.edit { + putString(SHARED_PREFS_LAST_TOPICS, topics.joinToString(separator = "\n")) + } + } + + private fun toSubscriptionList(list: List): List { + return list.map { s -> + Subscription( + id = s.id, + baseUrl = s.baseUrl, + topic = s.topic, + mutedUntil = s.mutedUntil, + minPriority = s.minPriority, + autoDelete = s.autoDelete, + insistent = s.insistent, + upAppId = s.upAppId, + upConnectorToken = s.upConnectorToken, + displayName = s.displayName, + totalCount = s.totalCount, + newCount = s.newCount, + lastActive = s.lastActive, + connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails() + ) + } + } + + private fun toSubscription(s: SubscriptionWithMetadata?): Subscription? { + if (s == null) { + return null + } + return Subscription( + id = s.id, + baseUrl = s.baseUrl, + topic = s.topic, + mutedUntil = s.mutedUntil, + minPriority = s.minPriority, + autoDelete = s.autoDelete, + insistent = s.insistent, + upAppId = s.upAppId, + upConnectorToken = s.upConnectorToken, + displayName = s.displayName, + totalCount = s.totalCount, + newCount = s.newCount, + lastActive = s.lastActive, + connectionDetails = connectionDetails[s.baseUrl] ?: ConnectionDetails() + ) + } + + fun updateConnectionDetails(baseUrl: String, state: ConnectionState, error: Throwable? = null, nextRetryTime: Long = 0L) { + val details = ConnectionDetails(state, error, nextRetryTime) + val current = connectionDetails[baseUrl] + if (current != details) { + if (state == ConnectionState.NOT_APPLICABLE && error == null) { + connectionDetails.remove(baseUrl) + } else { + connectionDetails[baseUrl] = details + } + connectionDetailsLiveData.postValue(connectionDetails.toMap()) + Log.d(TAG, "Connection details updated for $baseUrl: state=$state, error=${error?.message}, nextRetry=$nextRetryTime") + } + } + + fun getConnectionDetailsLiveData(): LiveData> { + return connectionDetailsLiveData + } + + fun getConnectionDetails(): Map { + return connectionDetails.toMap() + } + + fun getConnectionDetailsForBaseUrl(baseUrl: String): ConnectionDetails? { + return connectionDetails[baseUrl] + } + + fun getConnectionForceReconnectVersion(baseUrl: String): Long { + return connectionForceReconnectVersions[baseUrl] ?: 0L + } + + fun incrementConnectionForceReconnectVersion(baseUrl: String) { + connectionForceReconnectVersions.compute(baseUrl) { _, current -> (current ?: 0L) + 1 } + Log.d(TAG, "Connection force reconnect version incremented for $baseUrl: ${connectionForceReconnectVersions[baseUrl]}") + } + + companion object { + const val SHARED_PREFS_ID = "MainPreferences" + const val SHARED_PREFS_DELETE_WORKER_VERSION = "DeleteWorkerVersion" + const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" + const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil" + const val SHARED_PREFS_MIN_PRIORITY = "MinPriority" + const val SHARED_PREFS_AUTO_DOWNLOAD_MAX_SIZE = "AutoDownload" + const val SHARED_PREFS_AUTO_DELETE_SECONDS = "AutoDelete" + const val SHARED_PREFS_DARK_MODE = "DarkMode" + const val SHARED_PREFS_DYNAMIC_COLORS = "DynamicColors" + const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled" + const val SHARED_PREFS_UNIFIEDPUSH_ENABLED = "UnifiedPushEnabled" + const val SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED = "InsistentMaxPriority" + const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs" + const val SHARED_PREFS_MESSAGE_BAR_ENABLED = "MessageBarEnabled" + const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime" + 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" + + private const val LAST_TOPICS_COUNT = 3 + + const val MIN_PRIORITY_USE_GLOBAL = 0 + const val MIN_PRIORITY_ANY = 1 + + const val MUTED_UNTIL_SHOW_ALL = 0L + const val MUTED_UNTIL_FOREVER = 1L + const val MUTED_UNTIL_TOMORROW = 2L + + private const val ONE_MB = 1024 * 1024L + const val AUTO_DOWNLOAD_NEVER = 0L // Values must match values.xml + const val AUTO_DOWNLOAD_ALWAYS = 1L + const val AUTO_DOWNLOAD_DEFAULT = ONE_MB + + private const val ONE_DAY_SECONDS = 24 * 60 * 60L + const val AUTO_DELETE_USE_GLOBAL = -1L // Values must match values.xml + const val AUTO_DELETE_NEVER = 0L + const val AUTO_DELETE_ONE_DAY_SECONDS = ONE_DAY_SECONDS + const val AUTO_DELETE_THREE_DAYS_SECONDS = 3 * ONE_DAY_SECONDS + const val AUTO_DELETE_ONE_WEEK_SECONDS = 7 * ONE_DAY_SECONDS + const val AUTO_DELETE_ONE_MONTH_SECONDS = 30 * ONE_DAY_SECONDS + const val AUTO_DELETE_THREE_MONTHS_SECONDS = 90 * ONE_DAY_SECONDS + const val AUTO_DELETE_DEFAULT_SECONDS = AUTO_DELETE_ONE_MONTH_SECONDS + + const val INSISTENT_MAX_PRIORITY_USE_GLOBAL = -1 // Values must match values.xml + const val INSISTENT_MAX_PRIORITY_ENABLED = 1 // 0 = Disabled (but not needed in code) + + const val BATTERY_OPTIMIZATIONS_REMIND_TIME_ALWAYS = 1L + const val BATTERY_OPTIMIZATIONS_REMIND_TIME_NEVER = Long.MAX_VALUE + + private const val TAG = "NtfyRepository" + private var instance: Repository? = null + + fun getInstance(context: Context): Repository { + val database = Database.getInstance(context.applicationContext) + val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) + return getInstance(sharedPrefs, database) + } + + private fun getInstance(sharedPrefs: SharedPreferences, database: Database): Repository { + return synchronized(Repository::class) { + val newInstance = instance ?: Repository(sharedPrefs, database) + instance = newInstance + newInstance + } + } + } +} + +/* https://stackoverflow.com/a/57079290/1440785 */ +fun LiveData.combineWith( + liveData: LiveData, + block: (T?, K?) -> R +): LiveData { + val result = MediatorLiveData() + result.addSource(this) { + result.value = block(this.value, liveData.value) + } + result.addSource(liveData) { + result.value = block(this.value, liveData.value) + } + return result +} diff --git a/android/app/src/main/java/com/lonecloud/sup/msg/ApiService.kt b/android/app/src/main/java/com/lonecloud/sup/msg/ApiService.kt new file mode 100644 index 0000000..5243779 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/msg/ApiService.kt @@ -0,0 +1,193 @@ +package com.lonecloud.sup.msg + +import android.content.Context +import com.google.gson.Gson +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.db.Repository +import com.lonecloud.sup.service.NotAuthorizedException +import com.lonecloud.sup.util.ALL_PRIORITIES +import com.lonecloud.sup.util.HttpUtil +import com.lonecloud.sup.util.Log +import com.lonecloud.sup.util.PRIORITY_DEFAULT +import com.lonecloud.sup.util.topicUrl +import com.lonecloud.sup.util.topicUrlAuth +import com.lonecloud.sup.util.topicUrlJson +import com.lonecloud.sup.util.topicUrlJsonPoll +import okhttp3.Call +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSource +import java.io.IOException +import java.net.URLEncoder +import kotlin.random.Random + +class ApiService(private val context: Context) { + private val repository = Repository.getInstance(context) + private val gson = Gson() + private val parser = NotificationParser() + + suspend fun publish( + baseUrl: String, + topic: String, + message: String, + title: String = "", + priority: Int = PRIORITY_DEFAULT, + tags: List = emptyList(), + delay: String = "", + body: RequestBody? = null, + filename: String = "", + click: String = "", + attach: String = "", + email: String = "", + call: String = "", + markdown: Boolean = false, + onCancelAvailable: ((cancel: () -> Unit) -> Unit)? = null // Called when the HTTP request was started and cancellable (caller can cancel) + ) { + val url = topicUrl(baseUrl, topic) + val query = mutableListOf() + if (priority in ALL_PRIORITIES) { + query.add("priority=$priority") + } + if (tags.isNotEmpty()) { + query.add("tags=${URLEncoder.encode(tags.joinToString(","), "UTF-8")}") + } + if (title.isNotEmpty()) { + query.add("title=${URLEncoder.encode(title, "UTF-8")}") + } + if (delay.isNotEmpty()) { + query.add("delay=${URLEncoder.encode(delay, "UTF-8")}") + } + if (filename.isNotEmpty()) { + query.add("filename=${URLEncoder.encode(filename, "UTF-8")}") + } + if (click.isNotEmpty()) { + query.add("click=${URLEncoder.encode(click, "UTF-8")}") + } + if (attach.isNotEmpty()) { + query.add("attach=${URLEncoder.encode(attach, "UTF-8")}") + } + if (email.isNotEmpty()) { + query.add("email=${URLEncoder.encode(email, "UTF-8")}") + } + if (call.isNotEmpty()) { + query.add("call=${URLEncoder.encode(call, "UTF-8")}") + } + if (markdown) { + query.add("markdown=true") + } + if (body != null) { + query.add("message=${URLEncoder.encode(message.replace("\n", "\\n"), "UTF-8")}") + } + val urlWithQuery = if (query.isNotEmpty()) { + url + "?" + query.joinToString("&") + } else { + url + } + val request = HttpUtil.requestBuilder(urlWithQuery) + .put(body ?: message.toRequestBody()) + .build() + Log.d(TAG, "Publishing to $request") + val httpCall = HttpUtil.longCallClient(context, baseUrl).newCall(request) + onCancelAvailable?.invoke { httpCall.cancel() } // Notify caller that HTTP request can now be canceled + httpCall.execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException() + } else if (response.code == 413) { + throw EntityTooLargeException() + } else if (!response.isSuccessful) { + // Try to parse error response from server + val errorBody = response.body.string() + val apiError = try { + gson.fromJson(errorBody, ErrorResponse::class.java) + } catch (e: Exception) { + null + } + if (apiError?.error != null && apiError.code != null) { + throw ApiException(apiError.error, apiError.code) + } + throw Exception("Unexpected response ${response.code} when publishing to $url") + } + Log.d(TAG, "Successfully published to $url") + } + } + + suspend fun poll(subscriptionId: Long, baseUrl: String, topic: String, since: String? = null): List { + val sinceVal = since ?: "all" + val url = topicUrlJsonPoll(baseUrl, topic, sinceVal) + Log.d(TAG, "Polling topic $url") + + val request = HttpUtil.requestBuilder(url).build() + HttpUtil.defaultClient(context, baseUrl).newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Unexpected response ${response.code} when polling topic $url") + } + val body = response.body.string().trim() + if (body.isEmpty()) return emptyList() + val notifications = body.lines().mapNotNull { line -> + parser.parse(line, subscriptionId = subscriptionId, notificationId = 0) // No notification when we poll + } + + Log.d(TAG, "Notifications: $notifications") + return notifications + } + } + + suspend fun subscribe( + baseUrl: String, + topics: String, + since: String? + ): Pair { + val sinceVal = since ?: "all" + val url = topicUrlJson(baseUrl, topics, sinceVal) + Log.d(TAG, "Opening subscription connection to $url") + val request = HttpUtil.requestBuilder(url).build() + val call = HttpUtil.subscriberClient(context, baseUrl).newCall(request) + val response = call.execute() + if (!response.isSuccessful) { + val code = response.code + val message = response.message + response.close() + if (code == 401 || code == 403) { + throw NotAuthorizedException("$code $message") + } + throw IOException("Unexpected response $code when subscribing to $url") + } + return Pair(call, response.body.source()) + } + + suspend fun checkAuth(baseUrl: String, topic: String): Boolean { + Log.d(TAG, "Checking anonymous read against ${topicUrl(baseUrl, topic)}") + val url = topicUrlAuth(baseUrl, topic) + val request = HttpUtil.requestBuilder(url).build() + HttpUtil.defaultClient(context, baseUrl).newCall(request).execute().use { response -> + if (response.isSuccessful) { + return true + } else if (response.code == 404) { + return true // Special case: Anonymous login to old servers return 404 since //auth doesn't exist + } else if (response.code == 401 || response.code == 403) { // See server/server.go + return false + } + throw Exception("Unexpected server response ${response.code}") + } + } + + class UnauthorizedException : Exception() + class EntityTooLargeException : Exception() + class ApiException(val error: String, val code: Int) : Exception(error) + + private data class ErrorResponse( + val code: Int?, + val http: Int?, + val error: String? + ) + + companion object { + private const val TAG = "NtfyApiService" + + // These constants have corresponding values in the server codebase! + const val CONTROL_TOPIC = "~control" + const val EVENT_MESSAGE = "message" + const val EVENT_KEEPALIVE = "keepalive" + const val EVENT_POLL_REQUEST = "poll_request" + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/msg/BroadcastService.kt b/android/app/src/main/java/com/lonecloud/sup/msg/BroadcastService.kt new file mode 100644 index 0000000..89a347d --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/msg/BroadcastService.kt @@ -0,0 +1,109 @@ +package com.lonecloud.sup.msg + +import android.content.Context +import android.content.Intent +import com.lonecloud.sup.R +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.db.Repository +import com.lonecloud.sup.db.Subscription +import com.lonecloud.sup.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +/** + * The broadcast service is responsible for sending and receiving broadcast intents + * in order to facilitate tasks app integrations. + */ +class BroadcastService(private val ctx: Context) { + fun sendMessage(subscription: Subscription, notification: Notification, muted: Boolean) { + val intent = Intent() + intent.action = MESSAGE_RECEIVED_ACTION + intent.putExtra("id", notification.id) + intent.putExtra("base_url", subscription.baseUrl) + intent.putExtra("topic", subscription.topic) + intent.putExtra("time", notification.timestamp.toInt()) + intent.putExtra("title", notification.title) + intent.putExtra("message", decodeMessage(notification)) + intent.putExtra("message_bytes", decodeBytesMessage(notification)) + intent.putExtra("tags", notification.tags) + intent.putExtra("tags_map", joinTagsMap(splitTags(notification.tags))) + intent.putExtra("priority", notification.priority) + intent.putExtra("muted", muted) + intent.putExtra("muted_str", muted.toString()) + + Log.d(TAG, "Sending message intent broadcast: ${intent.action} with extras ${intent.extras}") + ctx.sendBroadcast(intent) + } + + /** + * This receiver is triggered when the SEND_MESSAGE intent is received. + * See AndroidManifest.xml for details. + */ + class BroadcastReceiver : android.content.BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "Broadcast received: $intent") + when (intent.action) { + MESSAGE_SEND_ACTION -> send(context, intent) + } + } + + private fun send(ctx: Context, intent: Intent) { + val api = ApiService(ctx) + val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url) + val topic = getStringExtra(intent, "topic") ?: return + val message = getStringExtra(intent, "message") ?: return + val title = getStringExtra(intent, "title") ?: "" + val tags = getStringExtra(intent,"tags") ?: "" + val priority = when (getStringExtra(intent, "priority")) { + "min", "1" -> 1 + "low", "2" -> 2 + "default", "3" -> 3 + "high", "4" -> 4 + "urgent", "max", "5" -> 5 + else -> 0 + } + val delay = getStringExtra(intent,"delay") ?: "" + GlobalScope.launch(Dispatchers.IO) { + try { + Log.d(TAG, "Publishing message $intent") + api.publish( + baseUrl = baseUrl, + topic = topic, + message = message, + title = title, + priority = priority, + tags = splitTags(tags), + delay = delay + ) + } catch (e: Exception) { + Log.w(TAG, "Unable to publish message: ${e.message}", e) + } + } + } + + /** + * Gets an extra as a String value, even if the extra may be an int or a long. + */ + private fun getStringExtra(intent: Intent, name: String): String? { + if (intent.getStringExtra(name) != null) { + return intent.getStringExtra(name) + } else if (intent.getIntExtra(name, DOES_NOT_EXIST) != DOES_NOT_EXIST) { + return intent.getIntExtra(name, DOES_NOT_EXIST).toString() + } else if (intent.getLongExtra(name, DOES_NOT_EXIST.toLong()) != DOES_NOT_EXIST.toLong()) { + return intent.getLongExtra(name, DOES_NOT_EXIST.toLong()).toString() + } + return null + } + } + + companion object { + private const val TAG = "NtfyBroadcastService" + private const val DOES_NOT_EXIST = -2586000 + + // These constants cannot be changed without breaking the contract; also see manifest + private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED" + private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE" + private const val USER_ACTION_ACTION = "io.heckel.ntfy.USER_ACTION" + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/msg/Message.kt b/android/app/src/main/java/com/lonecloud/sup/msg/Message.kt new file mode 100644 index 0000000..2da5381 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/msg/Message.kt @@ -0,0 +1,49 @@ +package com.lonecloud.sup.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 */ +@Keep +data class Message( + val id: String, + val time: Long, + val event: String, + val topic: String, + val priority: Int?, + val tags: List?, + val click: String?, + val icon: String?, + val actions: List?, + val title: String?, + val message: String, + @SerializedName("content_type") val contentType: String?, + val encoding: String?, + val attachment: MessageAttachment?, +) + +@Keep +data class MessageAttachment( + val name: String, + val type: String?, + val size: Long?, + val expires: Long?, + val url: String, +) + +@Keep +data class MessageAction( + val id: String, + val action: String, + val label: String, // "view", "broadcast" or "http" + val clear: Boolean?, // clear notification after successful execution + val url: String?, // used in "view" and "http" actions + val method: String?, // used in "http" action, default is POST (!) + val headers: Map?, // used in "http" action + val body: String?, // used in "http" action + val intent: String?, // used in "broadcast" action + val extras: Map?, // used in "broadcast" action +) + +const val MESSAGE_ENCODING_BASE64 = "base64" diff --git a/android/app/src/main/java/com/lonecloud/sup/msg/NotificationDispatcher.kt b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationDispatcher.kt new file mode 100644 index 0000000..410f2fb --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationDispatcher.kt @@ -0,0 +1,79 @@ +package com.lonecloud.sup.msg + +import android.content.Context +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.db.Repository +import com.lonecloud.sup.db.Subscription +import com.lonecloud.sup.util.Log +import com.lonecloud.sup.up.Distributor +import com.lonecloud.sup.util.decodeBytesMessage +import com.lonecloud.sup.util.safeLet + +/** + * The notification dispatcher figures out what to do with a notification. + * It may display a notification, send out a broadcast, or forward via UnifiedPush. + */ +class NotificationDispatcher(val context: Context, val repository: Repository) { + private val notifier = NotificationService(context) + private val broadcaster = BroadcastService(context) + private val distributor = Distributor(context) + + fun init() { + notifier.createNotificationChannels() + } + + fun dispatch(subscription: Subscription, notification: Notification) { + Log.d(TAG, "Dispatching $notification for subscription $subscription") + + val muted = getMuted(subscription) + val notify = shouldNotify(subscription, notification, muted) + val broadcast = shouldBroadcast(subscription) + val distribute = shouldDistribute(subscription) + if (notify) { + notifier.display(subscription, notification) + } + if (broadcast) { + broadcaster.sendMessage(subscription, notification, muted) + } + if (distribute) { + safeLet(subscription.upAppId, subscription.upConnectorToken) { appId, connectorToken -> + distributor.sendMessage(appId, connectorToken, decodeBytesMessage(notification)) + } + } + } + + private fun shouldNotify(subscription: Subscription, notification: Notification, muted: Boolean): Boolean { + if (subscription.upAppId != null) { + return false + } + val priority = if (notification.priority > 0) notification.priority else 3 + val minPriority = if (subscription.minPriority > 0) subscription.minPriority else repository.getMinPriority() + if (priority < minPriority) { + return false + } + val detailsVisible = repository.detailViewSubscriptionId.get() == notification.subscriptionId + return !detailsVisible && !muted + } + + private fun shouldBroadcast(subscription: Subscription): Boolean { + if (subscription.upAppId != null) { // Never broadcast for UnifiedPush subscriptions + return false + } + return repository.getBroadcastEnabled() + } + + private fun shouldDistribute(subscription: Subscription): Boolean { + return subscription.upAppId != null // Only distribute for UnifiedPush subscriptions + } + + private fun getMuted(subscription: Subscription): Boolean { + if (repository.isGlobalMuted()) { + return true + } + return subscription.mutedUntil == 1L || (subscription.mutedUntil > 1L && subscription.mutedUntil > System.currentTimeMillis()/1000) + } + + companion object { + private const val TAG = "NtfyNotifDispatch" + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/msg/NotificationParser.kt b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationParser.kt new file mode 100644 index 0000000..0444b39 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationParser.kt @@ -0,0 +1,37 @@ +package com.lonecloud.sup.msg + +import com.google.gson.Gson +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.util.joinTags +import com.lonecloud.sup.util.toPriority +import java.lang.reflect.Type + +class NotificationParser { + private val gson = Gson() + + fun parse(s: String, subscriptionId: Long = 0, notificationId: Int = 0): Notification? { + val notificationWithTopic = parseWithTopic(s, subscriptionId = subscriptionId, notificationId = notificationId) + return notificationWithTopic?.notification + } + + fun parseWithTopic(s: String, subscriptionId: Long = 0, notificationId: Int = 0): NotificationWithTopic? { + val message = gson.fromJson(s, Message::class.java) + if (message.event != ApiService.EVENT_MESSAGE) { + return null + } + val notification = Notification( + id = message.id, + subscriptionId = subscriptionId, + timestamp = message.time, + title = message.title ?: "", + message = message.message, + priority = toPriority(message.priority), + tags = joinTags(message.tags), + notificationId = notificationId, + deleted = false + ) + return NotificationWithTopic(message.topic, notification) + } + + data class NotificationWithTopic(val topic: String, val notification: Notification) +} diff --git a/android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt new file mode 100644 index 0000000..af49613 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/msg/NotificationService.kt @@ -0,0 +1,232 @@ +package com.lonecloud.sup.msg + +import android.app.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.RingtoneManager +import android.net.Uri +import androidx.core.app.NotificationCompat +import com.lonecloud.sup.R +import com.lonecloud.sup.db.* +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.ui.Colors +import com.lonecloud.sup.ui.DetailActivity +import com.lonecloud.sup.ui.MainActivity +import com.lonecloud.sup.util.* +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 appBaseUrl = context.getString(R.string.app_base_url) + + fun display(subscription: Subscription, notification: Notification) { + displayInternal(subscription, notification, update = false) + } + + fun update(subscription: Subscription, notification: Notification, isNew: Boolean) { + displayInternal(subscription, notification, update = !isNew) + } + + fun cancel(notificationId: Int) { + notificationManager.cancel(notificationId) + } + + fun cancel(subscription: Subscription, notification: Notification) { + notificationManager.cancel(notification.notificationId) + } + + fun createNotificationChannels() { + val groupId = DEFAULT_GROUP + val groupName = context.getString(R.string.channel_notifications_group_default_name) + maybeCreateNotificationGroup(groupId, groupName) + (PRIORITY_MIN..PRIORITY_MAX).forEach { priority -> + maybeCreateNotificationChannel(groupId, priority) + } + } + + private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false) { + val title = formatTitle(appBaseUrl, subscription, notification) + val groupId = DEFAULT_GROUP + val channelId = toChannelId(groupId, notification.priority) + val insistent = notification.priority == PRIORITY_MAX && + (repository.getInsistentMaxPriorityEnabled() || subscription.insistent == Repository.INSISTENT_MAX_PRIORITY_ENABLED) + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_notification) + .setColor(Colors.notificationIcon(context)) + .setContentTitle(title) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + setStyleAndText(builder, notification) + setClickAction(builder, subscription) + maybeSetDeleteIntent(builder, insistent) + maybeSetSound(builder, insistent, update) + + maybeCreateNotificationGroup(groupId, subscriptionGroupName(subscription)) + maybeCreateNotificationChannel(groupId, notification.priority) + maybePlayInsistentSound(groupId, insistent) + + notificationManager.notify(notification.notificationId, builder.build()) + } + + private fun maybeSetDeleteIntent(builder: NotificationCompat.Builder, insistent: Boolean) { + if (!insistent) { + return + } + val intent = Intent(context, DeleteBroadcastReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE) + builder.setDeleteIntent(pendingIntent) + } + + private fun maybeSetSound(builder: NotificationCompat.Builder, insistent: Boolean, update: Boolean) { + val hasSound = !update && !insistent + if (hasSound) { + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + builder.setSound(defaultSoundUri) + } else { + builder.setSound(null) + } + } + + private fun setStyleAndText(builder: NotificationCompat.Builder, notification: Notification) { + val message = formatMessage(notification) + builder + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + } + + private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription) { + builder.setContentIntent(detailActivityIntent(subscription)) + } + + private fun subscriptionGroupName(subscription: Subscription): String { + return displayName(appBaseUrl, subscription) + } + + private fun displayName(appBaseUrl: String?, subscription: Subscription): String { + return subscription.displayName ?: subscriptionTopicShortUrl(subscription) + } + + class DeleteBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "Media player: Stopping insistent ring") + val mediaPlayer = Repository.getInstance(context).mediaPlayer + mediaPlayer.stop() + } + } + + private fun detailActivityIntent(subscription: Subscription): PendingIntent? { + val intent = Intent(context, DetailActivity::class.java).apply { + putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) + putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) + putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) + putExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription)) + putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil) + } + return TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(intent) + getPendingIntent(Random().nextInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + } + + private fun maybeCreateNotificationChannel(group: String, priority: Int) { + val channelId = toChannelId(group, priority) + val pause = 300L + val channel = when (priority) { + PRIORITY_MIN -> NotificationChannel(channelId, context.getString(R.string.common_priority_min_name), NotificationManager.IMPORTANCE_MIN) + PRIORITY_LOW -> NotificationChannel(channelId, context.getString(R.string.common_priority_low_name), NotificationManager.IMPORTANCE_LOW) + PRIORITY_HIGH -> { + val channel = NotificationChannel(channelId, context.getString(R.string.common_priority_high_name), NotificationManager.IMPORTANCE_HIGH) + channel.enableVibration(true) + channel.vibrationPattern = longArrayOf( + pause, 100, pause, 100, pause, 100, + pause, 2000 + ) + channel + } + PRIORITY_MAX -> { + val channel = NotificationChannel(channelId, context.getString(R.string.common_priority_max_name), NotificationManager.IMPORTANCE_HIGH) + channel.enableLights(true) + channel.enableVibration(true) + channel.setBypassDnd(true) + channel.vibrationPattern = longArrayOf( + pause, 100, pause, 100, pause, 100, + pause, 2000, + pause, 100, pause, 100, pause, 100, + pause, 2000, + pause, 100, pause, 100, pause, 100, + pause, 2000 + ) + channel + } + else -> NotificationChannel(channelId, context.getString(R.string.common_priority_default_name), NotificationManager.IMPORTANCE_DEFAULT) + } + channel.group = group + notificationManager.createNotificationChannel(channel) + } + + private fun maybeDeleteNotificationChannel(group: String, priority: Int) { + notificationManager.deleteNotificationChannel(toChannelId(group, priority)) + } + + private fun maybeCreateNotificationGroup(id: String, name: String) { + notificationManager.createNotificationChannelGroup(NotificationChannelGroup(id, name)) + } + + private fun maybeDeleteNotificationGroup(id: String) { + notificationManager.deleteNotificationChannelGroup(id) + } + + private fun toChannelId(groupId: String, priority: Int): String { + return when (priority) { + PRIORITY_MIN -> groupId + GROUP_SUFFIX_PRIORITY_MIN + PRIORITY_LOW -> groupId + GROUP_SUFFIX_PRIORITY_LOW + PRIORITY_HIGH -> groupId + GROUP_SUFFIX_PRIORITY_HIGH + PRIORITY_MAX -> groupId + GROUP_SUFFIX_PRIORITY_MAX + else -> groupId + GROUP_SUFFIX_PRIORITY_DEFAULT + } + } + + private fun maybePlayInsistentSound(groupId: String, insistent: Boolean) { + if (!insistent) { + return + } + try { + val mediaPlayer = repository.mediaPlayer + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + if (audioManager.getStreamVolume(AudioManager.STREAM_ALARM) != 0) { + Log.d(TAG, "Media player: Playing insistent alarm on alarm channel") + mediaPlayer.reset() + mediaPlayer.setDataSource(context, getInsistentSound(groupId)) + mediaPlayer.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build()) + mediaPlayer.isLooping = true + mediaPlayer.prepare() + mediaPlayer.start() + } else { + Log.d(TAG, "Media player: Alarm volume is 0; not playing insistent alarm") + } + } catch (e: Exception) { + Log.w(TAG, "Media player: Failed to play insistent alarm", e) + } + } + + private fun getInsistentSound(groupId: String): Uri { + val channelId = toChannelId(groupId, PRIORITY_MAX) + val channel = notificationManager.getNotificationChannel(channelId) + return channel.sound + } + + companion object { + private const val TAG = "NtfyNotifService" + private const val DEFAULT_GROUP = "ntfy" + private const val SUBSCRIPTION_GROUP_PREFIX = "ntfy-subscription-" + private const val GROUP_SUFFIX_PRIORITY_MIN = "-min" + private const val GROUP_SUFFIX_PRIORITY_LOW = "-low" + private const val GROUP_SUFFIX_PRIORITY_DEFAULT = "" + private const val GROUP_SUFFIX_PRIORITY_HIGH = "-high" + private const val GROUP_SUFFIX_PRIORITY_MAX = "-max" + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/service/Exceptions.kt b/android/app/src/main/java/com/lonecloud/sup/service/Exceptions.kt new file mode 100644 index 0000000..8d3b06e --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/service/Exceptions.kt @@ -0,0 +1,12 @@ +package com.lonecloud.sup.service + +class NotAuthorizedException(message: String, val user: Any? = null) : Exception(message) + +fun Throwable.hasCause(causeClass: Class): Boolean { + var current: Throwable? = this + while (current != null) { + if (causeClass.isInstance(current)) return true + current = current.cause + } + return false +} diff --git a/android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt b/android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt new file mode 100644 index 0000000..3cea5a4 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/service/SignalListenerService.kt @@ -0,0 +1,88 @@ +package com.lonecloud.sup.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.lonecloud.sup.R +import com.lonecloud.sup.app.Application +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.msg.NotificationDispatcher +import com.lonecloud.sup.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Service that listens for Signal notifications and processes them. + * This replaces ntfy's SubscriberService which polls HTTP/WebSocket. + * We get notifications pushed via Signal instead. + */ +class SignalListenerService : Service() { + private val repository by lazy { (application as Application).repository } + private val dispatcher by lazy { NotificationDispatcher(this, repository) } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service created") + createForegroundNotification() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "Service started") + + // Process notification from intent if present + intent?.getStringExtra(EXTRA_NOTIFICATION_DATA)?.let { data -> + scope.launch { + processNotification(data) + } + } + + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createForegroundNotification() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + getString(R.string.channel_subscriber_service_name), + NotificationManager.IMPORTANCE_LOW + ) + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Listening for notifications via Signal") + .setSmallIcon(R.drawable.ic_notification) + .build() + + startForeground(NOTIFICATION_ID, notification) + } + + private suspend fun processNotification(data: String) { + try { + // Parse notification data and dispatch + Log.d(TAG, "Processing notification: $data") + // This will be called by SignalNotificationListener + // with parsed notification data + } catch (e: Exception) { + Log.e(TAG, "Error processing notification: ${e.message}", e) + } + } + + companion object { + private const val TAG = "NtfySignalListener" + private const val NOTIFICATION_CHANNEL_ID = "ntfy-signal" + private const val NOTIFICATION_ID = 1 + const val EXTRA_NOTIFICATION_DATA = "notification_data" + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/AddFragment.kt b/android/app/src/main/java/com/lonecloud/sup/ui/AddFragment.kt new file mode 100644 index 0000000..9a402cd --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/ui/AddFragment.kt @@ -0,0 +1,466 @@ +package com.lonecloud.sup.ui + +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.* +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.lonecloud.sup.BuildConfig +import com.lonecloud.sup.R +import com.lonecloud.sup.db.Repository +import com.lonecloud.sup.msg.ApiService +import com.lonecloud.sup.util.CertUtil +import com.lonecloud.sup.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import androidx.core.view.isVisible +import androidx.core.view.isGone +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +class AddFragment : DialogFragment() { + private lateinit var repository: Repository + private lateinit var api: ApiService + private lateinit var subscribeListener: SubscribeListener + private lateinit var appBaseUrl: String + private var defaultBaseUrl: String? = null + + private lateinit var toolbar: MaterialToolbar + private lateinit var actionMenuItem: MenuItem + private lateinit var subscribeView: View + private lateinit var loginView: View + + // Subscribe page + private lateinit var subscribeTopicText: TextInputEditText + private lateinit var subscribeBaseUrlLayout: TextInputLayout + private lateinit var subscribeBaseUrlText: AutoCompleteTextView + private lateinit var subscribeUseAnotherServerCheckbox: CheckBox + private lateinit var subscribeUseAnotherServerDescription: TextView + private lateinit var subscribeInstantDeliveryBox: View + private lateinit var subscribeInstantDeliveryCheckbox: CheckBox + private lateinit var subscribeInstantDeliveryDescription: View + private lateinit var subscribeForegroundDescription: TextView + private lateinit var subscribeProgress: ProgressBar + private lateinit var subscribeErrorText: TextView + private lateinit var subscribeErrorTextImage: View + + // Login page + private lateinit var loginUsernameText: TextInputEditText + private lateinit var loginPasswordText: TextInputEditText + private lateinit var loginProgress: ProgressBar + private lateinit var loginErrorText: TextView + private lateinit var loginErrorTextImage: View + + interface SubscribeListener { + fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + subscribeListener = activity as SubscribeListener + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (activity == null) { + throw IllegalStateException("Activity cannot be null") + } + + // Dependencies (Fragments need a default constructor) + repository = Repository.getInstance(requireActivity()) + api = ApiService(requireContext()) + appBaseUrl = getString(R.string.app_base_url) + defaultBaseUrl = repository.getDefaultBaseUrl() + + // Build root view + val view = requireActivity().layoutInflater.inflate(R.layout.fragment_add_dialog, null) + + // Setup toolbar + toolbar = view.findViewById(R.id.add_dialog_toolbar) + toolbar.setNavigationOnClickListener { + dismiss() + } + toolbar.setOnMenuItemClickListener { menuItem -> + if (menuItem.itemId == R.id.add_dialog_action_button) { + onActionButtonClick() + true + } else { + false + } + } + actionMenuItem = toolbar.menu.findItem(R.id.add_dialog_action_button) + + // Main "pages" + subscribeView = view.findViewById(R.id.add_dialog_subscribe_view) + subscribeView.visibility = View.VISIBLE + loginView = view.findViewById(R.id.add_dialog_login_view) + loginView.visibility = View.GONE + + // Fields for "subscribe page" + subscribeTopicText = view.findViewById(R.id.add_dialog_subscribe_topic_text) + subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_subscribe_base_url_layout) + subscribeBaseUrlLayout.background = view.background + subscribeBaseUrlLayout.makeEndIconSmaller(resources) // Hack! + subscribeBaseUrlText = view.findViewById(R.id.add_dialog_subscribe_base_url_text) + subscribeBaseUrlText.background = view.background + subscribeBaseUrlText.hint = defaultBaseUrl ?: appBaseUrl + subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_box) + subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_checkbox) + subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_description) + subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_subscribe_use_another_server_checkbox) + subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_subscribe_use_another_server_description) + subscribeForegroundDescription = view.findViewById(R.id.add_dialog_subscribe_foreground_description) + subscribeProgress = view.findViewById(R.id.add_dialog_subscribe_progress) + subscribeErrorText = view.findViewById(R.id.add_dialog_subscribe_error_text) + subscribeErrorText.visibility = View.GONE + subscribeErrorTextImage = view.findViewById(R.id.add_dialog_subscribe_error_text_image) + subscribeErrorTextImage.visibility = View.GONE + + // Fields for "login page" + loginUsernameText = view.findViewById(R.id.add_dialog_login_username) + loginPasswordText = view.findViewById(R.id.add_dialog_login_password) + loginProgress = view.findViewById(R.id.add_dialog_login_progress) + loginErrorText = view.findViewById(R.id.add_dialog_login_error_text) + loginErrorTextImage = view.findViewById(R.id.add_dialog_login_error_text_image) + + // Set foreground description text + subscribeForegroundDescription.text = getString(R.string.add_dialog_foreground_description, shortUrl(appBaseUrl)) + + // Hide instant delivery UI (no Firebase) + subscribeInstantDeliveryBox.visibility = View.GONE + + // Add baseUrl auto-complete behavior + lifecycleScope.launch(Dispatchers.IO) { + val baseUrlsRaw = repository.getSubscriptions() + .groupBy { it.baseUrl } + .map { it.key } + .filterNot { it == appBaseUrl } + val baseUrls = if (defaultBaseUrl != null) { + (baseUrlsRaw.filterNot { it == defaultBaseUrl } + appBaseUrl).sorted() + } else { + baseUrlsRaw.sorted() + } + val activity = activity ?: return@launch // We may have pressed "Cancel" + activity.runOnUiThread { + initBaseUrlDropdown(baseUrls, subscribeBaseUrlText, subscribeBaseUrlLayout) + } + } + + // Subscribe view validation + val subscribeTextWatcher = AfterChangedTextWatcher { + validateInputSubscribeView() + } + subscribeTopicText.addTextChangedListener(subscribeTextWatcher) + subscribeBaseUrlText.addTextChangedListener(subscribeTextWatcher) + subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, _ -> + validateInputSubscribeView() + } + subscribeUseAnotherServerCheckbox.setOnCheckedChangeListener { _, _ -> + validateInputSubscribeView() + } + + // Username/password validation on type + val loginTextWatcher = AfterChangedTextWatcher { + validateInputLoginView() + } + loginUsernameText.addTextChangedListener(loginTextWatcher) + loginPasswordText.addTextChangedListener(loginTextWatcher) + + // Build dialog + val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog) + dialog.setContentView(view) + + // Initial validation + validateInputSubscribeView() + + return dialog + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + } + + override fun onResume() { + super.onResume() + // Show keyboard after the dialog is fully visible + subscribeTopicText.postDelayed({ + subscribeTopicText.requestFocus() + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(subscribeTopicText, InputMethodManager.SHOW_IMPLICIT) + }, 200) + } + + private fun onActionButtonClick() { + val topic = subscribeTopicText.text.toString() + val baseUrl = getBaseUrl() + if (subscribeView.isVisible) { + checkReadAndMaybeShowLogin(baseUrl, topic) + } else if (loginView.isVisible) { + loginAndMaybeDismiss(baseUrl, topic) + } + } + + private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) { + subscribeProgress.visibility = View.VISIBLE + subscribeErrorText.visibility = View.GONE + subscribeErrorTextImage.visibility = View.GONE + enableSubscribeView(false) + lifecycleScope.launch(Dispatchers.IO) { + try { + val authorized = api.checkAuth(baseUrl, topic) + if (authorized) { + Log.d(TAG, "Access granted to topic ${topicUrl(baseUrl, topic)}") + dismissDialog() + } else { + Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog") + val activity = activity ?: return@launch // We may have pressed "Cancel" + activity.runOnUiThread { + showLoginView(activity) + } + } + } catch (e: Exception) { + Log.w(TAG, "Connection to topic failed: ${e.message}", e) + + // If this is an SSL certificate error, show the trust cert dialog + // Never show the dialog for the app base URL + if (isSSLException(e) && baseUrl != appBaseUrl) { + Log.d(TAG, "SSL certificate error detected, attempting to fetch certificate for user review") + handleSSLException(baseUrl) + } else { + showErrorAndReenableSubscribeView(e.message) + } + } + } + } + + private fun isSSLException(e: Exception): Boolean { + var cause: Throwable? = e + while (cause != null) { + if (cause is SSLHandshakeException || cause is SSLPeerUnverifiedException || cause is CertificateException) { + return true + } + cause = cause.cause + } + return false + } + + private fun handleSSLException(baseUrl: String) { + // Try to fetch the server's certificate + val activity = activity ?: return + val certUtil = CertUtil.getInstance(requireContext()) + val certificate = certUtil.fetchServerCertificate(baseUrl) + activity.runOnUiThread { + if (certificate != null) { + showCertificateTrustDialog(baseUrl, certificate) + } else { + // Could not fetch certificate, show generic SSL error + showErrorAndReenableSubscribeView(getString(R.string.add_dialog_error_ssl_untrusted)) + } + } + } + + private fun showCertificateTrustDialog(baseUrl: String, certificate: X509Certificate) { + subscribeProgress.visibility = View.GONE + enableSubscribeView(true) + showErrorAndReenableSubscribeView(getString(R.string.add_dialog_error_ssl_untrusted)) + } + + private fun showErrorAndReenableSubscribeView(message: String?) { + val activity = activity ?: return // We may have pressed "Cancel" + activity.runOnUiThread { + subscribeProgress.visibility = View.GONE + subscribeErrorText.visibility = View.VISIBLE + subscribeErrorText.text = message + subscribeErrorTextImage.visibility = View.VISIBLE + enableSubscribeView(true) + } + } + + private fun loginAndMaybeDismiss(baseUrl: String, topic: String) { + loginProgress.visibility = View.VISIBLE + loginErrorText.visibility = View.GONE + loginErrorTextImage.visibility = View.GONE + enableLoginView(false) + val username = loginUsernameText.text.toString() + val password = loginPasswordText.text.toString() + lifecycleScope.launch(Dispatchers.IO) { + Log.d(TAG, "Checking read access for user $username to topic ${topicUrl(baseUrl, topic)}") + try { + val authorized = api.checkAuth(baseUrl, topic) + if (authorized) { + Log.d(TAG, "Access granted for user $username to topic ${topicUrl(baseUrl, topic)}") + dismissDialog() + } else { + Log.w(TAG, "Access not allowed for user $username to topic ${topicUrl(baseUrl, topic)}") + showErrorAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized, username)) + } + } catch (e: Exception) { + Log.w(TAG, "Connection to topic failed during login: ${e.message}", e) + showErrorAndReenableLoginView(e.message) + } + } + } + + private fun showErrorAndReenableLoginView(message: String?) { + val activity = activity ?: return // We may have pressed "Cancel" + activity.runOnUiThread { + loginProgress.visibility = View.GONE + loginErrorText.visibility = View.VISIBLE + loginErrorText.text = message + loginErrorTextImage.visibility = View.VISIBLE + enableLoginView(true) + } + } + + private fun validateInputSubscribeView() { + if (!this::actionMenuItem.isInitialized) return // As per crash seen in Google Play + + // Show/hide server selection UI + if (subscribeUseAnotherServerCheckbox.isChecked) { + subscribeUseAnotherServerDescription.visibility = View.VISIBLE + subscribeBaseUrlLayout.visibility = View.VISIBLE + } else { + subscribeUseAnotherServerDescription.visibility = View.GONE + subscribeBaseUrlLayout.visibility = View.GONE + } + + // Enable/disable "Subscribe" button + lifecycleScope.launch(Dispatchers.IO) { + val baseUrl = getBaseUrl() + val topic = subscribeTopicText.text.toString() + val subscription = repository.getSubscription(baseUrl, topic) + + activity?.let { + it.runOnUiThread { + if (subscription != null || DISALLOWED_TOPICS.contains(topic)) { + actionMenuItem.isEnabled = false + } else if (subscribeUseAnotherServerCheckbox.isChecked) { + actionMenuItem.isEnabled = validTopic(topic) && validUrl(baseUrl) + } else { + actionMenuItem.isEnabled = validTopic(topic) + } + } + } + } + } + + private fun validateInputLoginView() { + if (!this::actionMenuItem.isInitialized || !this::loginUsernameText.isInitialized || !this::loginPasswordText.isInitialized) { + return // As per crash seen in Google Play + } + if (loginUsernameText.isGone) { + actionMenuItem.isEnabled = true + } else { + actionMenuItem.isEnabled = (loginUsernameText.text?.isNotEmpty() ?: false) + && (loginPasswordText.text?.isNotEmpty() ?: false) + } + } + + private fun dismissDialog() { + Log.d(TAG, "Closing dialog and calling onSubscribe handler") + val activity = activity?: return // We may have pressed "Cancel" + activity.runOnUiThread { + val baseUrl = getBaseUrl() + val topic = subscribeTopicText.text.toString() + val instant = true // Always use foreground service (no Firebase) + subscribeListener.onSubscribe(topic, baseUrl, instant) + dialog?.dismiss() + } + } + + private fun getBaseUrl(): String { + return if (subscribeUseAnotherServerCheckbox.isChecked) { + subscribeBaseUrlText.text.toString() + } else { + return defaultBaseUrl ?: appBaseUrl + } + } + + private fun showSubscribeView() { + resetSubscribeView() + toolbar.setTitle(R.string.add_dialog_title) + actionMenuItem.setTitle(R.string.add_dialog_button_subscribe) + toolbar.setNavigationOnClickListener { + dismiss() + } + loginView.visibility = View.GONE + subscribeView.visibility = View.VISIBLE + if (subscribeTopicText.requestFocus()) { + val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(subscribeTopicText, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun showLoginView(activity: Activity) { + resetLoginView() + loginProgress.visibility = View.INVISIBLE + toolbar.setTitle(R.string.add_dialog_login_title) + actionMenuItem.setTitle(R.string.add_dialog_button_login) + toolbar.setNavigationOnClickListener { + showSubscribeView() + } + subscribeView.visibility = View.GONE + loginView.visibility = View.VISIBLE + if (loginUsernameText.requestFocus()) { + val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun enableSubscribeView(enable: Boolean) { + subscribeTopicText.isEnabled = enable + subscribeBaseUrlText.isEnabled = enable + subscribeInstantDeliveryCheckbox.isEnabled = enable + subscribeUseAnotherServerCheckbox.isEnabled = enable + actionMenuItem.isEnabled = enable + } + + private fun resetSubscribeView() { + subscribeProgress.visibility = View.GONE + subscribeErrorText.visibility = View.GONE + subscribeErrorTextImage.visibility = View.GONE + enableSubscribeView(true) + } + + private fun enableLoginView(enable: Boolean) { + loginUsernameText.isEnabled = enable + loginPasswordText.isEnabled = enable + actionMenuItem.isEnabled = enable + if (enable && loginUsernameText.requestFocus()) { + val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun resetLoginView() { + loginProgress.visibility = View.GONE + loginErrorText.visibility = View.GONE + loginErrorTextImage.visibility = View.GONE + loginUsernameText.visibility = View.VISIBLE + loginUsernameText.text?.clear() + loginPasswordText.visibility = View.VISIBLE + loginPasswordText.text?.clear() + enableLoginView(true) + } + + companion object { + const val TAG = "NtfyAddFragment" + private val DISALLOWED_TOPICS = listOf("docs", "static", "file") // If updated, also update in server + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/BaseUrl.kt b/android/app/src/main/java/com/lonecloud/sup/ui/BaseUrl.kt new file mode 100644 index 0000000..5cc5e51 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/ui/BaseUrl.kt @@ -0,0 +1,68 @@ +package com.lonecloud.sup.ui + +import android.text.Editable +import android.text.TextWatcher +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import com.google.android.material.textfield.TextInputLayout +import com.lonecloud.sup.R + +fun initBaseUrlDropdown(baseUrls: List, textView: AutoCompleteTextView, layout: TextInputLayout) { + // Base URL dropdown behavior; Oh my, why is this so complicated?! + val context = layout.context + val toggleEndIcon = { + if (textView.text.isNotEmpty()) { + layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_clear) + } else if (baseUrls.isEmpty()) { + layout.setEndIconDrawable(0) + layout.endIconContentDescription = "" + } else { + layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose) + } + } + layout.setEndIconOnClickListener { + if (textView.text.isNotEmpty()) { + textView.text.clear() + if (baseUrls.isEmpty()) { + layout.setEndIconDrawable(0) + layout.endIconContentDescription = "" + } else { + layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose) + } + } else if (textView.text.isEmpty() && baseUrls.isNotEmpty()) { + layout.setEndIconDrawable(R.drawable.ic_drop_up_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose) + textView.showDropDown() + } + } + textView.setOnDismissListener { toggleEndIcon() } + textView.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + toggleEndIcon() + } + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + // Nothing + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // Nothing + } + }) + + val adapter = ArrayAdapter(textView.context, R.layout.fragment_add_dialog_dropdown_item, baseUrls) + textView.threshold = 1 + textView.setAdapter(adapter) + if (baseUrls.count() == 1) { + layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_clear) + textView.setText(baseUrls.first()) + } else if (baseUrls.count() > 1) { + layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose) + } else { + layout.setEndIconDrawable(0) + layout.endIconContentDescription = "" + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/Colors.kt b/android/app/src/main/java/com/lonecloud/sup/ui/Colors.kt new file mode 100644 index 0000000..00ce6c5 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/ui/Colors.kt @@ -0,0 +1,86 @@ +package com.lonecloud.sup.ui + +import android.content.Context +import android.graphics.Color +import androidx.core.content.ContextCompat +import com.google.android.material.color.MaterialColors +import com.lonecloud.sup.R +import com.lonecloud.sup.util.isDarkThemeOn + +class Colors { + companion object { + fun primary(context: Context): Int { + return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN) + } + + fun onPrimary(context: Context): Int { + return MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnPrimary, Color.GREEN) + } + + fun notificationIcon(context: Context): Int { + return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN) + } + + fun linkColor(context: Context): Int { + return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN) + } + + fun itemSelectedBackground(context: Context): Int { + return ContextCompat.getColor(context, R.color.md_theme_surfaceContainerHigh) + } + + fun cardBackgroundColor(context: Context): Int { + return if (isDarkThemeOn(context)) { + MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainer, Color.GRAY) + } else { + MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.WHITE) + } + } + + fun cardSelectedBackgroundColor(context: Context): Int { + return if (isDarkThemeOn(context)) { + MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainerHigh, Color.GRAY) + } else { + MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurfaceContainerHighest, Color.GRAY) + } + } + + fun statusBarNormal(context: Context, dynamicColors: Boolean, darkMode: Boolean): Int { + val default = context.resources.getColor(R.color.action_bar, null) + return if (dynamicColors) { + // Use colorSurface for both light and dark mode when dynamic colors are enabled + MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, default) + } else { + default + } + } + + fun shouldUseLightStatusBar(dynamicColors: Boolean, darkMode: Boolean): Boolean { + // Use light status bar (dark icons) when dynamic colors are enabled in light mode + return dynamicColors && !darkMode + } + + fun toolbarTextColor(context: Context, dynamicColors: Boolean, darkMode: Boolean): Int { + return if (dynamicColors) { + // Use colorOnSurface (dark on light, light on dark) when dynamic colors are enabled + MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnSurface, Color.BLACK) + } else { + if (darkMode) { + // In dark mode, toolbar is gray (surfaceContainer), so use light text + MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnSurface, Color.WHITE) + } else { + // In light mode, toolbar is teal (primary), so use white text + MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnPrimary, Color.WHITE) + } + } + } + + fun dangerText(context: Context): Int { + return ContextCompat.getColor(context, android.R.color.holo_red_dark) + } + + fun swipeToRefreshColor(context: Context): Int { + return MaterialColors.getColor(context, android.R.attr.colorPrimary, Color.GREEN) + } + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/DetailActivity.kt b/android/app/src/main/java/com/lonecloud/sup/ui/DetailActivity.kt new file mode 100644 index 0000000..5ca66e2 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/ui/DetailActivity.kt @@ -0,0 +1,877 @@ +package com.lonecloud.sup.ui + +import android.app.AlertDialog +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import android.net.Uri +import android.os.Bundle +import android.text.Html +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.TextView +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.lonecloud.sup.BuildConfig +import com.lonecloud.sup.R +import com.lonecloud.sup.app.Application +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.db.Repository +import com.lonecloud.sup.db.Subscription +import com.lonecloud.sup.msg.ApiService +import com.lonecloud.sup.msg.NotificationService +import com.lonecloud.sup.util.Log +import com.lonecloud.sup.util.copyToClipboard +import com.lonecloud.sup.util.dangerButton +import com.lonecloud.sup.util.decodeMessage +import com.lonecloud.sup.util.displayName +import com.lonecloud.sup.util.formatDateShort +import com.lonecloud.sup.util.isDarkThemeOn +import com.lonecloud.sup.util.randomSubscriptionId +import com.lonecloud.sup.util.topicShortUrl +import com.lonecloud.sup.util.topicUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.Date +import kotlin.random.Random +import androidx.core.view.size +import androidx.core.view.get +import androidx.core.net.toUri +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.textfield.TextInputEditText +import android.widget.ImageButton + +class DetailActivity : AppCompatActivity() { + private val viewModel by viewModels { + DetailViewModelFactory((application as Application).repository) + } + private val repository by lazy { (application as Application).repository } + private val api by lazy { ApiService(this) } + private var notifier: NotificationService? = null // Context-dependent + private var appBaseUrl: String? = null // Context-dependent + + // Which subscription are we looking at + private var subscriptionId: Long = 0L // Set in onCreate() + private var subscriptionBaseUrl: String = "" // Set in onCreate() + private var subscriptionTopic: String = "" // Set in onCreate() + private var subscriptionDisplayName: String = "" // Set in onCreate() & updated by options menu! + private var subscriptionMutedUntil: Long = 0L // Set in onCreate() & updated by options menu! + + // UI elements + private lateinit var adapter: DetailAdapter + private lateinit var mainList: RecyclerView + private lateinit var mainListContainer: SwipeRefreshLayout + private lateinit var menu: Menu + private lateinit var fab: FloatingActionButton + private lateinit var messageBar: View + private lateinit var messageBarText: TextInputEditText + private lateinit var messageBarPublishButton: FloatingActionButton + private lateinit var messageBarExpandButton: ImageButton + + // Action mode stuff + private var actionMode: ActionMode? = null + private val actionModeCallback = object : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + actionMode = mode + if (mode != null) { + mode.menuInflater.inflate(R.menu.menu_detail_action_mode, menu) + mode.title = "1" // One item selected + } + return true + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.detail_action_mode_copy -> { + onMultiCopyClick() + true + } + R.id.detail_action_mode_delete -> { + onMultiDeleteClick() + true + } + else -> false + } + } + + override fun onDestroyActionMode(mode: ActionMode?) { + endActionModeAndRedraw() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_detail) + + Log.d(TAG, "Create $this") + + // Dependencies that depend on Context + notifier = NotificationService(this) + appBaseUrl = getString(R.string.app_base_url) + + val toolbarLayout = findViewById(R.id.app_bar_drawer) + val dynamicColors = repository.getDynamicColorsEnabled() + val darkMode = isDarkThemeOn(this) + val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode) + val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode) + toolbarLayout.setBackgroundColor(statusBarColor) + + val toolbar = toolbarLayout.findViewById(R.id.toolbar) + toolbar.setTitleTextColor(toolbarTextColor) + toolbar.setNavigationIconTint(toolbarTextColor) + toolbar.overflowIcon?.setTint(toolbarTextColor) + setSupportActionBar(toolbar) + + // Set system status bar appearance + WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = + Colors.shouldUseLightStatusBar(dynamicColors, darkMode) + + // Set detail activity background: use theme background for dynamic colors, static gray for non-dynamic + val detailContentLayout = findViewById(R.id.detail_content_layout) + if (repository.getDynamicColorsEnabled()) { + detailContentLayout.setBackgroundColor( + com.google.android.material.color.MaterialColors.getColor( + this, + android.R.attr.colorBackground, + ContextCompat.getColor(this, R.color.detail_activity_background) + ) + ) + } else { + detailContentLayout.setBackgroundColor( + ContextCompat.getColor(this, R.color.detail_activity_background) + ) + } + + // Show 'Back' button + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463 + val howToLink = findViewById(R.id.detail_how_to_link) + howToLink.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE + + // Handle direct deep links to topic "ntfy://..." + val url = intent?.data + if (intent?.action == ACTION_VIEW && url != null) { + maybeSubscribeAndLoadView(url) + } else { + loadView() + } + } + + private fun maybeSubscribeAndLoadView(url: Uri) { + if (url.pathSegments.size != 1) { + Log.w(TAG, "Invalid link $url. Aborting.") + finish() + return + } + val secure = url.getBooleanQueryParameter("secure", true) // Default to https:// + val displayName = url.getQueryParameter("display") + val baseUrl = extractBaseUrl(url, secure) + val topic = url.pathSegments.first() + + title = topicShortUrl(baseUrl, topic) + + // Subscribe to topic if it doesn't already exist + lifecycleScope.launch(Dispatchers.IO) { + var subscription = repository.getSubscription(baseUrl, topic) + if (subscription == null) { + subscription = Subscription( + id = randomSubscriptionId(), + baseUrl = baseUrl, + topic = topic, + mutedUntil = 0, + minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, + autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, + insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL, + upAppId = null, + upConnectorToken = null, + displayName = displayName, + totalCount = 0, + newCount = 0, + lastActive = Date().time/1000 + ) + repository.addSubscription(subscription) + + + // Fetch cached messages + try { + val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) + notifications.forEach { notification -> repository.addNotification(notification) } + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) + } + + runOnUiThread { + val message = getString(R.string.detail_deep_link_subscribed_toast_message, topicShortUrl(baseUrl, topic)) + Toast.makeText(this@DetailActivity, message, Toast.LENGTH_LONG).show() + } + } + + // Add extras needed in loadView(); normally these are added in MainActivity + intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id) + intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) + intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) + intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription)) + intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil) + + runOnUiThread { + loadView() + } + } + } + + fun extractBaseUrl(url: Uri, secure: Boolean): String { + if (secure) { + return if (url.port != 443 && url.port != -1) "https://${url.host}:${url.port}" else "https://${url.host}" + } + return if (url.port != 80 && url.port != -1) "http://${url.host}:${url.port}" else "http://${url.host}" + } + + private fun loadView() { + // Get extras required for the return to the main activity + subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0) + subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return + subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return + subscriptionDisplayName = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME) ?: return + subscriptionMutedUntil = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, 0L) + + // Set title + val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return + val topicUrl = topicShortUrl(subscriptionBaseUrl, subscriptionTopic) + title = subscriptionDisplayName + + // Set "how to instructions" + val howToExample: TextView = findViewById(R.id.detail_how_to_example) + howToExample.linksClickable = true + + val howToText = getString(R.string.detail_how_to_example, topicUrl) + howToExample.text = Html.fromHtml(howToText, Html.FROM_HTML_MODE_LEGACY) + + // Swipe to refresh + mainListContainer = findViewById(R.id.detail_notification_list_container) + mainListContainer.setOnRefreshListener { refresh() } + mainListContainer.setColorSchemeColors(Colors.swipeToRefreshColor(this)) + + // Update main list based on viewModel (& its datasource/livedata) + val noEntriesText: View = findViewById(R.id.detail_no_notifications) + val onNotificationClick = { n: Notification -> onNotificationClick(n) } + val onNotificationLongClick = { n: Notification -> onNotificationLongClick(n) } + + adapter = DetailAdapter(this, lifecycleScope, repository, onNotificationClick, onNotificationLongClick) + mainList = findViewById(R.id.detail_notification_list) + mainList.adapter = adapter + + // Apply window insets to ensure content is not covered by navigation bar + mainList.clipToPadding = false + ViewCompat.setOnApplyWindowInsetsListener(mainList) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + + viewModel.list(subscriptionId).observe(this) { + it?.let { + // Show list view + adapter.submitList(it as MutableList) + if (it.isEmpty()) { + mainListContainer.visibility = View.GONE + noEntriesText.visibility = View.VISIBLE + } else { + mainListContainer.visibility = View.VISIBLE + noEntriesText.visibility = View.GONE + } + + // Cancel notifications that still have popups + maybeCancelNotificationPopups(it) + } + } + + // Swipe to remove + val itemTouchCallback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + return false + } + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { + val notification = adapter.get(viewHolder.absoluteAdapterPosition) + lifecycleScope.launch(Dispatchers.IO) { + repository.markAsDeleted(notification.id) + } + val snackbar = Snackbar.make(mainList, R.string.detail_item_snack_deleted, Snackbar.LENGTH_SHORT) + snackbar.setAction(R.string.detail_item_snack_undo) { + lifecycleScope.launch(Dispatchers.IO) { + repository.undeleteNotification(notification.id) + } + } + snackbar.show() + } + } + val itemTouchHelper = ItemTouchHelper(itemTouchCallback) + itemTouchHelper.attachToRecyclerView(mainList) + + // Scroll up when new notification is added + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0) { + Log.d(TAG, "$itemCount item(s) inserted at 0, scrolling to the top") + mainList.scrollToPosition(positionStart) + } + } + }) + + // React to changes in fast delivery setting + repository.getSubscriptionIdsWithInstantStatusLiveData().observe(this) { + // Signal pushes to us, no service to refresh + } + + // Observe connection details and update menu item visibility + repository.getConnectionDetailsLiveData().observe(this) { details -> + showHideConnectionErrorMenuItem(details) + } + + // Mark this subscription as "open" so we don't receive notifications for it + repository.detailViewSubscriptionId.set(subscriptionId) + + // Stop insistent playback (if running, otherwise it'll throw) + try { + repository.mediaPlayer.stop() + } catch (_: Exception) { + // Ignore errors + } + + // Setup FAB and message bar + setupPublishUI() + } + + private fun setupPublishUI() { + fab = findViewById(R.id.detail_fab) + messageBar = findViewById(R.id.detail_message_bar) + messageBarText = messageBar.findViewById(R.id.message_bar_text) + messageBarPublishButton = messageBar.findViewById(R.id.message_bar_publish_button) + messageBarExpandButton = messageBar.findViewById(R.id.message_bar_expand_button) + + // Message bar enabled: Show message bar, hide FAB + if (repository.getMessageBarEnabled()) { + fab.visibility = View.GONE + messageBar.visibility = View.VISIBLE + + // Send button click + messageBarPublishButton.setOnClickListener { + publishMessage(messageBarText.text.toString()) // Allow publishing empty messages + } + + // Expand button click opens the full dialog + messageBarExpandButton.setOnClickListener { + openPublishDialog(messageBarText.text.toString()) + } + + // Handle window insets for navigation bar and keyboard + val contentLayout = findViewById(R.id.detail_content_layout) + ViewCompat.setOnApplyWindowInsetsListener(contentLayout) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + // Use the larger of navigation bar or keyboard height + val bottomPadding = maxOf(systemBars.bottom, ime.bottom) + view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding) + insets + } + } else { + // Show FAB, hide message bar + fab.visibility = View.VISIBLE + messageBar.visibility = View.GONE + + fab.setOnClickListener { + openPublishDialog("") + } + + // Add bottom padding to FAB to account for navigation bar + ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val layoutParams = view.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams + layoutParams.bottomMargin = systemBars.bottom + resources.getDimensionPixelSize(R.dimen.fab_margin) + view.layoutParams = layoutParams + insets + } + } + } + + private fun openPublishDialog(initialMessage: String) { + // Publishing dialog removed - feature not implemented + Log.d(TAG, "Publishing dialog not available") + } + + private fun publishMessage(message: String) { + // Disable send button while publishing + messageBarPublishButton.isEnabled = false + + lifecycleScope.launch(Dispatchers.IO) { + try { + api.publish( + baseUrl = subscriptionBaseUrl, + topic = subscriptionTopic, + message = message, + title = "", + priority = 3, // Default priority + tags = emptyList(), + delay = "" + ) + runOnUiThread { + messageBarText.text?.clear() + messageBarPublishButton.isEnabled = true + } + } catch (e: Exception) { + Log.w(TAG, "Failed to publish message", e) + runOnUiThread { + messageBarPublishButton.isEnabled = true + val errorMessage = when (e) { + is ApiService.UnauthorizedException -> { + getString(R.string.detail_test_message_error_unauthorized_anon) + } + is ApiService.EntityTooLargeException -> { + getString(R.string.detail_test_message_error_too_large) + } + is ApiService.ApiException -> { + getString(R.string.publish_dialog_error_server, e.error, e.code) + } + else -> { + getString(R.string.publish_dialog_error_sending, e.message) + } + } + Toast.makeText(this@DetailActivity, errorMessage, Toast.LENGTH_LONG).show() + } + } + } + } + + override fun onResume() { + super.onResume() + + // Mark as "open" so we don't send notifications while this is open + repository.detailViewSubscriptionId.set(subscriptionId) + + // Update buttons (this is for when we return from the preferences screen) + lifecycleScope.launch(Dispatchers.IO) { + val subscription = repository.getSubscription(subscriptionId) ?: return@launch + subscriptionMutedUntil = subscription.mutedUntil + subscriptionDisplayName = displayName(appBaseUrl, subscription) + + showHideMutedUntilMenuItems(subscriptionMutedUntil) + showHideCopyMenuItems(subscription.baseUrl) + showHideConnectionErrorMenuItem(repository.getConnectionDetails()) + updateTitle(subscriptionDisplayName) + } + } + + override fun onPause() { + super.onPause() + Log.d(TAG, "onPause hook: Removing 'notificationId' from all notifications for $subscriptionId") + GlobalScope.launch(Dispatchers.IO) { + // Note: This is here and not in onDestroy/onStop, because we want to clear notifications as early + // as possible, so that we don't see the "new" bubble in the main list anymore. + repository.clearAllNotificationIds(subscriptionId) + } + Log.d(TAG, "onPause hook: Marking subscription $subscriptionId as 'not open'") + repository.detailViewSubscriptionId.set(0) // Mark as closed + } + + private fun maybeCancelNotificationPopups(notifications: List) { + val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 } + if (notificationsWithPopups.isNotEmpty()) { + lifecycleScope.launch(Dispatchers.IO) { + notificationsWithPopups.forEach { notification -> + notifier?.cancel(notification.notificationId) + // Do NOT remove the notificationId here, we need that for the UI indicators; we'll remove it in onPause() + } + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_detail_action_bar, menu) + this.menu = menu + + // Tint menu icons based on theme + val toolbarTextColor = Colors.toolbarTextColor(this, repository.getDynamicColorsEnabled(), isDarkThemeOn(this)) + for (i in 0 until menu.size) { + menu[i].icon?.setTint(toolbarTextColor) + } + + // Show and hide buttons + showHideMutedUntilMenuItems(subscriptionMutedUntil) + showHideCopyMenuItems(subscriptionBaseUrl) + showHideConnectionErrorMenuItem(repository.getConnectionDetails()) + + // Regularly check if "notification muted" time has passed + // NOTE: This is done here, because then we know that we've initialized the menu items. + startNotificationMutedChecker() + + return true + } + + private fun startNotificationMutedChecker() { + // FIXME This is awful and has to go. + + lifecycleScope.launch(Dispatchers.IO) { + delay(1000) // Just to be sure we've initialized all the things, we wait a bit ... + while (isActive) { + Log.d(TAG, "Checking 'muted until' timestamp for subscription $subscriptionId") + val subscription = repository.getSubscription(subscriptionId) ?: return@launch + val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil + if (mutedUntilExpired) { + val newSubscription = subscription.copy(mutedUntil = 0L) + repository.updateSubscription(newSubscription) + showHideMutedUntilMenuItems(0L) + } + delay(60_000) + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.detail_menu_test -> { + onTestClick() + true + } + R.id.detail_menu_notifications_enabled -> { + onMutedUntilClick(enable = false) + true + } + R.id.detail_menu_notifications_disabled_until -> { + onMutedUntilClick(enable = true) + true + } + R.id.detail_menu_notifications_disabled_forever -> { + onMutedUntilClick(enable = true) + true + } + R.id.detail_menu_connection_error -> { + onConnectionErrorClick() + true + } + R.id.detail_menu_copy_url -> { + onCopyUrlClick() + true + } + R.id.detail_menu_clear -> { + onClearClick() + true + } + R.id.detail_menu_settings -> { + onSettingsClick() + true + } + R.id.detail_menu_unsubscribe -> { + onDeleteClick() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun onTestClick() { + Log.d(TAG, "Sending test notification to ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") + + lifecycleScope.launch(Dispatchers.IO) { + try { + val possibleTags = listOf( + "warning", "skull", "success", "triangular_flag_on_post", "de", "dog", "rotating_light", "cat", "bike", // Emojis + "backup", "rsync", "de-server1", "this-is-a-tag" + ) + val priority = Random.nextInt(1, 6) + val tags = possibleTags.shuffled().take(Random.nextInt(0, 4)) + val title = if (Random.nextBoolean()) getString(R.string.detail_test_title) else "" + val message = getString(R.string.detail_test_message, priority) + api.publish(subscriptionBaseUrl, subscriptionTopic, message, title, priority, tags, delay = "") + } catch (e: Exception) { + runOnUiThread { + val message = if (e is ApiService.UnauthorizedException) { + getString(R.string.detail_test_message_error_unauthorized_anon) + } else { + getString(R.string.detail_test_message_error, e.message) + } + Toast + .makeText(this@DetailActivity, message, Toast.LENGTH_LONG) + .show() + } + } + } + } + + private fun onMutedUntilClick(enable: Boolean) { + if (!enable) { + Log.d(TAG, "Notification settings dialog not available") + } else { + Log.d(TAG, "Re-enabling notifications ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") + onNotificationMutedUntilChanged(Repository.MUTED_UNTIL_SHOW_ALL) + } + } + + private fun onConnectionErrorClick() { + Log.d(TAG, "Connection error dialog not available") + } + + fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) { + lifecycleScope.launch(Dispatchers.IO) { + Log.d(TAG, "Setting subscription 'muted until' to $mutedUntilTimestamp") + val subscription = repository.getSubscription(subscriptionId) + val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp) + newSubscription?.let { repository.updateSubscription(newSubscription) } + subscriptionMutedUntil = mutedUntilTimestamp + showHideMutedUntilMenuItems(mutedUntilTimestamp) + runOnUiThread { + when (mutedUntilTimestamp) { + 0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show() + 1L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show() + else -> { + val formattedDate = formatDateShort(mutedUntilTimestamp) + Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show() + } + } + } + } + } + + private fun onCopyUrlClick() { + val url = topicUrl(subscriptionBaseUrl, subscriptionTopic) + Log.d(TAG, "Copying topic URL $url to clipboard ") + + runOnUiThread { + copyToClipboard(this, "topic address", url) + } + } + + private fun refresh() { + Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") + + lifecycleScope.launch(Dispatchers.IO) { + try { + val subscription = repository.getSubscription(subscriptionId) ?: return@launch + val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, null) + val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications) + val toastMessage = if (newNotifications.isEmpty()) { + getString(R.string.refresh_message_no_results) + } else { + getString(R.string.refresh_message_result, newNotifications.size) + } + newNotifications.forEach { notification -> repository.addNotification(notification) } + runOnUiThread { + Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() + mainListContainer.isRefreshing = false + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}: ${e.stackTrace}", e) + runOnUiThread { + Toast + .makeText(this@DetailActivity, getString(R.string.refresh_message_error_one, e.message), Toast.LENGTH_LONG) + .show() + mainListContainer.isRefreshing = false + } + } + } + } + + private fun showHideMutedUntilMenuItems(mutedUntilTimestamp: Long) { + if (!this::menu.isInitialized) { + return + } + subscriptionMutedUntil = mutedUntilTimestamp + runOnUiThread { + val notificationsEnabledItem = menu.findItem(R.id.detail_menu_notifications_enabled) + val notificationsDisabledUntilItem = menu.findItem(R.id.detail_menu_notifications_disabled_until) + val notificationsDisabledForeverItem = menu.findItem(R.id.detail_menu_notifications_disabled_forever) + notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L + notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L + notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L + if (subscriptionMutedUntil > 1L) { + val formattedDate = formatDateShort(subscriptionMutedUntil) + notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate) + } + } + } + + + private fun showHideCopyMenuItems(subscriptionBaseUrl: String) { + if (!this::menu.isInitialized) { + return + } + runOnUiThread { + // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463 + val copyUrlItem = menu.findItem(R.id.detail_menu_copy_url) + copyUrlItem?.isVisible = appBaseUrl != subscriptionBaseUrl || BuildConfig.PAYMENT_LINKS_AVAILABLE + } + } + + private fun showHideConnectionErrorMenuItem(details: Map) { + if (!this::menu.isInitialized) { + return + } + runOnUiThread { + val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error) + // Only show if there's an error for this subscription's base URL + val hasError = details[subscriptionBaseUrl]?.hasError() == true + connectionErrorItem?.isVisible = hasError + } + } + + private fun updateTitle(subscriptionDisplayName: String) { + runOnUiThread { + title = subscriptionDisplayName + } + } + + private fun onClearClick() { + Log.d(TAG, "Clearing all notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") + + val dialog = MaterialAlertDialogBuilder(this) + .setMessage(R.string.detail_clear_dialog_message) + .setPositiveButton(R.string.detail_clear_dialog_permanently_delete) { _, _ -> + lifecycleScope.launch(Dispatchers.IO) { + repository.markAllAsDeleted(subscriptionId) + } + } + .setNegativeButton(R.string.detail_clear_dialog_cancel) { _, _ -> /* Do nothing */ } + .create() + dialog.setOnShowListener { + dialog + .getButton(AlertDialog.BUTTON_POSITIVE) + .dangerButton() + } + dialog.show() + } + + private fun onSettingsClick() { + Log.d(TAG, "Settings not available") + } + + private fun onDeleteClick() { + Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") + + val dialog = MaterialAlertDialogBuilder(this) + .setMessage(R.string.detail_delete_dialog_message) + .setPositiveButton(R.string.detail_delete_dialog_permanently_delete) { _, _ -> + Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)") + GlobalScope.launch(Dispatchers.IO) { + repository.removeAllNotifications(subscriptionId) + repository.removeSubscription(subscriptionId) + // Signal pushes to us, no Firebase to unsubscribe from + } + finish() + } + .setNegativeButton(R.string.detail_delete_dialog_cancel) { _, _ -> /* Do nothing */ } + .create() + dialog.setOnShowListener { + dialog + .getButton(AlertDialog.BUTTON_POSITIVE) + .dangerButton() + } + dialog.show() + } + + private fun onNotificationClick(notification: Notification) { + if (actionMode != null) { + handleActionModeClick(notification) + } else { + runOnUiThread { + copyToClipboard(this, "notification", decodeMessage(notification)) + } + } + } + + private fun onNotificationLongClick(notification: Notification) { + if (actionMode == null) { + beginActionMode(notification) + } + } + + private fun handleActionModeClick(notification: Notification) { + adapter.toggleSelection(notification.id) + if (adapter.selected.size == 0) { + finishActionMode() + } else { + actionMode!!.title = adapter.selected.size.toString() + } + } + + private fun onMultiCopyClick() { + Log.d(TAG, "Copying multiple notifications to clipboard") + + lifecycleScope.launch(Dispatchers.IO) { + val content = adapter.selected.joinToString("\n\n") { notificationId -> + val notification = repository.getNotification(notificationId) + notification?.let { + decodeMessage(it) + "\n" + Date(it.timestamp * 1000).toString() + }.orEmpty() + } + runOnUiThread { + copyToClipboard(this@DetailActivity, "notifications", content) + finishActionMode() + } + } + } + + private fun onMultiDeleteClick() { + Log.d(TAG, "Showing multi-delete dialog for selected items") + + val dialog = MaterialAlertDialogBuilder(this) + .setMessage(R.string.detail_action_mode_delete_dialog_message) + .setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ -> + adapter.selected.map { notificationId -> viewModel.markAsDeleted(notificationId) } + finishActionMode() + } + .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> + finishActionMode() + } + .create() + dialog.setOnShowListener { + dialog + .getButton(AlertDialog.BUTTON_POSITIVE) + .dangerButton() + } + dialog.show() + } + + private fun beginActionMode(notification: Notification) { + actionMode = startSupportActionMode(actionModeCallback) + adapter.toggleSelection(notification.id) + } + + private fun finishActionMode() { + actionMode?.finish() + endActionModeAndRedraw() + } + + private fun endActionModeAndRedraw() { + actionMode = null + adapter.selected.clear() + adapter.notifyItemRangeChanged(0, adapter.currentList.size) + } + + companion object { + const val TAG = "NtfyDetailActivity" + const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" + const val EXTRA_SUBSCRIPTION_BASE_URL = "baseUrl" + const val EXTRA_SUBSCRIPTION_TOPIC = "topic" + const val EXTRA_SUBSCRIPTION_DISPLAY_NAME = "displayName" + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/DetailAdapter.kt b/android/app/src/main/java/com/lonecloud/sup/ui/DetailAdapter.kt new file mode 100644 index 0000000..0106db4 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/ui/DetailAdapter.kt @@ -0,0 +1,245 @@ +package com.lonecloud.sup.ui + +import android.Manifest +import android.app.Activity +import android.content.* +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.text.util.Linkify +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.cardview.widget.CardView +import androidx.constraintlayout.helper.widget.Flow +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +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 com.lonecloud.sup.R +import com.lonecloud.sup.db.* +import com.lonecloud.sup.msg.NotificationService +import com.lonecloud.sup.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 +import androidx.core.net.toUri + +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, markwon, view, selected, onClick, onLongClick) + } + + /* Gets current topic and uses it to bind view. */ + override fun onBindViewHolder(holder: DetailViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + fun get(position: Int): Notification { + return getItem(position) + } + + fun toggleSelection(notificationId: String) { + if (selected.contains(notificationId)) { + selected.remove(notificationId) + } else { + selected.add(notificationId) + } + + if (selected.isNotEmpty()) { + val listIds = currentList.map { notification -> notification.id } + val notificationPosition = listIds.indexOf(notificationId) + notifyItemChanged(notificationPosition) + } + } + + /* 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, + 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) + private val cardView: CardView = itemView.findViewById(R.id.detail_item_card) + private val priorityImageView: ImageView = itemView.findViewById(R.id.detail_item_priority_image) + private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text) + private val titleView: TextView = itemView.findViewById(R.id.detail_item_title_text) + private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text) + private val newDotImageView: View = itemView.findViewById(R.id.detail_item_new_dot) + private val tagsView: TextView = itemView.findViewById(R.id.detail_item_tags_text) + private val menuButton: ImageButton = itemView.findViewById(R.id.detail_item_menu_button) + private val actionsWrapperView: ConstraintLayout = itemView.findViewById(R.id.detail_item_actions_wrapper) + private val actionsFlow: Flow = itemView.findViewById(R.id.detail_item_actions_flow) + + fun bind(notification: Notification) { + this.notification = notification + + val context = itemView.context + val unmatchedTags = unmatchedTags(splitTags(notification.tags)) + val message = formatMessage(notification) + + dateView.text = formatDateShort(notification.timestamp) + messageView.autoLinkMask = Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS + 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. + // It's weird because "layout" is the ripple-able, but the card is clickable. + // See https://github.com/binwiederhier/ntfy/issues/226 + layout.ripple(lifecycleScope) + onClick(notification) + } + messageView.setOnLongClickListener { + onLongClick(notification); true + } + newDotImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE + cardView.setOnClickListener { onClick(notification) } + cardView.setOnLongClickListener { onLongClick(notification); true } + if (notification.title != "") { + titleView.visibility = View.VISIBLE + titleView.text = formatTitle(notification) + } else { + titleView.visibility = View.GONE + } + if (unmatchedTags.isNotEmpty()) { + tagsView.visibility = View.VISIBLE + tagsView.text = context.getString(R.string.detail_item_tags, unmatchedTags.joinToString(", ")) + } else { + tagsView.visibility = View.GONE + } + if (selected.contains(notification.id)) { + cardView.setCardBackgroundColor(Colors.cardSelectedBackgroundColor(context)) + } else { + cardView.setCardBackgroundColor(Colors.cardBackgroundColor(context)) + } + renderPriority(context, notification) + resetCardButtons() + maybeRenderMenu(context, notification) + } + + private fun renderPriority(context: Context, notification: Notification) { + when (notification.priority) { + PRIORITY_MIN -> { + priorityImageView.visibility = View.VISIBLE + priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_1_24dp)) + } + PRIORITY_LOW -> { + priorityImageView.visibility = View.VISIBLE + priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_2_24dp)) + } + PRIORITY_DEFAULT -> { + priorityImageView.visibility = View.GONE + } + PRIORITY_HIGH -> { + priorityImageView.visibility = View.VISIBLE + priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_4_24dp)) + } + PRIORITY_MAX -> { + priorityImageView.visibility = View.VISIBLE + priorityImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_priority_5_24dp)) + } + } + } + + private fun maybeRenderMenu(context: Context, notification: Notification) { + val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification) // Heavy lifting not during on-click + if (menuButtonPopupMenu != null) { + menuButton.setOnClickListener { menuButtonPopupMenu.show() } + menuButton.visibility = View.VISIBLE + } else { + menuButton.visibility = View.GONE + } + } + + private fun resetCardButtons() { + // clear any previously created dynamic buttons + actionsFlow.allViews.forEach { actionsFlow.removeView(it) } + actionsWrapperView.removeAllViews() + actionsWrapperView.addView(actionsFlow) + } + + private fun addButtonToCard(button: View) { + actionsWrapperView.addView(button) + actionsFlow.addView(button) + } + + private fun createCardButton(context: Context, label: String, onClick: () -> Boolean): View { + // See https://stackoverflow.com/a/41139179/1440785 + val button = LayoutInflater.from(context).inflate(R.layout.button_action, null) as MaterialButton + button.id = View.generateViewId() + button.text = label + button.setOnClickListener { onClick() } + return button + } + + private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification): PopupMenu? { + val popup = PopupMenu(context, anchor) + popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu) + val downloadItem = popup.menu.findItem(R.id.detail_item_menu_download) + val cancelItem = popup.menu.findItem(R.id.detail_item_menu_cancel) + val openItem = popup.menu.findItem(R.id.detail_item_menu_open) + val deleteItem = popup.menu.findItem(R.id.detail_item_menu_delete) + val saveFileItem = popup.menu.findItem(R.id.detail_item_menu_save_file) + val copyUrlItem = popup.menu.findItem(R.id.detail_item_menu_copy_url) + val copyContentsItem = popup.menu.findItem(R.id.detail_item_menu_copy_contents) + + copyContentsItem.setOnMenuItemClickListener { + copyToClipboard(context, "notification", decodeMessage(notification)); true + } + + openItem.isVisible = false + downloadItem.isVisible = false + deleteItem.isVisible = false + saveFileItem.isVisible = false + copyUrlItem.isVisible = false + cancelItem.isVisible = false + copyContentsItem.isVisible = true + + return popup + } + } + + object TopicDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean { + return oldItem == newItem + } + } + + companion object { + const val TAG = "NtfyDetailAdapter" + const val REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD = 9876 + const val IMAGE_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 // Too large images crash the app with "Canvas: trying to draw too large(233280000bytes) bitmap." + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/DetailViewModel.kt b/android/app/src/main/java/com/lonecloud/sup/ui/DetailViewModel.kt new file mode 100644 index 0000000..44754b6 --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/ui/DetailViewModel.kt @@ -0,0 +1,31 @@ +package com.lonecloud.sup.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.lonecloud.sup.db.Notification +import com.lonecloud.sup.db.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class DetailViewModel(private val repository: Repository) : ViewModel() { + fun list(subscriptionId: Long): LiveData> { + return repository.getNotificationsLiveData(subscriptionId) + } + + fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) { + repository.markAsDeleted(notificationId) + } +} + +class DetailViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + with(modelClass){ + when { + isAssignableFrom(DetailViewModel::class.java) -> DetailViewModel(repository) as T + else -> throw IllegalArgumentException("Unknown viewModel class $modelClass") + } + } +} diff --git a/android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt b/android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt new file mode 100644 index 0000000..ac26f7d --- /dev/null +++ b/android/app/src/main/java/com/lonecloud/sup/ui/MainActivity.kt @@ -0,0 +1,738 @@ +package com.lonecloud.sup.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 +import android.content.pm.PackageManager +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.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.view.ActionMode +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.text.HtmlCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.updatePadding +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.lonecloud.sup.BuildConfig +import com.lonecloud.sup.R +import com.lonecloud.sup.app.Application +import com.lonecloud.sup.db.Repository +import com.lonecloud.sup.db.Subscription +import com.lonecloud.sup.msg.ApiService +import com.lonecloud.sup.msg.NotificationDispatcher +import com.lonecloud.sup.util.Log +import com.lonecloud.sup.util.dangerButton +import com.lonecloud.sup.util.displayName +import com.lonecloud.sup.util.formatDateShort +import com.lonecloud.sup.util.isDarkThemeOn +import com.lonecloud.sup.util.isIgnoringBatteryOptimizations +import com.lonecloud.sup.util.maybeSplitTopicUrl +import com.lonecloud.sup.util.randomSubscriptionId +import com.lonecloud.sup.util.shortUrl +import com.lonecloud.sup.util.topicShortUrl +import com.lonecloud.sup.work.DeleteWorker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.isActive +import java.util.Date +import java.util.concurrent.TimeUnit +import kotlin.random.Random +import androidx.core.view.size +import androidx.core.view.get +import androidx.core.net.toUri + +class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener { + private val viewModel by viewModels { + SubscriptionsViewModelFactory((application as Application).repository) + } + private val repository by lazy { (application as Application).repository } + private val api by lazy { ApiService(this) } + + // UI elements + private lateinit var menu: Menu + private lateinit var mainList: RecyclerView + private lateinit var mainListContainer: SwipeRefreshLayout + private lateinit var adapter: MainAdapter + private lateinit var fab: FloatingActionButton + + // Other stuff + private var workManager: WorkManager? = null // Context-dependent + private var dispatcher: NotificationDispatcher? = null // Context-dependent + private var appBaseUrl: String? = null // Context-dependent + + // Action mode stuff + private var actionMode: ActionMode? = null + private val actionModeCallback = object : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + actionMode = mode + if (mode != null) { + mode.menuInflater.inflate(R.menu.menu_main_action_mode, menu) + mode.title = "1" // One item selected + } + return true + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.main_action_mode_delete -> { + onMultiDeleteClick() + true + } + else -> false + } + } + + override fun onDestroyActionMode(mode: ActionMode?) { + endActionModeAndRedraw() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + Log.init(this) // Init logs in all entry points + Log.d(TAG, "Create $this") + + // Dependencies that depend on Context + workManager = WorkManager.getInstance(this) + dispatcher = NotificationDispatcher(this, repository) + appBaseUrl = getString(R.string.app_base_url) + + // Action bar + val toolbarLayout = findViewById(R.id.app_bar_drawer) + val dynamicColors = repository.getDynamicColorsEnabled() + val darkMode = isDarkThemeOn(this) + val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode) + val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode) + toolbarLayout.setBackgroundColor(statusBarColor) + + val toolbar = toolbarLayout.findViewById(R.id.toolbar) + toolbar.setTitleTextColor(toolbarTextColor) + toolbar.setNavigationIconTint(toolbarTextColor) + toolbar.overflowIcon?.setTint(toolbarTextColor) + setSupportActionBar(toolbar) + title = getString(R.string.main_action_bar_title) + + // Set system status bar appearance + WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = + Colors.shouldUseLightStatusBar(dynamicColors, darkMode) + + // Floating action button ("+") + fab = findViewById(R.id.fab) + fab.setOnClickListener { + onSubscribeButtonClick() + } + + // Add bottom padding to FAB to account for navigation bar + ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val layoutParams = view.layoutParams as androidx.constraintlayout.widget.ConstraintLayout.LayoutParams + layoutParams.bottomMargin = systemBars.bottom + view.layoutParams = layoutParams + insets + } + + // Swipe to refresh + mainListContainer = findViewById(R.id.main_subscriptions_list_container) + mainListContainer.setOnRefreshListener { refreshAllSubscriptions() } + mainListContainer.setColorSchemeColors(Colors.swipeToRefreshColor(this)) + + // Update main list based on viewModel (& its datasource/livedata) + val noEntries: View = findViewById(R.id.main_no_subscriptions) + val onSubscriptionClick = { s: Subscription -> onSubscriptionItemClick(s) } + val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) } + + mainList = findViewById(R.id.main_subscriptions_list) + adapter = MainAdapter( + repository, + onSubscriptionClick, + onSubscriptionLongClick, + ResourcesCompat.getDrawable(resources, R.drawable.ic_circle, theme)!!.apply { + setTint(Colors.primary(this@MainActivity)) + }, + Colors.onPrimary(this) + ) + mainList.adapter = adapter + + // Apply window insets to ensure content is not covered by navigation bar + mainList.clipToPadding = false + ViewCompat.setOnApplyWindowInsetsListener(mainList) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + + viewModel.list().observe(this) { + it?.let { subscriptions -> + // Update main list + adapter.submitList(subscriptions as MutableList) + if (it.isEmpty()) { + mainListContainer.visibility = View.GONE + noEntries.visibility = View.VISIBLE + } else { + mainListContainer.visibility = View.VISIBLE + noEntries.visibility = View.GONE + } + + // Add scrub terms to log (in case it gets exported) + subscriptions.forEach { s -> + Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain) + Log.addScrubTerm(s.topic) + } + + // Update battery banner + showHideBatteryBanner(subscriptions) + } + } + + + // Scrub terms for last topics // FIXME this should be in Log.getFormatted + repository.getLastShareTopics().forEach { topicUrl -> + maybeSplitTopicUrl(topicUrl)?.let { + Log.addScrubTerm(shortUrl(it.first), Log.TermType.Domain) + Log.addScrubTerm(shortUrl(it.second), Log.TermType.Term) + } + } + + // React to changes in instant delivery setting + viewModel.listIdsWithInstantStatus().observe(this) { + // Signal pushes to us, no service to refresh + } + + // Observe connection details and update menu item visibility + repository.getConnectionDetailsLiveData().observe(this) { details -> + showHideConnectionErrorMenuItem(details) + } + + // Battery banner + val batteryBanner = findViewById(R.id.main_banner_battery) // Banner visibility is toggled in onResume() + val dontAskAgainButton = findViewById