Compare commits
1 commit
main
...
work-manag
Author | SHA1 | Date | |
---|---|---|---|
|
bd6d6344f5 |
473 changed files with 1055 additions and 39014 deletions
20
.gitignore
vendored
20
.gitignore
vendored
|
@ -1,17 +1,3 @@
|
|||
# Google services (Firebase/FCM) config and keys
|
||||
google-services.json
|
||||
|
||||
# Signing keystore
|
||||
.keystores/
|
||||
.signing/
|
||||
|
||||
# Playground
|
||||
playground/
|
||||
|
||||
# Release
|
||||
releases/
|
||||
app/release/
|
||||
|
||||
# built application files
|
||||
*.apk
|
||||
*.ap_
|
||||
|
@ -31,13 +17,7 @@ gen/
|
|||
|
||||
# Ignore gradle files
|
||||
.gradle/
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
app/fdroid/release/
|
||||
app/fdroid/debug/
|
||||
app/play/release/
|
||||
app/play/debug/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest"
|
||||
|
||||
stages:
|
||||
- build
|
||||
|
||||
before_script:
|
||||
- export GRADLE_USER_HOME=$(pwd)/.gradle
|
||||
- chmod +x ./gradlew
|
||||
|
||||
cache:
|
||||
key: ${CI_PROJECT_ID}
|
||||
paths:
|
||||
- .gradle/
|
||||
|
||||
buildRelease:
|
||||
stage: build
|
||||
script:
|
||||
- ./gradlew assembleFdroid
|
||||
artifacts:
|
||||
paths:
|
||||
- app/build/outputs/apk/fdroid/release
|
25
README.md
25
README.md
|
@ -1,26 +1,15 @@
|
|||
# ntfy Android App
|
||||
This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)). You can find the app in [F-Droid](https://f-droid.org/packages/io.heckel.ntfy/) or the [Play Store](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
||||
or as .apk files on the [releases page](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)).
|
||||
**It is very much work in progress. Also: I'm new to Android development, so I'm still learning.**
|
||||
|
||||
## Build
|
||||
For up-to-date building instructions, please see the [official docs](https://docs.ntfy.sh/develop/#android-app).
|
||||
|
||||
## Translations
|
||||
We're using [Weblate](https://hosted.weblate.org/projects/ntfy/) to translate the ntfy Android app. We'd love your participation.
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/ntfy/">
|
||||
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
||||
</a>
|
||||
## ...
|
||||
...
|
||||
|
||||
## License
|
||||
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
Thank you to these fantastic resources:
|
||||
This app is heavily based on:
|
||||
* [RecyclerViewKotlin](https://github.com/android/views-widgets-samples/tree/main/RecyclerViewKotlin) (Apache 2.0)
|
||||
* [Just another Hacker News Android client](https://github.com/manoamaro/another-hacker-news-client) (MIT)
|
||||
* [Android Room with a View](https://github.com/googlecodelabs/android-room-with-a-view/tree/kotlin) (Apache 2.0)
|
||||
* [Firebase Messaging Example](https://github.com/firebase/quickstart-android/blob/7147f60451b3eeaaa05fc31208ffb67e2df73c3c/messaging/app/src/main/java/com/google/firebase/quickstart/fcm/kotlin/MyFirebaseMessagingService.kt) (Apache 2.0)
|
||||
* [Designing a logo with Inkscape](https://www.youtube.com/watch?v=r2Kv61cd2P4)
|
||||
* [Foreground service](https://robertohuertas.com/2019/06/29/android_foreground_services/)
|
||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) for as data source for an up-to-date [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file
|
||||
* [emoji-java](https://github.com/vdurmont/emoji-java) (MIT) has been stripped and inlined to use the emoji.json file
|
||||
|
||||
Thanks to these projects for allowing me to copy-paste a lot.
|
||||
|
|
23
TESTING.md
23
TESTING.md
|
@ -1,23 +0,0 @@
|
|||
# Testing
|
||||
|
||||
## Manual testing steps
|
||||
|
||||
* Upgrade from old version
|
||||
* Subscribe to topic
|
||||
* With instant delivery
|
||||
* With other server
|
||||
* Deep link subscribe
|
||||
* Sending messages
|
||||
* Tags/emojis
|
||||
* Titles
|
||||
* Priority, in particular that high/max priority messages vibrate
|
||||
* Main view
|
||||
* Multi-delete
|
||||
* Toggle global mute
|
||||
* Detail view
|
||||
* Toggle per-topic mute
|
||||
* Toggle instant delivery
|
||||
* Send message while in detail view (should show notification)
|
||||
* Check if notifications get canceled when sending message
|
||||
* Clear notifications
|
||||
* Send test notification
|
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
137
app/build.gradle
137
app/build.gradle
|
@ -1,59 +1,39 @@
|
|||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
/*
|
||||
* Copyright (C) 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
android {
|
||||
compileSdkVersion 33
|
||||
compileSdkVersion 30
|
||||
|
||||
defaultConfig {
|
||||
applicationId "foundation.e.ntfy"
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 33
|
||||
|
||||
versionCode 33
|
||||
versionName "1.17.0"
|
||||
applicationId "io.heckel.ntfy"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
/* Required for Room schema migrations */
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments += ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
debuggable false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
debuggable true
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "store"
|
||||
productFlavors {
|
||||
play {
|
||||
buildConfigField 'boolean', 'FIREBASE_AVAILABLE', 'true'
|
||||
buildConfigField 'boolean', 'RATE_APP_AVAILABLE', 'true'
|
||||
buildConfigField 'boolean', 'INSTALL_PACKAGES_AVAILABLE', 'false'
|
||||
}
|
||||
fdroid {
|
||||
buildConfigField 'boolean', 'FIREBASE_AVAILABLE', 'false'
|
||||
buildConfigField 'boolean', 'RATE_APP_AVAILABLE', 'false'
|
||||
buildConfigField 'boolean', 'INSTALL_PACKAGES_AVAILABLE', 'true'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -64,75 +44,30 @@ android {
|
|||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
freeCompilerArgs += [
|
||||
'-Xjvm-default=all-compatibility' // https://stackoverflow.com/a/71234042/1440785
|
||||
]
|
||||
}
|
||||
namespace "io.heckel.ntfy"
|
||||
}
|
||||
|
||||
// Disables GoogleServices tasks for F-Droid variant
|
||||
android.applicationVariants.all { variant ->
|
||||
def shouldProcessGoogleServices = variant.flavorName == "play"
|
||||
def googleTask = tasks.findByName("process${variant.name.capitalize()}GoogleServices")
|
||||
googleTask.enabled = shouldProcessGoogleServices
|
||||
}
|
||||
|
||||
// Strips out REQUEST_INSTALL_PACKAGES permission for Google Play variant
|
||||
android.applicationVariants.all { variant ->
|
||||
def shouldStripInstallPermission = variant.flavorName == "play"
|
||||
if (shouldStripInstallPermission) {
|
||||
variant.outputs.each { output ->
|
||||
def processManifest = output.getProcessManifestProvider().get()
|
||||
processManifest.doLast { task ->
|
||||
def outputDir = task.getMultiApkManifestOutputDirectory().get().asFile
|
||||
def manifestOutFile = file("$outputDir/AndroidManifest.xml")
|
||||
def newFileContents = manifestOutFile.collect { s -> s.contains("android.permission.REQUEST_INSTALL_PACKAGES") ? "" : s }.join("\n")
|
||||
manifestOutFile.write(newFileContents, 'UTF-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX, The Basics
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation "androidx.core:core-ktx:1.10.1"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||
implementation "androidx.activity:activity-ktx:1.7.1"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.7"
|
||||
implementation "androidx.work:work-runtime-ktx:2.8.1"
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
def workManagerVersion = "2.5.0"
|
||||
|
||||
// JSON serialization
|
||||
implementation 'com.google.code.gson:gson:2.10'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
|
||||
implementation "androidx.core:core-ktx:$rootProject.coreKtxVersion"
|
||||
implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
|
||||
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
|
||||
implementation 'com.google.code.gson:gson:2.8.8'
|
||||
|
||||
// Room (SQLite)
|
||||
def room_version = "2.5.1"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
|
||||
// OkHttp (HTTP library)
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
|
||||
|
||||
// Firebase, sigh ... (only Google Play)
|
||||
playImplementation 'com.google.firebase:firebase-messaging:23.1.2'
|
||||
// WorkManager
|
||||
implementation("androidx.work:work-runtime:$workManagerVersion")
|
||||
implementation("androidx.work:work-runtime-ktx:$workManagerVersion")
|
||||
|
||||
// RecyclerView
|
||||
implementation "androidx.recyclerview:recyclerview:1.3.0"
|
||||
|
||||
// Swipe down to refresh
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerViewVersion"
|
||||
|
||||
// Material design
|
||||
implementation "com.google.android.material:material:1.9.0"
|
||||
implementation "com.google.android.material:material:$rootProject.materialVersion"
|
||||
|
||||
// LiveData
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.liveDataVersion"
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
|
||||
// Image viewer
|
||||
implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1'
|
||||
|
||||
implementation 'foundation.e:elib:0.0.1-alpha11'
|
||||
}
|
||||
|
|
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
|
@ -1 +0,0 @@
|
|||
-dontobfuscate
|
|
@ -1,112 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "4b24fe9241d824ae94f32a31e41841c8",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Notification",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"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": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, '4b24fe9241d824ae94f32a31e41841c8')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,118 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "7b0ef556331f6d2dd3515425837c3d3a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Notification",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"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": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, '7b0ef556331f6d2dd3515425837c3d3a')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,138 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "06bd845a8d39dd10549f1aeb6b40d7c5",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, '06bd845a8d39dd10549f1aeb6b40d7c5')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,164 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "f662a6c15e0b9e510350918228bfa0ea",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
},
|
||||
{
|
||||
"name": "index_Subscription_upConnectorToken",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"upConnectorToken"
|
||||
],
|
||||
"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, `click` 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": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, 'f662a6c15e0b9e510350918228bfa0ea')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,256 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "09ecfdb757b0f7643ad010fca9a0ed43",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
},
|
||||
{
|
||||
"name": "index_Subscription_upConnectorToken",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"upConnectorToken"
|
||||
],
|
||||
"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, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "Logs",
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, '09ecfdb757b0f7643ad010fca9a0ed43')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,256 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"identityHash": "ecb1b85b2ae822dc62b2843620368477",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
},
|
||||
{
|
||||
"name": "index_Subscription_upConnectorToken",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"upConnectorToken"
|
||||
],
|
||||
"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, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, 'ecb1b85b2ae822dc62b2843620368477')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,302 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 10,
|
||||
"identityHash": "c1b4f54d1d3111dc5c8f02e8fa960ceb",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encoding",
|
||||
"columnName": "encoding",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "actions",
|
||||
"columnName": "actions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, 'c1b4f54d1d3111dc5c8f02e8fa960ceb')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,320 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 11,
|
||||
"identityHash": "31f8e6a2032d1d404fad4307abf23e1b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPriority",
|
||||
"columnName": "minPriority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "autoDelete",
|
||||
"columnName": "autoDelete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encoding",
|
||||
"columnName": "encoding",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "actions",
|
||||
"columnName": "actions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, '31f8e6a2032d1d404fad4307abf23e1b')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,344 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 12,
|
||||
"identityHash": "d230005f4d9824ba9aa34c61003bdcbb",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPriority",
|
||||
"columnName": "minPriority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "autoDelete",
|
||||
"columnName": "autoDelete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastNotificationId",
|
||||
"columnName": "lastNotificationId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encoding",
|
||||
"columnName": "encoding",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "actions",
|
||||
"columnName": "actions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon.url",
|
||||
"columnName": "icon_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon.contentUri",
|
||||
"columnName": "icon_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, 'd230005f4d9824ba9aa34c61003bdcbb')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,356 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 13,
|
||||
"identityHash": "44fc291d937fdf02b9bc2d0abb10d2e0",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `dedicatedChannels` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topic",
|
||||
"columnName": "topic",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPriority",
|
||||
"columnName": "minPriority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "autoDelete",
|
||||
"columnName": "autoDelete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "insistent",
|
||||
"columnName": "insistent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastNotificationId",
|
||||
"columnName": "lastNotificationId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "dedicatedChannels",
|
||||
"columnName": "dedicatedChannels",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encoding",
|
||||
"columnName": "encoding",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "actions",
|
||||
"columnName": "actions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon.url",
|
||||
"columnName": "icon_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon.contentUri",
|
||||
"columnName": "icon_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, '44fc291d937fdf02b9bc2d0abb10d2e0')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,256 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"identityHash": "ecb1b85b2ae822dc62b2843620368477",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Subscription_baseUrl_topic",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"baseUrl",
|
||||
"topic"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
|
||||
},
|
||||
{
|
||||
"name": "index_Subscription_upConnectorToken",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"upConnectorToken"
|
||||
],
|
||||
"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, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, 'ecb1b85b2ae822dc62b2843620368477')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,296 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "5bab75c3b41c53c9855fe3a7ef8f0669",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encoding",
|
||||
"columnName": "encoding",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, '5bab75c3b41c53c9855fe3a7ef8f0669')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,296 +0,0 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "5bab75c3b41c53c9855fe3a7ef8f0669",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Subscription",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` 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": "instant",
|
||||
"columnName": "instant",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mutedUntil",
|
||||
"columnName": "mutedUntil",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "upAppId",
|
||||
"columnName": "upAppId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "upConnectorToken",
|
||||
"columnName": "upConnectorToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscriptionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "message",
|
||||
"columnName": "message",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encoding",
|
||||
"columnName": "encoding",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationId",
|
||||
"columnName": "notificationId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "priority",
|
||||
"columnName": "priority",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "3"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "click",
|
||||
"columnName": "click",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deleted",
|
||||
"columnName": "deleted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.name",
|
||||
"columnName": "attachment_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.type",
|
||||
"columnName": "attachment_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.size",
|
||||
"columnName": "attachment_size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.expires",
|
||||
"columnName": "attachment_expires",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.url",
|
||||
"columnName": "attachment_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.contentUri",
|
||||
"columnName": "attachment_contentUri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachment.progress",
|
||||
"columnName": "attachment_progress",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"subscriptionId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"baseUrl"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"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": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"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, '5bab75c3b41c53c9855fe3a7ef8f0669')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package io.heckel.ntfy.firebase
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
class FirebaseMessenger {
|
||||
fun subscribe(topic: String) {
|
||||
// Dummy to keep F-Droid flavor happy
|
||||
}
|
||||
|
||||
fun unsubscribe(topic: String) {
|
||||
// Dummy to keep F-Droid flavor happy
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package io.heckel.ntfy.firebase
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
|
||||
// Dummy to keep F-Droid flavor happy
|
||||
class FirebaseService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -1,200 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
package="io.heckel.ntfy">
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <!-- For instant delivery foregrounds service -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/> <!-- To keep foreground service awake; soon not needed anymore -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <!-- To restart service on reboot -->
|
||||
<uses-permission android:name="android.permission.VIBRATE"/> <!-- Incoming notifications should be able to vibrate the phone -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/> <!-- Only required on SDK <= 28 -->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <!-- To reschedule the websocket retry -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <!-- As of Android 13, we need to ask for permission to post notifications -->
|
||||
|
||||
<!--
|
||||
Permission REQUEST_INSTALL_PACKAGES (F-Droid only!):
|
||||
- Permission is used to install .apk files that were received as attachments
|
||||
- Google rejected the permission for ntfy, so this permission is STRIPPED OUT by the build process
|
||||
for the Google Play variant of the app.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application
|
||||
android:name=".app.Application"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:persistent="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true">
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity android:name="io.heckel.ntfy.ui.MainActivity"
|
||||
android:icon="@drawable/ntfy"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Detail activity -->
|
||||
<activity
|
||||
android:name=".ui.DetailActivity"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:exported="true">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".ui.MainActivity"/>
|
||||
|
||||
<!-- Open ntfy:// links with the app -->
|
||||
<intent-filter android:label="@string/app_name">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="ntfy" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Settings activity -->
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:parentActivityName=".ui.MainActivity">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".ui.MainActivity"/>
|
||||
</activity>
|
||||
|
||||
<!-- Detail settings activity -->
|
||||
<activity
|
||||
android:name=".ui.DetailSettingsActivity"
|
||||
android:parentActivityName=".ui.DetailActivity">
|
||||
</activity>
|
||||
|
||||
<!-- Share file activity, incoming files/shares -->
|
||||
<activity android:name=".ui.ShareActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Hack: Activity used for "view" action button with "clear=true" (to be able to cancel notifications and show a URL) -->
|
||||
<activity
|
||||
android:name=".msg.NotificationService$ViewActionWithClearActivity"
|
||||
android:exported="false">
|
||||
</activity>
|
||||
|
||||
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
|
||||
<service android:name=".service.SubscriberService"/>
|
||||
|
||||
<!-- Subscriber service restart on reboot -->
|
||||
<receiver
|
||||
android:name=".service.SubscriberService$BootStartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Subscriber service restart on destruction -->
|
||||
<receiver
|
||||
android:name=".service.SubscriberService$AutoRestartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false"/>
|
||||
|
||||
<!-- Broadcast receiver to send messages via intents -->
|
||||
<receiver
|
||||
android:name=".msg.BroadcastService$BroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="io.heckel.ntfy.SEND_MESSAGE"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Broadcast receiver for UnifiedPush; must match https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md -->
|
||||
<receiver
|
||||
android:name=".up.BroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.distributor.REGISTER"/>
|
||||
<action android:name="org.unifiedpush.android.distributor.UNREGISTER"/>
|
||||
<action android:name="org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Broadcast receiver for the "Download"/"Cancel" attachment action in the notification popup -->
|
||||
<receiver
|
||||
android:name=".msg.NotificationService$UserActionBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
</receiver>
|
||||
|
||||
<!-- Broadcast receiver for when the notification is swiped away (currently only to cancel the insistent sound) -->
|
||||
<receiver
|
||||
android:name=".msg.NotificationService$DeleteBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
</receiver>
|
||||
|
||||
<!-- Firebase messaging (note that this is empty in the F-Droid flavor) -->
|
||||
<service
|
||||
android:name=".firebase.FirebaseService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data
|
||||
android:name="firebase_analytics_collection_enabled"
|
||||
android:value="false"/>
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_notification"/>
|
||||
|
||||
<!-- FileProvider required for older Android versions (<= P), to allow passing the file URI in the open intent.
|
||||
Avoids "exposed beyond app through Intent.getData" exception, see see https://stackoverflow.com/a/57288352/1440785 -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainSettingsActivity"
|
||||
android:theme="@style/PreferenceTheme"/>
|
||||
|
||||
<activity-alias
|
||||
android:name=".ui.SettingsActivityLink"
|
||||
android:exported="true"
|
||||
android:label="@string/eos_settings_title"
|
||||
android:targetActivity=".ui.MainSettingsActivity">
|
||||
<intent-filter>
|
||||
<action android:name="com.android.settings.action.EXTRA_SETTINGS" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="com.android.settings.category"
|
||||
android:value="com.android.settings.category.device" />
|
||||
<meta-data
|
||||
android:name="com.android.settings.icon"
|
||||
android:resource="@drawable/ic_notification" />
|
||||
</activity-alias>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 49 KiB |
|
@ -1,15 +0,0 @@
|
|||
package io.heckel.ntfy.app
|
||||
|
||||
import android.app.Application
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.util.Log
|
||||
|
||||
class Application : Application() {
|
||||
val repository by lazy {
|
||||
val repository = Repository.getInstance(applicationContext)
|
||||
if (repository.getRecordLogs()) {
|
||||
Log.setRecord(true)
|
||||
}
|
||||
repository
|
||||
}
|
||||
}
|
|
@ -1,435 +0,0 @@
|
|||
package io.heckel.ntfy.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.stream.JsonReader
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.firebase.FirebaseMessenger
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class Backuper(val context: Context) {
|
||||
private val gson = Gson()
|
||||
private val resolver = context.applicationContext.contentResolver
|
||||
private val repository = (context.applicationContext as Application).repository
|
||||
private val messenger = FirebaseMessenger()
|
||||
private val notifier = NotificationService(context)
|
||||
|
||||
suspend fun backup(uri: Uri, withSettings: Boolean = true, withSubscriptions: Boolean = true, withUsers: Boolean = true) {
|
||||
Log.d(TAG, "Backing up settings to file $uri")
|
||||
val json = gson.toJson(createBackupFile(withSettings, withSubscriptions, withUsers))
|
||||
val outputStream = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
|
||||
outputStream.use { it.write(json.toByteArray()) }
|
||||
Log.d(TAG, "Backup done")
|
||||
}
|
||||
|
||||
suspend fun restore(uri: Uri) {
|
||||
Log.d(TAG, "Restoring settings from file $uri")
|
||||
val reader = JsonReader(InputStreamReader(resolver.openInputStream(uri)))
|
||||
val backupFile = gson.fromJson<BackupFile>(reader, BackupFile::class.java)
|
||||
applyBackupFile(backupFile)
|
||||
Log.d(TAG, "Restoring done")
|
||||
}
|
||||
|
||||
fun settingsAsString(): String {
|
||||
val gson = GsonBuilder().setPrettyPrinting().create()
|
||||
return gson.toJson(createSettings())
|
||||
}
|
||||
|
||||
private suspend fun applyBackupFile(backupFile: BackupFile) {
|
||||
if (backupFile.magic != FILE_MAGIC) {
|
||||
throw InvalidBackupFileException()
|
||||
}
|
||||
applySettings(backupFile.settings)
|
||||
applySubscriptions(backupFile.subscriptions)
|
||||
applyNotifications(backupFile.notifications)
|
||||
applyUsers(backupFile.users)
|
||||
}
|
||||
|
||||
private fun applySettings(settings: Settings?) {
|
||||
if (settings == null) {
|
||||
return
|
||||
}
|
||||
if (settings.minPriority != null) {
|
||||
repository.setMinPriority(settings.minPriority)
|
||||
}
|
||||
if (settings.autoDownloadMaxSize != null) {
|
||||
repository.setAutoDownloadMaxSize(settings.autoDownloadMaxSize)
|
||||
}
|
||||
if (settings.autoDeleteSeconds != null) {
|
||||
repository.setAutoDeleteSeconds(settings.autoDeleteSeconds)
|
||||
}
|
||||
if (settings.darkMode != null) {
|
||||
repository.setDarkMode(settings.darkMode)
|
||||
}
|
||||
if (settings.connectionProtocol != null) {
|
||||
repository.setConnectionProtocol(settings.connectionProtocol)
|
||||
}
|
||||
if (settings.broadcastEnabled != null) {
|
||||
repository.setBroadcastEnabled(settings.broadcastEnabled)
|
||||
}
|
||||
if (settings.recordLogs != null) {
|
||||
repository.setRecordLogsEnabled(settings.recordLogs)
|
||||
}
|
||||
if (settings.defaultBaseUrl != null) {
|
||||
repository.setDefaultBaseUrl(settings.defaultBaseUrl)
|
||||
}
|
||||
if (settings.mutedUntil != null) {
|
||||
repository.setGlobalMutedUntil(settings.mutedUntil)
|
||||
}
|
||||
if (settings.lastSharedTopics != null) {
|
||||
settings.lastSharedTopics.forEach { repository.addLastShareTopic(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applySubscriptions(subscriptions: List<Subscription>?) {
|
||||
if (subscriptions == null) {
|
||||
return
|
||||
}
|
||||
val appBaseUrl = context.getString(R.string.app_base_url)
|
||||
subscriptions.forEach { s ->
|
||||
try {
|
||||
// Add to database
|
||||
val subscription = io.heckel.ntfy.db.Subscription(
|
||||
id = s.id,
|
||||
baseUrl = s.baseUrl,
|
||||
topic = s.topic,
|
||||
instant = s.instant,
|
||||
dedicatedChannels = s.dedicatedChannels,
|
||||
mutedUntil = s.mutedUntil,
|
||||
minPriority = s.minPriority ?: Repository.MIN_PRIORITY_USE_GLOBAL,
|
||||
autoDelete = s.autoDelete ?: Repository.AUTO_DELETE_USE_GLOBAL,
|
||||
insistent = s.insistent ?: Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL,
|
||||
lastNotificationId = s.lastNotificationId,
|
||||
icon = s.icon,
|
||||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName,
|
||||
)
|
||||
repository.addSubscription(subscription)
|
||||
|
||||
// Subscribe to Firebase topics
|
||||
if (s.baseUrl == appBaseUrl) {
|
||||
messenger.subscribe(s.topic)
|
||||
}
|
||||
|
||||
// Create dedicated channels
|
||||
if (s.dedicatedChannels) {
|
||||
notifier.createSubscriptionNotificationChannels(subscription)
|
||||
// TODO Backup/restore individual notification channel settings
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to restore subscription ${s.id} (${topicUrl(s.baseUrl, s.topic)}): ${e.message}. Ignoring.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyNotifications(notifications: List<Notification>?) {
|
||||
if (notifications == null) {
|
||||
return
|
||||
}
|
||||
notifications.forEach { n ->
|
||||
try {
|
||||
val actions = if (n.actions != null) {
|
||||
n.actions.map { a ->
|
||||
io.heckel.ntfy.db.Action(
|
||||
id = a.id,
|
||||
action = a.action,
|
||||
label = a.label,
|
||||
clear = a.clear,
|
||||
url = a.url,
|
||||
method = a.method,
|
||||
headers = a.headers,
|
||||
body = a.body,
|
||||
intent = a.intent,
|
||||
extras = a.extras,
|
||||
progress = a.progress,
|
||||
error = a.error
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val attachment = if (n.attachment != null) {
|
||||
io.heckel.ntfy.db.Attachment(
|
||||
name = n.attachment.name,
|
||||
type = n.attachment.type,
|
||||
size = n.attachment.size,
|
||||
expires = n.attachment.expires,
|
||||
url = n.attachment.url,
|
||||
contentUri = n.attachment.contentUri,
|
||||
progress = n.attachment.progress,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val icon = if (n.icon != null) {
|
||||
io.heckel.ntfy.db.Icon(
|
||||
url = n.icon.url,
|
||||
contentUri = n.icon.contentUri,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
repository.addNotification(io.heckel.ntfy.db.Notification(
|
||||
id = n.id,
|
||||
subscriptionId = n.subscriptionId,
|
||||
timestamp = n.timestamp,
|
||||
title = n.title,
|
||||
message = n.message,
|
||||
encoding = n.encoding,
|
||||
notificationId = 0,
|
||||
priority = n.priority,
|
||||
tags = n.tags,
|
||||
click = n.click,
|
||||
icon = icon,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
deleted = n.deleted
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to restore notification ${n.id}: ${e.message}. Ignoring.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyUsers(users: List<User>?) {
|
||||
if (users == null) {
|
||||
return
|
||||
}
|
||||
users.forEach { u ->
|
||||
try {
|
||||
repository.addUser(io.heckel.ntfy.db.User(
|
||||
baseUrl = u.baseUrl,
|
||||
username = u.username,
|
||||
password = u.password
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to restore user ${u.baseUrl} / ${u.username}: ${e.message}. Ignoring.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createBackupFile(withSettings: Boolean, withSubscriptions: Boolean, withUsers: Boolean): BackupFile {
|
||||
return BackupFile(
|
||||
magic = FILE_MAGIC,
|
||||
version = FILE_VERSION,
|
||||
settings = if (withSettings) createSettings() else null,
|
||||
subscriptions = if (withSubscriptions) createSubscriptionList() else null,
|
||||
notifications = if (withSubscriptions) createNotificationList() else null,
|
||||
users = if (withUsers) createUserList() else null
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSettings(): Settings {
|
||||
return Settings(
|
||||
minPriority = repository.getMinPriority(),
|
||||
autoDownloadMaxSize = repository.getAutoDownloadMaxSize(),
|
||||
autoDeleteSeconds = repository.getAutoDeleteSeconds(),
|
||||
darkMode = repository.getDarkMode(),
|
||||
connectionProtocol = repository.getConnectionProtocol(),
|
||||
broadcastEnabled = repository.getBroadcastEnabled(),
|
||||
recordLogs = repository.getRecordLogs(),
|
||||
defaultBaseUrl = repository.getDefaultBaseUrl() ?: "",
|
||||
mutedUntil = repository.getGlobalMutedUntil(),
|
||||
lastSharedTopics = repository.getLastShareTopics()
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun createSubscriptionList(): List<Subscription> {
|
||||
return repository.getSubscriptions().map { s ->
|
||||
Subscription(
|
||||
id = s.id,
|
||||
baseUrl = s.baseUrl,
|
||||
topic = s.topic,
|
||||
instant = s.instant,
|
||||
dedicatedChannels = s.dedicatedChannels,
|
||||
mutedUntil = s.mutedUntil,
|
||||
minPriority = s.minPriority,
|
||||
autoDelete = s.autoDelete,
|
||||
insistent = s.insistent,
|
||||
lastNotificationId = s.lastNotificationId,
|
||||
icon = s.icon,
|
||||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createNotificationList(): List<Notification> {
|
||||
return repository.getNotifications().map { n ->
|
||||
val actions = if (n.actions != null) {
|
||||
n.actions.map { a ->
|
||||
Action(
|
||||
id = a.id,
|
||||
action = a.action,
|
||||
label = a.label,
|
||||
clear = a.clear,
|
||||
url = a.url,
|
||||
method = a.method,
|
||||
headers = a.headers,
|
||||
body = a.body,
|
||||
intent = a.intent,
|
||||
extras = a.extras,
|
||||
progress = a.progress,
|
||||
error = a.error
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val attachment = if (n.attachment != null) {
|
||||
Attachment(
|
||||
name = n.attachment.name,
|
||||
type = n.attachment.type,
|
||||
size = n.attachment.size,
|
||||
expires = n.attachment.expires,
|
||||
url = n.attachment.url,
|
||||
contentUri = n.attachment.contentUri,
|
||||
progress = n.attachment.progress,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val icon = if (n.icon != null) {
|
||||
Icon(
|
||||
url = n.icon.url,
|
||||
contentUri = n.icon.contentUri,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Notification(
|
||||
id = n.id,
|
||||
subscriptionId = n.subscriptionId,
|
||||
timestamp = n.timestamp,
|
||||
title = n.title,
|
||||
message = n.message,
|
||||
encoding = n.encoding,
|
||||
priority = n.priority,
|
||||
tags = n.tags,
|
||||
click = n.click,
|
||||
icon = icon,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
deleted = n.deleted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createUserList(): List<User> {
|
||||
return repository.getUsers().map { u ->
|
||||
User(
|
||||
baseUrl = u.baseUrl,
|
||||
username = u.username,
|
||||
password = u.password
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_MAGIC = "ntfy2586"
|
||||
private const val FILE_VERSION = 1
|
||||
private const val TAG = "NtfyExporter"
|
||||
}
|
||||
}
|
||||
|
||||
data class BackupFile(
|
||||
val magic: String,
|
||||
val version: Int,
|
||||
val settings: Settings?,
|
||||
val subscriptions: List<Subscription>?,
|
||||
val notifications: List<Notification>?,
|
||||
val users: List<User>?
|
||||
)
|
||||
|
||||
data class Settings(
|
||||
val minPriority: Int?,
|
||||
val autoDownloadMaxSize: Long?,
|
||||
val autoDeleteSeconds: Long?,
|
||||
val darkMode: Int?,
|
||||
val connectionProtocol: String?,
|
||||
val broadcastEnabled: Boolean?,
|
||||
val recordLogs: Boolean?,
|
||||
val defaultBaseUrl: String?,
|
||||
val mutedUntil: Long?,
|
||||
val lastSharedTopics: List<String>?,
|
||||
)
|
||||
|
||||
data class Subscription(
|
||||
val id: Long,
|
||||
val baseUrl: String,
|
||||
val topic: String,
|
||||
val instant: Boolean,
|
||||
val dedicatedChannels: Boolean,
|
||||
val mutedUntil: Long,
|
||||
val minPriority: Int?,
|
||||
val autoDelete: Long?,
|
||||
val insistent: Int?,
|
||||
val lastNotificationId: String?,
|
||||
val icon: String?,
|
||||
val upAppId: String?,
|
||||
val upConnectorToken: String?,
|
||||
val displayName: String?
|
||||
)
|
||||
|
||||
data class Notification(
|
||||
val id: String,
|
||||
val subscriptionId: Long,
|
||||
val timestamp: Long,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val encoding: String, // "base64" or ""
|
||||
val priority: Int, // 1=min, 3=default, 5=max
|
||||
val tags: String,
|
||||
val click: String, // URL/intent to open on notification click
|
||||
val icon: Icon?,
|
||||
val actions: List<Action>?,
|
||||
val attachment: Attachment?,
|
||||
val deleted: Boolean
|
||||
)
|
||||
|
||||
data class Action(
|
||||
val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
|
||||
val action: String, // "view", "http" or "broadcast"
|
||||
val label: String,
|
||||
val clear: Boolean?, // clear notification after successful execution
|
||||
val url: String?, // used in "view" and "http" actions
|
||||
val method: String?, // used in "http" action
|
||||
val headers: Map<String,String>?, // used in "http" action
|
||||
val body: String?, // used in "http" action
|
||||
val intent: String?, // used in "broadcast" action
|
||||
val extras: Map<String,String>?, // used in "broadcast" action
|
||||
val progress: Int?, // used to indicate progress in popup
|
||||
val error: String? // used to indicate errors in popup
|
||||
)
|
||||
|
||||
data class Attachment(
|
||||
val name: String, // Filename
|
||||
val type: String?, // MIME type
|
||||
val size: Long?, // Size in bytes
|
||||
val expires: Long?, // Unix timestamp
|
||||
val url: String, // URL (mandatory, see ntfy server)
|
||||
val contentUri: String?, // After it's downloaded, the content:// location
|
||||
val progress: Int, // Progress during download, -1 if not downloaded
|
||||
)
|
||||
|
||||
data class Icon(
|
||||
val url: String, // URL (mandatory, see ntfy server)
|
||||
val contentUri: String?, // After it's downloaded, the content:// location
|
||||
)
|
||||
|
||||
data class User(
|
||||
val baseUrl: String,
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
class InvalidBackupFileException : Exception("Invalid backup file format")
|
92
app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt
Normal file
92
app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt
Normal file
|
@ -0,0 +1,92 @@
|
|||
package io.heckel.ntfy.data
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonObject
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
const val READ_TIMEOUT = 60_000 // Keep alive every 30s assumed
|
||||
|
||||
class ConnectionManager(private val repository: Repository) {
|
||||
private val jobs = mutableMapOf<Long, Job>()
|
||||
private val gson = GsonBuilder().create()
|
||||
private var listener: NotificationListener? = null;
|
||||
|
||||
fun start(s: Subscription) {
|
||||
jobs[s.id] = launchConnection(s.id, topicJsonUrl(s))
|
||||
}
|
||||
|
||||
fun stop(s: Subscription) {
|
||||
jobs.remove(s.id)?.cancel() // Cancel coroutine and remove
|
||||
}
|
||||
|
||||
fun setListener(l: NotificationListener) {
|
||||
this.listener = l
|
||||
}
|
||||
|
||||
private fun launchConnection(subscriptionId: Long, topicUrl: String): Job {
|
||||
return GlobalScope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
openConnection(subscriptionId, topicUrl)
|
||||
delay(5000) // TODO exponential back-off
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConnection(subscriptionId: Long, topicUrl: String) {
|
||||
println("Connecting to $topicUrl ...")
|
||||
val conn = (URL(topicUrl).openConnection() as HttpURLConnection).also {
|
||||
it.doInput = true
|
||||
it.readTimeout = READ_TIMEOUT
|
||||
}
|
||||
try {
|
||||
updateStatus(subscriptionId, Status.CONNECTED)
|
||||
val input = conn.inputStream.bufferedReader()
|
||||
while (GlobalScope.isActive) {
|
||||
val line = input.readLine() ?: break // Break if EOF is reached, i.e. readLine is null
|
||||
if (!GlobalScope.isActive) {
|
||||
break // Break if scope is not active anymore; readLine blocks for a while, so we want to be sure
|
||||
}
|
||||
val json = gson.fromJson(line, JsonObject::class.java) ?: break // Break on unexpected line
|
||||
val validNotification = !json.isJsonNull
|
||||
&& !json.has("event") // No keepalive or open messages
|
||||
&& json.has("message")
|
||||
if (validNotification) {
|
||||
notify(subscriptionId, json.get("message").asString)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Connection error: " + e)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
updateStatus(subscriptionId, Status.RECONNECTING)
|
||||
println("Connection terminated: $topicUrl")
|
||||
}
|
||||
|
||||
private fun updateStatus(subscriptionId: Long, status: Status) {
|
||||
val subscription = repository.get(subscriptionId)
|
||||
repository.update(subscription?.copy(status = status))
|
||||
}
|
||||
|
||||
private fun notify(subscriptionId: Long, message: String) {
|
||||
val subscription = repository.get(subscriptionId)
|
||||
if (subscription != null) {
|
||||
listener?.let { it(Notification(subscription, message)) }
|
||||
repository.update(subscription.copy(messages = subscription.messages + 1))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var instance: ConnectionManager? = null
|
||||
|
||||
fun getInstance(repository: Repository): ConnectionManager {
|
||||
return synchronized(ConnectionManager::class) {
|
||||
val newInstance = instance ?: ConnectionManager(repository)
|
||||
instance = newInstance
|
||||
newInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
107
app/src/main/java/io/heckel/ntfy/data/ConnectionWorker.kt
Normal file
107
app/src/main/java/io/heckel/ntfy/data/ConnectionWorker.kt
Normal file
|
@ -0,0 +1,107 @@
|
|||
package io.heckel.ntfy.data
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonObject
|
||||
import io.heckel.ntfy.R
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.random.Random
|
||||
|
||||
class ConnectionWorker(val ctx: Context, workerParams: WorkerParameters) : CoroutineWorker(ctx, workerParams) {
|
||||
private val gson = GsonBuilder().create()
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
println("PHIL work started")
|
||||
|
||||
while (isStopped) {
|
||||
openConnection(Random.nextLong(), "https://ntfy.sh/test/json")
|
||||
}
|
||||
|
||||
println("PHIL work ended")
|
||||
// Indicate whether the work finished successfully with the Result
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun openConnection(subscriptionId: Long, topicUrl: String) {
|
||||
println("Connecting to $topicUrl ...")
|
||||
val conn = (URL(topicUrl).openConnection() as HttpURLConnection).also {
|
||||
it.doInput = true
|
||||
it.readTimeout = READ_TIMEOUT
|
||||
}
|
||||
try {
|
||||
println("PHIL connected")
|
||||
val input = conn.inputStream.bufferedReader()
|
||||
while (isStopped) {
|
||||
val line = input.readLine() ?: break // Break if EOF is reached, i.e. readLine is null
|
||||
val json = gson.fromJson(line, JsonObject::class.java) ?: break // Break on unexpected line
|
||||
val validNotification = !json.isJsonNull
|
||||
&& !json.has("event") // No keepalive or open messages
|
||||
&& json.has("message")
|
||||
if (validNotification) {
|
||||
val title = "ntfy.sh/test"
|
||||
val message = json.get("message").asString
|
||||
displayNotification(title, message)
|
||||
println("notification received: ${json.get("message").asString}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Connection error: " + e)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
println("Connection terminated: $topicUrl")
|
||||
}
|
||||
|
||||
private fun displayNotification(title: String, message: String) {
|
||||
val notificationManager =
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channelId = ctx.getString(R.string.notification_channel_id)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name = ctx.getString(R.string.notification_channel_name)
|
||||
val descriptionText = ctx.getString(R.string.notification_channel_name)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(ctx, channelId)
|
||||
.setSmallIcon(R.drawable.ntfy)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.build()
|
||||
notificationManager.notify(Random.nextInt(), notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the NotificationChannel, but only on API 26+ because
|
||||
* the NotificationChannel class is new and not in the support library
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = ctx.getString(R.string.notification_channel_id)
|
||||
val name = ctx.getString(R.string.notification_channel_name)
|
||||
val descriptionText = ctx.getString(R.string.notification_channel_name)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
24
app/src/main/java/io/heckel/ntfy/data/Models.kt
Normal file
24
app/src/main/java/io/heckel/ntfy/data/Models.kt
Normal file
|
@ -0,0 +1,24 @@
|
|||
package io.heckel.ntfy.data
|
||||
|
||||
enum class Status {
|
||||
CONNECTED, CONNECTING, RECONNECTING
|
||||
}
|
||||
|
||||
data class Subscription(
|
||||
val id: Long, // Internal ID, only used in Repository and activities
|
||||
val topic: String,
|
||||
val baseUrl: String,
|
||||
val status: Status,
|
||||
val messages: Int
|
||||
)
|
||||
|
||||
data class Notification(
|
||||
val subscription: Subscription,
|
||||
val message: String
|
||||
)
|
||||
|
||||
typealias NotificationListener = (notification: Notification) -> Unit
|
||||
|
||||
fun topicUrl(s: Subscription) = "${s.baseUrl}/${s.topic}"
|
||||
fun topicJsonUrl(s: Subscription) = "${s.baseUrl}/${s.topic}/json"
|
||||
fun topicShortUrl(s: Subscription) = topicUrl(s).replace("http://", "").replace("https://", "")
|
58
app/src/main/java/io/heckel/ntfy/data/Repository.kt
Normal file
58
app/src/main/java/io/heckel/ntfy/data/Repository.kt
Normal file
|
@ -0,0 +1,58 @@
|
|||
package io.heckel.ntfy.data
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
||||
class Repository {
|
||||
private val subscriptions = mutableListOf<Subscription>()
|
||||
private val subscriptionsLiveData: MutableLiveData<List<Subscription>> = MutableLiveData(subscriptions)
|
||||
|
||||
fun add(subscription: Subscription) {
|
||||
synchronized(subscriptions) {
|
||||
subscriptions.add(subscription)
|
||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
||||
}
|
||||
}
|
||||
|
||||
fun update(subscription: Subscription?) {
|
||||
if (subscription == null) {
|
||||
return
|
||||
}
|
||||
synchronized(subscriptions) {
|
||||
val index = subscriptions.indexOfFirst { it.id == subscription.id } // Find index by Topic ID
|
||||
if (index == -1) return
|
||||
subscriptions[index] = subscription
|
||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(subscription: Subscription) {
|
||||
synchronized(subscriptions) {
|
||||
if (subscriptions.remove(subscription)) {
|
||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(id: Long): Subscription? {
|
||||
synchronized(subscriptions) {
|
||||
return subscriptions.firstOrNull { it.id == id } // Find index by Topic ID
|
||||
}
|
||||
}
|
||||
|
||||
fun list(): LiveData<List<Subscription>> {
|
||||
return subscriptionsLiveData
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var instance: Repository? = null
|
||||
|
||||
fun getInstance(): Repository {
|
||||
return synchronized(Repository::class) {
|
||||
val newInstance = instance ?: Repository()
|
||||
instance = newInstance
|
||||
newInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,500 +0,0 @@
|
|||
package io.heckel.ntfy.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.lang.reflect.Type
|
||||
|
||||
@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
|
||||
data class Subscription(
|
||||
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
|
||||
@ColumnInfo(name = "baseUrl") val baseUrl: String,
|
||||
@ColumnInfo(name = "topic") val topic: String,
|
||||
@ColumnInfo(name = "instant") val instant: Boolean,
|
||||
@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 = "lastNotificationId") val lastNotificationId: String?, // Used for polling, with since=<id>
|
||||
@ColumnInfo(name = "icon") val icon: String?, // content://-URI (or later other identifier)
|
||||
@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?,
|
||||
@ColumnInfo(name = "dedicatedChannels") val dedicatedChannels: Boolean,
|
||||
@Ignore val totalCount: Int = 0, // Total notifications
|
||||
@Ignore val newCount: Int = 0, // New notifications
|
||||
@Ignore val lastActive: Long = 0, // Unix timestamp
|
||||
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
|
||||
) {
|
||||
constructor(
|
||||
id: Long,
|
||||
baseUrl: String,
|
||||
topic: String,
|
||||
instant: Boolean,
|
||||
mutedUntil: Long,
|
||||
minPriority: Int,
|
||||
autoDelete: Long,
|
||||
insistent: Int,
|
||||
lastNotificationId: String,
|
||||
icon: String,
|
||||
upAppId: String,
|
||||
upConnectorToken: String,
|
||||
displayName: String?,
|
||||
dedicatedChannels: Boolean
|
||||
) :
|
||||
this(
|
||||
id,
|
||||
baseUrl,
|
||||
topic,
|
||||
instant,
|
||||
mutedUntil,
|
||||
minPriority,
|
||||
autoDelete,
|
||||
insistent,
|
||||
lastNotificationId,
|
||||
icon,
|
||||
upAppId,
|
||||
upConnectorToken,
|
||||
displayName,
|
||||
dedicatedChannels,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = 0,
|
||||
state = ConnectionState.NOT_APPLICABLE
|
||||
)
|
||||
}
|
||||
|
||||
enum class ConnectionState {
|
||||
NOT_APPLICABLE, CONNECTING, CONNECTED
|
||||
}
|
||||
|
||||
data class SubscriptionWithMetadata(
|
||||
val id: Long,
|
||||
val baseUrl: String,
|
||||
val topic: String,
|
||||
val instant: Boolean,
|
||||
val mutedUntil: Long,
|
||||
val autoDelete: Long,
|
||||
val minPriority: Int,
|
||||
val insistent: Int,
|
||||
val lastNotificationId: String?,
|
||||
val icon: String?,
|
||||
val upAppId: String?,
|
||||
val upConnectorToken: String?,
|
||||
val displayName: String?,
|
||||
val dedicatedChannels: Boolean,
|
||||
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 = "encoding") val encoding: String, // "base64" or ""
|
||||
@ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
|
||||
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
|
||||
@ColumnInfo(name = "tags") val tags: String,
|
||||
@ColumnInfo(name = "click") val click: String, // URL/intent to open on notification click
|
||||
@Embedded(prefix = "icon_") val icon: Icon?,
|
||||
@ColumnInfo(name = "actions") val actions: List<Action>?,
|
||||
@Embedded(prefix = "attachment_") val attachment: Attachment?,
|
||||
@ColumnInfo(name = "deleted") val deleted: Boolean,
|
||||
)
|
||||
|
||||
@Entity
|
||||
data class Attachment(
|
||||
@ColumnInfo(name = "name") val name: String, // Filename
|
||||
@ColumnInfo(name = "type") val type: String?, // MIME type
|
||||
@ColumnInfo(name = "size") val size: Long?, // Size in bytes
|
||||
@ColumnInfo(name = "expires") val expires: Long?, // Unix timestamp
|
||||
@ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server)
|
||||
@ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
|
||||
@ColumnInfo(name = "progress") val progress: Int, // Progress during download, -1 if not downloaded
|
||||
) {
|
||||
constructor(name: String, type: String?, size: Long?, expires: Long?, url: String) :
|
||||
this(name, type, size, expires, url, null, ATTACHMENT_PROGRESS_NONE)
|
||||
}
|
||||
|
||||
const val ATTACHMENT_PROGRESS_NONE = -1
|
||||
const val ATTACHMENT_PROGRESS_INDETERMINATE = -2
|
||||
const val ATTACHMENT_PROGRESS_FAILED = -3
|
||||
const val ATTACHMENT_PROGRESS_DELETED = -4
|
||||
const val ATTACHMENT_PROGRESS_DONE = 100
|
||||
|
||||
@Entity
|
||||
data class Icon(
|
||||
@ColumnInfo(name = "url") val url: String, // URL (mandatory, see ntfy server)
|
||||
@ColumnInfo(name = "contentUri") val contentUri: String?, // After it's downloaded, the content:// location
|
||||
) {
|
||||
constructor(url:String) :
|
||||
this(url, null)
|
||||
}
|
||||
|
||||
@Entity
|
||||
data class Action(
|
||||
@ColumnInfo(name = "id") val id: String, // Synthetic ID to identify result, and easily pass via Broadcast and WorkManager
|
||||
@ColumnInfo(name = "action") val action: String, // "view", "http" or "broadcast"
|
||||
@ColumnInfo(name = "label") val label: String,
|
||||
@ColumnInfo(name = "clear") val clear: Boolean?, // clear notification after successful execution
|
||||
@ColumnInfo(name = "url") val url: String?, // used in "view" and "http" actions
|
||||
@ColumnInfo(name = "method") val method: String?, // used in "http" action
|
||||
@ColumnInfo(name = "headers") val headers: Map<String,String>?, // used in "http" action
|
||||
@ColumnInfo(name = "body") val body: String?, // used in "http" action
|
||||
@ColumnInfo(name = "intent") val intent: String?, // used in "broadcast" action
|
||||
@ColumnInfo(name = "extras") val extras: Map<String,String>?, // used in "broadcast" action
|
||||
@ColumnInfo(name = "progress") val progress: Int?, // used to indicate progress in popup
|
||||
@ColumnInfo(name = "error") val error: String?, // used to indicate errors in popup
|
||||
)
|
||||
|
||||
const val ACTION_PROGRESS_ONGOING = 1
|
||||
const val ACTION_PROGRESS_SUCCESS = 2
|
||||
const val ACTION_PROGRESS_FAILED = 3
|
||||
|
||||
class Converters {
|
||||
private val gson = Gson()
|
||||
|
||||
@TypeConverter
|
||||
fun toActionList(value: String?): List<Action>? {
|
||||
val listType: Type = object : TypeToken<List<Action>?>() {}.type
|
||||
return gson.fromJson(value, listType)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromActionList(list: List<Action>?): String {
|
||||
return gson.toJson(list)
|
||||
}
|
||||
}
|
||||
|
||||
@Entity
|
||||
data class User(
|
||||
@PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String,
|
||||
@ColumnInfo(name = "username") val username: String,
|
||||
@ColumnInfo(name = "password") val password: String
|
||||
) {
|
||||
override fun toString(): String = username
|
||||
}
|
||||
|
||||
@Entity(tableName = "Log")
|
||||
data class LogEntry(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long, // Internal ID, only used in Repository and activities
|
||||
@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?
|
||||
) {
|
||||
constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) :
|
||||
this(0, timestamp, tag, level, message, exception)
|
||||
}
|
||||
|
||||
@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 13)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class Database : RoomDatabase() {
|
||||
abstract fun subscriptionDao(): SubscriptionDao
|
||||
abstract fun notificationDao(): NotificationDao
|
||||
abstract fun userDao(): UserDao
|
||||
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)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
this.instance = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Drop "notifications" & "lastActive" columns (SQLite does not support dropping columns, ...)
|
||||
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)")
|
||||
|
||||
// Add "notificationId" & "deleted" columns
|
||||
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") // Room limitation: Has to be nullable for @Embedded
|
||||
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") // Room limitation: Has to be nullable for @Embedded
|
||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_contentUri TEXT")
|
||||
db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_progress INT") // Room limitation: Has to be nullable for @Embedded
|
||||
}
|
||||
}
|
||||
|
||||
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)") // = Repository.MIN_PRIORITY_USE_GLOBAL
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN autoDelete INT NOT NULL DEFAULT (-1)") // = Repository.AUTO_DELETE_USE_GLOBAL
|
||||
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") // Room limitation: Has to be nullable for @Embedded
|
||||
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)") // = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN dedicatedChannels INTEGER NOT NULL DEFAULT (0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface SubscriptionDao {
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels,
|
||||
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<List<SubscriptionWithMetadata>>
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels,
|
||||
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<SubscriptionWithMetadata>
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels,
|
||||
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.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels,
|
||||
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.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.dedicatedChannels,
|
||||
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("UPDATE subscription SET lastNotificationId = :lastNotificationId WHERE id = :subscriptionId")
|
||||
fun updateLastNotificationId(subscriptionId: Long, lastNotificationId: String)
|
||||
|
||||
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
|
||||
fun remove(subscriptionId: Long)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface NotificationDao {
|
||||
@Query("SELECT * FROM notification")
|
||||
suspend fun list(): List<Notification>
|
||||
|
||||
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
|
||||
fun listFlow(subscriptionId: Long): Flow<List<Notification>>
|
||||
|
||||
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted
|
||||
fun listIds(subscriptionId: Long): List<String>
|
||||
|
||||
@Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''")
|
||||
fun listDeletedWithAttachments(): List<Notification>
|
||||
|
||||
@Query("SELECT DISTINCT icon_contentUri FROM notification WHERE deleted != 1 AND icon_contentUri <> ''")
|
||||
fun listActiveIconUris(): List<String>
|
||||
|
||||
@Query("UPDATE notification SET icon_contentUri = null WHERE icon_contentUri = :uri")
|
||||
fun clearIconUri(uri: String)
|
||||
|
||||
@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 UserDao {
|
||||
@Insert
|
||||
suspend fun insert(user: User)
|
||||
|
||||
@Query("SELECT * FROM user ORDER BY username")
|
||||
suspend fun list(): List<User>
|
||||
|
||||
@Query("SELECT * FROM user ORDER BY username")
|
||||
fun listFlow(): Flow<List<User>>
|
||||
|
||||
@Query("SELECT * FROM user WHERE baseUrl = :baseUrl")
|
||||
suspend fun get(baseUrl: String): User?
|
||||
|
||||
@Update
|
||||
suspend fun update(user: User)
|
||||
|
||||
@Query("DELETE FROM user WHERE baseUrl = :baseUrl")
|
||||
suspend fun delete(baseUrl: String)
|
||||
}
|
||||
|
||||
@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<LogEntry>
|
||||
|
||||
@Query("DELETE FROM log")
|
||||
fun deleteAll()
|
||||
}
|
|
@ -1,567 +0,0 @@
|
|||
package io.heckel.ntfy.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.lifecycle.*
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.validUrl
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class Repository(private val sharedPrefs: SharedPreferences, private val database: Database) {
|
||||
private val subscriptionDao = database.subscriptionDao()
|
||||
private val notificationDao = database.notificationDao()
|
||||
private val userDao = database.userDao()
|
||||
|
||||
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
|
||||
private val connectionStatesLiveData = MutableLiveData(connectionStates)
|
||||
|
||||
// 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<List<Subscription>> {
|
||||
return subscriptionDao
|
||||
.listFlow()
|
||||
.asLiveData()
|
||||
.combineWith(connectionStatesLiveData) { subscriptionsWithMetadata, _ ->
|
||||
toSubscriptionList(subscriptionsWithMetadata.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptionIdsWithInstantStatusLiveData(): LiveData<Set<Pair<Long, Boolean>>> {
|
||||
return subscriptionDao
|
||||
.listFlow()
|
||||
.asLiveData()
|
||||
.map { list -> list.map { Pair(it.id, it.instant) }.toSet() }
|
||||
}
|
||||
|
||||
suspend fun getSubscriptions(): List<Subscription> {
|
||||
return toSubscriptionList(subscriptionDao.list())
|
||||
}
|
||||
|
||||
suspend fun getSubscriptionIdsWithInstantStatus(): Set<Pair<Long, Boolean>> {
|
||||
return subscriptionDao
|
||||
.list()
|
||||
.map { Pair(it.id, it.instant) }.toSet()
|
||||
}
|
||||
|
||||
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<Notification> {
|
||||
return notificationDao.list()
|
||||
}
|
||||
|
||||
fun getDeletedNotificationsWithAttachments(): List<Notification> {
|
||||
return notificationDao.listDeletedWithAttachments()
|
||||
}
|
||||
|
||||
fun getActiveIconUris(): Set<String> {
|
||||
return notificationDao.listActiveIconUris().toSet()
|
||||
}
|
||||
|
||||
fun clearIconUri(uri: String) {
|
||||
notificationDao.clearIconUri(uri)
|
||||
}
|
||||
|
||||
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
|
||||
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<Notification>): List<Notification> {
|
||||
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
|
||||
}
|
||||
subscriptionDao.updateLastNotificationId(notification.subscriptionId, notification.id)
|
||||
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)
|
||||
}
|
||||
|
||||
suspend fun getUsers(): List<User> {
|
||||
return userDao.list()
|
||||
}
|
||||
|
||||
fun getUsersLiveData(): LiveData<List<User>> {
|
||||
return userDao.listFlow().asLiveData()
|
||||
}
|
||||
|
||||
suspend fun addUser(user: User) {
|
||||
userDao.insert(user)
|
||||
}
|
||||
|
||||
suspend fun updateUser(user: User) {
|
||||
userDao.update(user)
|
||||
}
|
||||
|
||||
suspend fun getUser(baseUrl: String): User? {
|
||||
return userDao.get(baseUrl)
|
||||
}
|
||||
|
||||
suspend fun deleteUser(baseUrl: String) {
|
||||
userDao.delete(baseUrl)
|
||||
}
|
||||
|
||||
fun getPollWorkerVersion(): Int {
|
||||
return sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0)
|
||||
}
|
||||
|
||||
fun setPollWorkerVersion(version: Int) {
|
||||
sharedPrefs.edit()
|
||||
.putInt(SHARED_PREFS_POLL_WORKER_VERSION, version)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun setMinPriority(minPriority: Int) {
|
||||
if (minPriority <= MIN_PRIORITY_ANY) {
|
||||
sharedPrefs.edit()
|
||||
.remove(SHARED_PREFS_MIN_PRIORITY)
|
||||
.apply()
|
||||
} else {
|
||||
sharedPrefs.edit()
|
||||
.putInt(SHARED_PREFS_MIN_PRIORITY, minPriority)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun setDarkMode(mode: Int) {
|
||||
if (mode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) {
|
||||
sharedPrefs.edit()
|
||||
.remove(SHARED_PREFS_DARK_MODE)
|
||||
.apply()
|
||||
} else {
|
||||
sharedPrefs.edit()
|
||||
.putInt(SHARED_PREFS_DARK_MODE, mode)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getDarkMode(): Int {
|
||||
return sharedPrefs.getInt(SHARED_PREFS_DARK_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
|
||||
fun setConnectionProtocol(connectionProtocol: String) {
|
||||
sharedPrefs.edit()
|
||||
.putString(SHARED_PREFS_CONNECTION_PROTOCOL, connectionProtocol)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getConnectionProtocol(): String {
|
||||
return sharedPrefs.getString(SHARED_PREFS_CONNECTION_PROTOCOL, null) ?: CONNECTION_PROTOCOL_JSONHTTP
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getWebSocketRemindTime(): Long {
|
||||
return sharedPrefs.getLong(SHARED_PREFS_WEBSOCKET_REMIND_TIME, WEBSOCKET_REMIND_TIME_ALWAYS)
|
||||
}
|
||||
|
||||
fun setWebSocketRemindTime(timeMillis: Long) {
|
||||
sharedPrefs.edit()
|
||||
.putLong(SHARED_PREFS_WEBSOCKET_REMIND_TIME, timeMillis)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getDefaultBaseUrl(): String? {
|
||||
return sharedPrefs.getString(SHARED_PREFS_DEFAULT_BASE_URL, null) ?:
|
||||
sharedPrefs.getString(SHARED_PREFS_UNIFIED_PUSH_BASE_URL, null) // Fall back to UP URL, removed when default is set!
|
||||
}
|
||||
|
||||
fun setDefaultBaseUrl(baseUrl: String) {
|
||||
if (baseUrl == "") {
|
||||
sharedPrefs
|
||||
.edit()
|
||||
.remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
|
||||
.remove(SHARED_PREFS_DEFAULT_BASE_URL)
|
||||
.apply()
|
||||
} else {
|
||||
sharedPrefs.edit()
|
||||
.remove(SHARED_PREFS_UNIFIED_PUSH_BASE_URL) // Remove legacy key
|
||||
.putString(SHARED_PREFS_DEFAULT_BASE_URL, baseUrl)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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)
|
||||
.apply()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getLastShareTopics(): List<String> {
|
||||
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"))
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> {
|
||||
return list.map { s ->
|
||||
val connectionState = connectionStates.getOrElse(s.id) { ConnectionState.NOT_APPLICABLE }
|
||||
Subscription(
|
||||
id = s.id,
|
||||
baseUrl = s.baseUrl,
|
||||
topic = s.topic,
|
||||
instant = s.instant,
|
||||
dedicatedChannels = s.dedicatedChannels,
|
||||
mutedUntil = s.mutedUntil,
|
||||
minPriority = s.minPriority,
|
||||
autoDelete = s.autoDelete,
|
||||
insistent = s.insistent,
|
||||
lastNotificationId = s.lastNotificationId,
|
||||
icon = s.icon,
|
||||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName,
|
||||
totalCount = s.totalCount,
|
||||
newCount = s.newCount,
|
||||
lastActive = s.lastActive,
|
||||
state = connectionState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSubscription(s: SubscriptionWithMetadata?): Subscription? {
|
||||
if (s == null) {
|
||||
return null
|
||||
}
|
||||
return Subscription(
|
||||
id = s.id,
|
||||
baseUrl = s.baseUrl,
|
||||
topic = s.topic,
|
||||
instant = s.instant,
|
||||
dedicatedChannels = s.dedicatedChannels,
|
||||
mutedUntil = s.mutedUntil,
|
||||
minPriority = s.minPriority,
|
||||
autoDelete = s.autoDelete,
|
||||
insistent = s.insistent,
|
||||
lastNotificationId = s.lastNotificationId,
|
||||
icon = s.icon,
|
||||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName,
|
||||
totalCount = s.totalCount,
|
||||
newCount = s.newCount,
|
||||
lastActive = s.lastActive,
|
||||
state = getState(s.id)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateState(subscriptionIds: Collection<Long>, newState: ConnectionState) {
|
||||
var changed = false
|
||||
subscriptionIds.forEach { subscriptionId ->
|
||||
val state = connectionStates.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
|
||||
if (state !== newState) {
|
||||
changed = true
|
||||
if (newState == ConnectionState.NOT_APPLICABLE) {
|
||||
connectionStates.remove(subscriptionId)
|
||||
} else {
|
||||
connectionStates[subscriptionId] = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
connectionStatesLiveData.postValue(connectionStates)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getState(subscriptionId: Long): ConnectionState {
|
||||
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SHARED_PREFS_ID = "MainPreferences"
|
||||
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
|
||||
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_CONNECTION_PROTOCOL = "ConnectionProtocol"
|
||||
const val SHARED_PREFS_DARK_MODE = "DarkMode"
|
||||
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_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime"
|
||||
const val SHARED_PREFS_WEBSOCKET_REMIND_TIME = "JsonStreamRemindTime" // "Use WebSocket" banner (used to be JSON stream deprecation banner)
|
||||
const val SHARED_PREFS_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 CONNECTION_PROTOCOL_JSONHTTP = "jsonhttp"
|
||||
const val CONNECTION_PROTOCOL_WS = "ws"
|
||||
|
||||
const val BATTERY_OPTIMIZATIONS_REMIND_TIME_ALWAYS = 1L
|
||||
const val BATTERY_OPTIMIZATIONS_REMIND_TIME_NEVER = Long.MAX_VALUE
|
||||
|
||||
const val WEBSOCKET_REMIND_TIME_ALWAYS = 1L
|
||||
const val WEBSOCKET_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 <T, K, R> LiveData<T>.combineWith(
|
||||
liveData: LiveData<K>,
|
||||
block: (T?, K?) -> R
|
||||
): LiveData<R> {
|
||||
val result = MediatorLiveData<R>()
|
||||
result.addSource(this) {
|
||||
result.value = block(this.value, liveData.value)
|
||||
}
|
||||
result.addSource(liveData) {
|
||||
result.value = block(this.value, liveData.value)
|
||||
}
|
||||
return result
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.os.Build
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.User
|
||||
import io.heckel.ntfy.util.*
|
||||
import okhttp3.*
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets.UTF_8
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
class ApiService {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val publishClient = OkHttpClient.Builder()
|
||||
.callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val subscriberClient = OkHttpClient.Builder()
|
||||
.readTimeout(77, TimeUnit.SECONDS) // Assuming that keepalive messages are more frequent than this
|
||||
.build()
|
||||
private val parser = NotificationParser()
|
||||
|
||||
fun publish(
|
||||
baseUrl: String,
|
||||
topic: String,
|
||||
user: User? = null,
|
||||
message: String,
|
||||
title: String = "",
|
||||
priority: Int = PRIORITY_DEFAULT,
|
||||
tags: List<String> = emptyList(),
|
||||
delay: String = "",
|
||||
body: RequestBody? = null,
|
||||
filename: String = ""
|
||||
) {
|
||||
val url = topicUrl(baseUrl, topic)
|
||||
val query = mutableListOf<String>()
|
||||
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 (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 = requestBuilder(urlWithQuery, user)
|
||||
.put(body ?: message.toRequestBody())
|
||||
.build()
|
||||
Log.d(TAG, "Publishing to $request")
|
||||
publishClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
throw UnauthorizedException(user)
|
||||
} else if (response.code == 413) {
|
||||
throw EntityTooLargeException()
|
||||
} else if (!response.isSuccessful) {
|
||||
throw Exception("Unexpected response ${response.code} when publishing to $url")
|
||||
}
|
||||
Log.d(TAG, "Successfully published to $url")
|
||||
}
|
||||
}
|
||||
|
||||
fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List<Notification> {
|
||||
val sinceVal = since ?: "all"
|
||||
val url = topicUrlJsonPoll(baseUrl, topic, sinceVal)
|
||||
Log.d(TAG, "Polling topic $url")
|
||||
|
||||
val request = requestBuilder(url, user).build()
|
||||
client.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.isNullOrEmpty()) 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
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(
|
||||
baseUrl: String,
|
||||
topics: String,
|
||||
unifiedPushTopics: String,
|
||||
since: String?,
|
||||
user: User?,
|
||||
notify: (topic: String, Notification) -> Unit,
|
||||
fail: (Exception) -> Unit
|
||||
): Call {
|
||||
val sinceVal = since ?: "all"
|
||||
val url = topicUrlJson(baseUrl, topics, sinceVal)
|
||||
Log.d(TAG, "Opening subscription connection to $url")
|
||||
val request = requestBuilder(url, user, unifiedPushTopics).build()
|
||||
val call = subscriberClient.newCall(request)
|
||||
call.enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Unexpected response ${response.code} when subscribing to topic $url")
|
||||
}
|
||||
val source = response.body?.source() ?: throw Exception("Unexpected response for $url: body is empty")
|
||||
while (!source.exhausted()) {
|
||||
val line = source.readUtf8Line() ?: throw Exception("Unexpected response for $url: line is null")
|
||||
val notification = parser.parseWithTopic(line, notificationId = Random.nextInt(), subscriptionId = 0) // subscriptionId to be set downstream
|
||||
if (notification != null) {
|
||||
notify(notification.topic, notification.notification)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection to $url failed (1): ${e.message}", e)
|
||||
fail(e)
|
||||
}
|
||||
}
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Connection to $url failed (2): ${e.message}", e)
|
||||
fail(e)
|
||||
}
|
||||
})
|
||||
return call
|
||||
}
|
||||
|
||||
fun checkAuth(baseUrl: String, topic: String, user: User?): Boolean {
|
||||
if (user == null) {
|
||||
Log.d(TAG, "Checking anonymous read against ${topicUrl(baseUrl, topic)}")
|
||||
} else {
|
||||
Log.d(TAG, "Checking read access for user ${user.username} against ${topicUrl(baseUrl, topic)}")
|
||||
}
|
||||
val url = topicUrlAuth(baseUrl, topic)
|
||||
val request = requestBuilder(url, user).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
return true
|
||||
} else if (user == null && response.code == 404) {
|
||||
return true // Special case: Anonymous login to old servers return 404 since /<topic>/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(val user: User?) : Exception()
|
||||
class EntityTooLargeException : Exception()
|
||||
|
||||
companion object {
|
||||
val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})"
|
||||
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"
|
||||
|
||||
fun requestBuilder(url: String, user: User?, unifiedPushTopics: String? = null): Request.Builder {
|
||||
val builder = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("User-Agent", USER_AGENT)
|
||||
if (user != null) {
|
||||
builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8))
|
||||
}
|
||||
if (unifiedPushTopics != null) {
|
||||
builder.addHeader("Rate-Topics", unifiedPushTopics)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,130 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.Action
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.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("message_encoding", notification.encoding)
|
||||
intent.putExtra("tags", notification.tags)
|
||||
intent.putExtra("tags_map", joinTagsMap(splitTags(notification.tags)))
|
||||
intent.putExtra("priority", notification.priority)
|
||||
intent.putExtra("click", notification.click)
|
||||
intent.putExtra("muted", muted)
|
||||
intent.putExtra("muted_str", muted.toString())
|
||||
intent.putExtra("attachment_name", notification.attachment?.name ?: "")
|
||||
intent.putExtra("attachment_type", notification.attachment?.type ?: "")
|
||||
intent.putExtra("attachment_size", notification.attachment?.size ?: 0L)
|
||||
intent.putExtra("attachment_expires", notification.attachment?.expires ?: 0L)
|
||||
intent.putExtra("attachment_url", notification.attachment?.url ?: "")
|
||||
|
||||
Log.d(TAG, "Sending message intent broadcast: ${intent.action} with extras ${intent.extras}")
|
||||
ctx.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
fun sendUserAction(action: Action) {
|
||||
val intent = Intent()
|
||||
intent.action = action.intent ?: USER_ACTION_ACTION
|
||||
action.extras?.forEach { (key, value) ->
|
||||
intent.putExtra(key, value)
|
||||
}
|
||||
Log.d(TAG, "Sending user action 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()
|
||||
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) {
|
||||
val repository = Repository.getInstance(ctx)
|
||||
val user = repository.getUser(baseUrl) // May be null
|
||||
try {
|
||||
Log.d(TAG, "Publishing message $intent")
|
||||
api.publish(
|
||||
baseUrl = baseUrl,
|
||||
topic = topic,
|
||||
user = user,
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -1,218 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.ensureSafeNewFile
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DownloadAttachmentWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val notifier = NotificationService(context)
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var subscription: Subscription
|
||||
private lateinit var notification: Notification
|
||||
private lateinit var attachment: Attachment
|
||||
private var uri: Uri? = null
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (context.applicationContext !is Application) return Result.failure()
|
||||
val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure()
|
||||
val userAction = inputData.getBoolean(INPUT_DATA_USER_ACTION, false)
|
||||
val app = context.applicationContext as Application
|
||||
repository = app.repository
|
||||
notification = repository.getNotification(notificationId) ?: return Result.failure()
|
||||
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
|
||||
attachment = notification.attachment ?: return Result.failure()
|
||||
try {
|
||||
downloadAttachment(userAction)
|
||||
} catch (e: Exception) {
|
||||
failed(e)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "Attachment download was canceled")
|
||||
maybeDeleteFile()
|
||||
}
|
||||
|
||||
private fun downloadAttachment(userAction: Boolean) {
|
||||
Log.d(TAG, "Downloading attachment from ${attachment.url}")
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(attachment.url)
|
||||
.addHeader("User-Agent", ApiService.USER_AGENT)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
Log.d(TAG, "Download: headers received: $response")
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
throw Exception("Unexpected response: ${response.code}")
|
||||
}
|
||||
save(updateAttachmentFromResponse(response))
|
||||
if (!userAction && shouldAbortDownload()) {
|
||||
Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting")
|
||||
return
|
||||
}
|
||||
val resolver = applicationContext.contentResolver
|
||||
val uri = createUri(notification)
|
||||
this.uri = uri // Required for cleanup in onStopped()
|
||||
|
||||
Log.d(TAG, "Starting download to content URI: $uri")
|
||||
var bytesCopied: Long = 0
|
||||
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
|
||||
val downloadLimit = getDownloadLimit(userAction)
|
||||
outFile.use { fileOut ->
|
||||
val fileIn = response.body!!.byteStream()
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var bytes = fileIn.read(buffer)
|
||||
var lastProgress = 0L
|
||||
while (bytes >= 0) {
|
||||
if (System.currentTimeMillis() - lastProgress > NOTIFICATION_UPDATE_INTERVAL_MILLIS) {
|
||||
if (isStopped) { // Canceled by user
|
||||
save(attachment.copy(progress = ATTACHMENT_PROGRESS_NONE))
|
||||
return // File will be deleted in onStopped()
|
||||
}
|
||||
val progress = if (attachment.size != null && attachment.size!! > 0) {
|
||||
(bytesCopied.toFloat()/attachment.size!!.toFloat()*100).toInt()
|
||||
} else {
|
||||
ATTACHMENT_PROGRESS_INDETERMINATE
|
||||
}
|
||||
save(attachment.copy(progress = progress))
|
||||
lastProgress = System.currentTimeMillis()
|
||||
}
|
||||
if (downloadLimit != null && bytesCopied > downloadLimit) {
|
||||
throw Exception("Attachment is longer than max download size.")
|
||||
}
|
||||
fileOut.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
bytes = fileIn.read(buffer)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Attachment download: successful response, proceeding with download")
|
||||
save(attachment.copy(
|
||||
size = bytesCopied,
|
||||
contentUri = uri.toString(),
|
||||
progress = ATTACHMENT_PROGRESS_DONE
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failed(e)
|
||||
|
||||
// Toast in a Worker: https://stackoverflow.com/a/56428145/1440785
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
handler.postDelayed({
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_download_failed, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAttachmentFromResponse(response: Response): Attachment {
|
||||
val size = if (response.headers["Content-Length"]?.toLongOrNull() != null) {
|
||||
response.headers["Content-Length"]?.toLong()
|
||||
} else {
|
||||
attachment.size // May be null!
|
||||
}
|
||||
val mimeType = if (response.headers["Content-Type"] != null) {
|
||||
response.headers["Content-Type"]
|
||||
} else {
|
||||
val ext = MimeTypeMap.getFileExtensionFromUrl(attachment.url)
|
||||
if (ext != null) {
|
||||
val typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
|
||||
typeFromExt ?: attachment.type // May be null!
|
||||
} else {
|
||||
attachment.type // May be null!
|
||||
}
|
||||
}
|
||||
return attachment.copy(
|
||||
size = size,
|
||||
type = mimeType
|
||||
)
|
||||
}
|
||||
|
||||
private fun failed(e: Exception) {
|
||||
Log.w(TAG, "Attachment download failed", e)
|
||||
save(attachment.copy(progress = ATTACHMENT_PROGRESS_FAILED))
|
||||
maybeDeleteFile()
|
||||
}
|
||||
|
||||
private fun maybeDeleteFile() {
|
||||
val uriCopy = uri
|
||||
if (uriCopy != null) {
|
||||
Log.d(TAG, "Deleting leftover attachment $uriCopy")
|
||||
val resolver = applicationContext.contentResolver
|
||||
resolver.delete(uriCopy, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(newAttachment: Attachment) {
|
||||
Log.d(TAG, "Updating attachment: $newAttachment")
|
||||
attachment = newAttachment
|
||||
notification = notification.copy(attachment = newAttachment)
|
||||
notifier.update(subscription, notification)
|
||||
repository.updateNotification(notification)
|
||||
}
|
||||
|
||||
private fun shouldAbortDownload(): Boolean {
|
||||
val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()
|
||||
when (maxAutoDownloadSize) {
|
||||
Repository.AUTO_DOWNLOAD_NEVER -> return true
|
||||
Repository.AUTO_DOWNLOAD_ALWAYS -> return false
|
||||
else -> {
|
||||
val size = attachment.size ?: return false // Don't abort if size unknown
|
||||
return size > maxAutoDownloadSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDownloadLimit(userAction: Boolean): Long? {
|
||||
return if (userAction || repository.getAutoDownloadMaxSize() == Repository.AUTO_DOWNLOAD_ALWAYS) {
|
||||
null
|
||||
} else {
|
||||
repository.getAutoDownloadMaxSize()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUri(notification: Notification): Uri {
|
||||
val attachmentDir = File(context.cacheDir, ATTACHMENT_CACHE_DIR)
|
||||
if (!attachmentDir.exists() && !attachmentDir.mkdirs()) {
|
||||
throw Exception("Cannot create cache directory for attachments: $attachmentDir")
|
||||
}
|
||||
val file = ensureSafeNewFile(attachmentDir, notification.id)
|
||||
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INPUT_DATA_ID = "id"
|
||||
const val INPUT_DATA_USER_ACTION = "userAction"
|
||||
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
|
||||
|
||||
private const val TAG = "NtfyAttachDownload"
|
||||
private const val ATTACHMENT_CACHE_DIR = "attachments"
|
||||
private const val BUFFER_SIZE = 8 * 1024
|
||||
private const val NOTIFICATION_UPDATE_INTERVAL_MILLIS = 800
|
||||
}
|
||||
}
|
|
@ -1,168 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.sha256
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DownloadIconWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val notifier = NotificationService(context)
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var subscription: Subscription
|
||||
private lateinit var notification: Notification
|
||||
private lateinit var icon: Icon
|
||||
private var uri: Uri? = null
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (context.applicationContext !is Application) return Result.failure()
|
||||
val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure()
|
||||
val app = context.applicationContext as Application
|
||||
repository = app.repository
|
||||
notification = repository.getNotification(notificationId) ?: return Result.failure()
|
||||
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
|
||||
icon = notification.icon ?: return Result.failure()
|
||||
try {
|
||||
val iconFile = createIconFile(icon)
|
||||
val yesterdayTimestamp = Date().time - MAX_CACHE_MILLIS
|
||||
if (!iconFile.exists() || iconFile.lastModified() < yesterdayTimestamp) {
|
||||
downloadIcon(iconFile)
|
||||
} else {
|
||||
Log.d(TAG, "Loading icon from cache: $iconFile")
|
||||
val iconUri = createIconUri(iconFile)
|
||||
this.uri = iconUri // Required for cleanup in onStopped()
|
||||
save(icon.copy(contentUri = iconUri.toString()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failed(e)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
override fun onStopped() {
|
||||
Log.d(TAG, "Icon download was canceled")
|
||||
maybeDeleteFile()
|
||||
}
|
||||
|
||||
private fun downloadIcon(iconFile: File) {
|
||||
Log.d(TAG, "Downloading icon from ${icon.url}")
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(icon.url)
|
||||
.addHeader("User-Agent", ApiService.USER_AGENT)
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
Log.d(TAG, "Headers received: $response, Content-Length: ${response.headers["Content-Length"]}")
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
throw Exception("Unexpected response: ${response.code}")
|
||||
} else if (shouldAbortDownload(response)) {
|
||||
Log.d(TAG, "Aborting download: Content-Length is larger than auto-download setting")
|
||||
return
|
||||
}
|
||||
val resolver = applicationContext.contentResolver
|
||||
val uri = createIconUri(iconFile)
|
||||
this.uri = uri // Required for cleanup in onStopped()
|
||||
|
||||
Log.d(TAG, "Starting download to content URI: $uri")
|
||||
var bytesCopied: Long = 0
|
||||
val outFile = resolver.openOutputStream(uri) ?: throw Exception("Cannot open output stream")
|
||||
val downloadLimit = getDownloadLimit()
|
||||
outFile.use { fileOut ->
|
||||
val fileIn = response.body!!.byteStream()
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var bytes = fileIn.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
if (bytesCopied > downloadLimit) {
|
||||
throw Exception("Icon is longer than max download size.")
|
||||
}
|
||||
fileOut.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
bytes = fileIn.read(buffer)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Icon download: successful response, proceeding with download")
|
||||
save(icon.copy(contentUri = uri.toString()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failed(e: Exception) {
|
||||
Log.w(TAG, "Icon download failed", e)
|
||||
maybeDeleteFile()
|
||||
}
|
||||
|
||||
private fun maybeDeleteFile() {
|
||||
val uriCopy = uri
|
||||
if (uriCopy != null) {
|
||||
Log.d(TAG, "Deleting leftover icon $uriCopy")
|
||||
val resolver = applicationContext.contentResolver
|
||||
resolver.delete(uriCopy, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(newIcon: Icon) {
|
||||
Log.d(TAG, "Updating icon: $newIcon")
|
||||
icon = newIcon
|
||||
notification = notification.copy(icon = newIcon)
|
||||
notifier.update(subscription, notification)
|
||||
repository.updateNotification(notification)
|
||||
}
|
||||
|
||||
private fun shouldAbortDownload(response: Response): Boolean {
|
||||
val maxAutoDownloadSize = getDownloadLimit()
|
||||
val size = response.headers["Content-Length"]?.toLongOrNull() ?: return false // Don't abort here if size unknown
|
||||
return size > maxAutoDownloadSize
|
||||
}
|
||||
|
||||
private fun getDownloadLimit(): Long {
|
||||
return if (repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_NEVER && repository.getAutoDownloadMaxSize() != Repository.AUTO_DOWNLOAD_ALWAYS) {
|
||||
Math.min(repository.getAutoDownloadMaxSize(), MAX_ICON_DOWNLOAD_BYTES)
|
||||
} else {
|
||||
DEFAULT_MAX_ICON_DOWNLOAD_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
private fun createIconFile(icon: Icon): File {
|
||||
val iconDir = File(context.cacheDir, ICON_CACHE_DIR)
|
||||
if (!iconDir.exists() && !iconDir.mkdirs()) {
|
||||
throw Exception("Cannot create cache directory for icons: $iconDir")
|
||||
}
|
||||
val hash = icon.url.sha256()
|
||||
return File(iconDir, hash)
|
||||
}
|
||||
|
||||
private fun createIconUri(iconFile: File): Uri {
|
||||
return FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, iconFile)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INPUT_DATA_ID = "id"
|
||||
const val FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" // See AndroidManifest.xml
|
||||
const val DEFAULT_MAX_ICON_DOWNLOAD_BYTES = 307_200L // 300 KB
|
||||
const val MAX_ICON_DOWNLOAD_BYTES = 5_242_880L // 5 MB
|
||||
const val MAX_CACHE_MILLIS = 1000*60*60*24 // 24 hours
|
||||
const val ICON_CACHE_DIR = "icons"
|
||||
|
||||
private const val TAG = "NtfyIconDownload"
|
||||
private const val BUFFER_SIZE = 8 * 1024
|
||||
}
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import io.heckel.ntfy.util.Log
|
||||
|
||||
/**
|
||||
* Download attachment in the background via WorkManager
|
||||
*
|
||||
* The indirection via WorkManager is required since this code may be executed
|
||||
* in a doze state and Internet may not be available. It's also best practice, apparently.
|
||||
*/
|
||||
object DownloadManager {
|
||||
private const val TAG = "NtfyDownloadManager"
|
||||
private const val DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
|
||||
private const val DOWNLOAD_WORK_ICON_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_ICON_"
|
||||
private const val DOWNLOAD_WORK_BOTH_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_BOTH_"
|
||||
|
||||
fun enqueue(context: Context, notificationId: String, userAction: Boolean, type: DownloadType) {
|
||||
when (type) {
|
||||
DownloadType.ATTACHMENT -> enqueueAttachment(context, notificationId, userAction)
|
||||
DownloadType.ICON -> enqueueIcon(context, notificationId)
|
||||
DownloadType.BOTH -> enqueueAttachmentAndIcon(context, notificationId, userAction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueAttachment(context: Context, notificationId: String, userAction: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val workName = DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX + notificationId
|
||||
Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName")
|
||||
val workRequest = OneTimeWorkRequest.Builder(DownloadAttachmentWorker::class.java)
|
||||
.setInputData(workDataOf(
|
||||
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId,
|
||||
DownloadAttachmentWorker.INPUT_DATA_USER_ACTION to userAction
|
||||
))
|
||||
.build()
|
||||
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
|
||||
}
|
||||
|
||||
private fun enqueueIcon(context: Context, notificationId: String) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val workName = DOWNLOAD_WORK_ICON_NAME_PREFIX + notificationId
|
||||
Log.d(TAG,"Enqueuing work to download icon for notification $notificationId, work: $workName")
|
||||
val workRequest = OneTimeWorkRequest.Builder(DownloadIconWorker::class.java)
|
||||
.setInputData(workDataOf(
|
||||
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId
|
||||
))
|
||||
.build()
|
||||
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
|
||||
}
|
||||
|
||||
private fun enqueueAttachmentAndIcon(context: Context, notificationId: String, userAction: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val workName = DOWNLOAD_WORK_BOTH_NAME_PREFIX + notificationId
|
||||
val attachmentWorkRequest = OneTimeWorkRequest.Builder(DownloadAttachmentWorker::class.java)
|
||||
.setInputData(workDataOf(
|
||||
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId,
|
||||
DownloadAttachmentWorker.INPUT_DATA_USER_ACTION to userAction
|
||||
))
|
||||
.build()
|
||||
val iconWorkRequest = OneTimeWorkRequest.Builder(DownloadIconWorker::class.java)
|
||||
.setInputData(workDataOf(
|
||||
DownloadAttachmentWorker.INPUT_DATA_ID to notificationId
|
||||
))
|
||||
.build()
|
||||
Log.d(TAG,"Enqueuing work to download both attachment and icon for notification $notificationId, work: $workName")
|
||||
workManager.beginUniqueWork(workName, ExistingWorkPolicy.KEEP, attachmentWorkRequest)
|
||||
.then(iconWorkRequest)
|
||||
.enqueue()
|
||||
}
|
||||
|
||||
fun cancel(context: Context, id: String) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val workName = DOWNLOAD_WORK_ATTACHMENT_NAME_PREFIX + id
|
||||
Log.d(TAG, "Cancelling attachment download for notification $id, work: $workName")
|
||||
workManager.cancelUniqueWork(workName)
|
||||
}
|
||||
}
|
||||
|
||||
enum class DownloadType {
|
||||
ATTACHMENT,
|
||||
ICON,
|
||||
BOTH
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
/* 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<String>?,
|
||||
val click: String?,
|
||||
val icon: String?,
|
||||
val actions: List<MessageAction>?,
|
||||
val title: String?,
|
||||
val message: 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<String,String>?, // used in "http" action
|
||||
val body: String?, // used in "http" action
|
||||
val intent: String?, // used in "broadcast" action
|
||||
val extras: Map<String,String>?, // used in "broadcast" action
|
||||
)
|
||||
|
||||
const val MESSAGE_ENCODING_BASE64 = "base64"
|
|
@ -1,112 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.up.Distributor
|
||||
import io.heckel.ntfy.util.decodeBytesMessage
|
||||
import io.heckel.ntfy.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.createDefaultNotificationChannels()
|
||||
}
|
||||
|
||||
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)
|
||||
val downloadAttachment = shouldDownloadAttachment(notification)
|
||||
val downloadIcon = shouldDownloadIcon(notification)
|
||||
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))
|
||||
}
|
||||
}
|
||||
if (downloadAttachment && downloadIcon) {
|
||||
DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.BOTH)
|
||||
} else if (downloadAttachment) {
|
||||
DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.ATTACHMENT)
|
||||
} else if (downloadIcon) {
|
||||
DownloadManager.enqueue(context, notification.id, userAction = false, type = DownloadType.ICON)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldDownloadAttachment(notification: Notification): Boolean {
|
||||
if (notification.attachment == null) {
|
||||
return false
|
||||
}
|
||||
val attachment = notification.attachment
|
||||
if (attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000) {
|
||||
Log.d(TAG, "Attachment already expired at ${attachment.expires}, not downloading")
|
||||
return false
|
||||
}
|
||||
when (val maxAutoDownloadSize = repository.getAutoDownloadMaxSize()) {
|
||||
Repository.AUTO_DOWNLOAD_ALWAYS -> return true
|
||||
Repository.AUTO_DOWNLOAD_NEVER -> return false
|
||||
else -> {
|
||||
if (attachment.size == null) {
|
||||
return true // DownloadWorker will bail out if attachment is too large!
|
||||
}
|
||||
return attachment.size <= maxAutoDownloadSize
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun shouldDownloadIcon(notification: Notification): Boolean {
|
||||
return notification.icon != null
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -1,99 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import io.heckel.ntfy.db.Action
|
||||
import io.heckel.ntfy.db.Attachment
|
||||
import io.heckel.ntfy.db.Icon
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.util.joinTags
|
||||
import io.heckel.ntfy.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 attachment = if (message.attachment?.url != null) {
|
||||
Attachment(
|
||||
name = message.attachment.name,
|
||||
type = message.attachment.type,
|
||||
size = message.attachment.size,
|
||||
expires = message.attachment.expires,
|
||||
url = message.attachment.url,
|
||||
)
|
||||
} else null
|
||||
val actions = if (message.actions != null) {
|
||||
message.actions.map { a ->
|
||||
Action(
|
||||
id = a.id,
|
||||
action = a.action,
|
||||
label = a.label,
|
||||
clear = a.clear,
|
||||
url = a.url,
|
||||
method = a.method,
|
||||
headers = a.headers,
|
||||
body = a.body,
|
||||
intent = a.intent,
|
||||
extras = a.extras,
|
||||
progress = null,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} else null
|
||||
val icon: Icon? = if (message.icon != null && message.icon != "") Icon(url = message.icon) else null
|
||||
val notification = Notification(
|
||||
id = message.id,
|
||||
subscriptionId = subscriptionId,
|
||||
timestamp = message.time,
|
||||
title = message.title ?: "",
|
||||
message = message.message,
|
||||
encoding = message.encoding ?: "",
|
||||
priority = toPriority(message.priority),
|
||||
tags = joinTags(message.tags),
|
||||
click = message.click ?: "",
|
||||
icon = icon,
|
||||
actions = actions,
|
||||
attachment = attachment,
|
||||
notificationId = notificationId,
|
||||
deleted = false
|
||||
)
|
||||
return NotificationWithTopic(message.topic, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON array to Action list. The indirection via MessageAction is probably
|
||||
* not necessary, but for "good form".
|
||||
*/
|
||||
fun parseActions(s: String?): List<Action>? {
|
||||
val listType: Type = object : TypeToken<List<MessageAction>?>() {}.type
|
||||
val messageActions: List<MessageAction>? = gson.fromJson(s, listType)
|
||||
return messageActions?.map { a ->
|
||||
Action(
|
||||
id = a.id,
|
||||
action = a.action,
|
||||
label = a.label,
|
||||
clear = a.clear,
|
||||
url = a.url,
|
||||
method = a.method,
|
||||
headers = a.headers,
|
||||
body = a.body,
|
||||
intent = a.intent,
|
||||
extras = a.extras,
|
||||
progress = null,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class NotificationWithTopic(val topic: String, val notification: Notification)
|
||||
}
|
|
@ -1,543 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.app.*
|
||||
import android.content.ActivityNotFoundException
|
||||
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 android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.ui.Colors
|
||||
import io.heckel.ntfy.ui.DetailActivity
|
||||
import io.heckel.ntfy.ui.MainActivity
|
||||
import io.heckel.ntfy.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)
|
||||
|
||||
fun display(subscription: Subscription, notification: Notification) {
|
||||
Log.d(TAG, "Displaying notification $notification")
|
||||
displayInternal(subscription, notification)
|
||||
}
|
||||
|
||||
fun update(subscription: Subscription, notification: Notification) {
|
||||
val active = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
notificationManager.activeNotifications.find { it.id == notification.notificationId } != null
|
||||
} else {
|
||||
true
|
||||
}
|
||||
if (active) {
|
||||
Log.d(TAG, "Updating notification $notification")
|
||||
displayInternal(subscription, notification, update = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(notification: Notification) {
|
||||
if (notification.notificationId != 0) {
|
||||
Log.d(TAG, "Cancelling notification ${notification.id}: ${decodeMessage(notification)}")
|
||||
notificationManager.cancel(notification.notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(notificationId: Int) {
|
||||
if (notificationId != 0) {
|
||||
Log.d(TAG, "Cancelling notification $notificationId")
|
||||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun createDefaultNotificationChannels() {
|
||||
maybeCreateNotificationGroup(DEFAULT_GROUP, context.getString(R.string.channel_notifications_group_default_name))
|
||||
ALL_PRIORITIES.forEach { priority -> maybeCreateNotificationChannel(DEFAULT_GROUP, priority) }
|
||||
}
|
||||
|
||||
fun createSubscriptionNotificationChannels(subscription: Subscription) {
|
||||
val groupId = subscriptionGroupId(subscription)
|
||||
maybeCreateNotificationGroup(groupId, subscriptionGroupName(subscription))
|
||||
ALL_PRIORITIES.forEach { priority -> maybeCreateNotificationChannel(groupId, priority) }
|
||||
}
|
||||
|
||||
fun deleteSubscriptionNotificationChannels(subscription: Subscription) {
|
||||
val groupId = subscriptionGroupId(subscription)
|
||||
ALL_PRIORITIES.forEach { priority -> maybeDeleteNotificationChannel(groupId, priority) }
|
||||
maybeDeleteNotificationGroup(groupId)
|
||||
}
|
||||
|
||||
fun channelsSupported(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
}
|
||||
|
||||
private fun subscriptionGroupId(subscription: Subscription): String {
|
||||
return SUBSCRIPTION_GROUP_PREFIX + subscription.id.toString()
|
||||
}
|
||||
|
||||
private fun subscriptionGroupName(subscription: Subscription): String {
|
||||
return subscription.displayName ?: subscriptionTopicShortUrl(subscription)
|
||||
}
|
||||
|
||||
private fun displayInternal(subscription: Subscription, notification: Notification, update: Boolean = false) {
|
||||
val title = formatTitle(subscription, notification)
|
||||
val groupId = if (subscription.dedicatedChannels) subscriptionGroupId(subscription) else 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(ContextCompat.getColor(context, Colors.notificationIcon(context)))
|
||||
.setContentTitle(title)
|
||||
.setOnlyAlertOnce(true) // Do not vibrate or play sound if already showing (updates!)
|
||||
.setAutoCancel(true) // Cancel when notification is clicked
|
||||
setStyleAndText(builder, subscription, notification) // Preview picture or big text style
|
||||
setClickAction(builder, subscription, notification)
|
||||
maybeSetDeleteIntent(builder, insistent)
|
||||
maybeSetSound(builder, insistent, update)
|
||||
maybeSetProgress(builder, notification)
|
||||
maybeAddOpenAction(builder, notification)
|
||||
maybeAddBrowseAction(builder, notification)
|
||||
maybeAddDownloadAction(builder, notification)
|
||||
maybeAddCancelAction(builder, notification)
|
||||
maybeAddUserActions(builder, notification)
|
||||
|
||||
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) {
|
||||
// Note that the sound setting is ignored in Android => O (26) in favor of notification channels
|
||||
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, subscription: Subscription, notification: Notification) {
|
||||
val contentUri = notification.attachment?.contentUri
|
||||
val isSupportedImage = supportedImage(notification.attachment?.type)
|
||||
val subscriptionIcon = if (subscription.icon != null) subscription.icon.readBitmapFromUriOrNull(context) else null
|
||||
val notificationIcon = if (notification.icon != null) notification.icon.contentUri?.readBitmapFromUriOrNull(context) else null
|
||||
val largeIcon = notificationIcon ?: subscriptionIcon
|
||||
if (contentUri != null && isSupportedImage) {
|
||||
try {
|
||||
val attachmentBitmap = contentUri.readBitmapFromUri(context)
|
||||
builder
|
||||
.setContentText(maybeAppendActionErrors(formatMessage(notification), notification))
|
||||
.setLargeIcon(attachmentBitmap)
|
||||
.setStyle(NotificationCompat.BigPictureStyle()
|
||||
.bigPicture(attachmentBitmap)
|
||||
.bigLargeIcon(largeIcon)) // May be null
|
||||
} catch (_: Exception) {
|
||||
val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
|
||||
builder
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
}
|
||||
} else {
|
||||
val message = maybeAppendActionErrors(formatMessageMaybeWithAttachmentInfos(notification), notification)
|
||||
builder
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setLargeIcon(largeIcon) // May be null
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMessageMaybeWithAttachmentInfos(notification: Notification): String {
|
||||
val message = formatMessage(notification)
|
||||
val attachment = notification.attachment ?: return message
|
||||
val attachmentInfos = if (attachment.size != null) {
|
||||
"${attachment.name}, ${formatBytes(attachment.size)}"
|
||||
} else {
|
||||
attachment.name
|
||||
}
|
||||
if (attachment.progress in 0..99) {
|
||||
return context.getString(R.string.notification_popup_file_downloading, attachmentInfos, attachment.progress, message)
|
||||
}
|
||||
if (attachment.progress == ATTACHMENT_PROGRESS_DONE) {
|
||||
return context.getString(R.string.notification_popup_file_download_successful, message, attachmentInfos)
|
||||
}
|
||||
if (attachment.progress == ATTACHMENT_PROGRESS_FAILED) {
|
||||
return context.getString(R.string.notification_popup_file_download_failed, message, attachmentInfos)
|
||||
}
|
||||
return context.getString(R.string.notification_popup_file, message, attachmentInfos)
|
||||
}
|
||||
|
||||
private fun setClickAction(builder: NotificationCompat.Builder, subscription: Subscription, notification: Notification) {
|
||||
if (notification.click == "") {
|
||||
builder.setContentIntent(detailActivityIntent(subscription))
|
||||
} else {
|
||||
try {
|
||||
val uri = Uri.parse(notification.click)
|
||||
val viewIntent = PendingIntent.getActivity(context, Random().nextInt(), Intent(Intent.ACTION_VIEW, uri), PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.setContentIntent(viewIntent)
|
||||
} catch (e: Exception) {
|
||||
builder.setContentIntent(detailActivityIntent(subscription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeSetProgress(builder: NotificationCompat.Builder, notification: Notification) {
|
||||
val progress = notification.attachment?.progress
|
||||
if (progress in 0..99) {
|
||||
builder.setProgress(100, progress!!, false)
|
||||
} else {
|
||||
builder.setProgress(0, 0, false) // Remove progress bar
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddOpenAction(builder: NotificationCompat.Builder, notification: Notification) {
|
||||
if (!canOpenAttachment(notification.attachment)) {
|
||||
return
|
||||
}
|
||||
if (notification.attachment?.contentUri != null) {
|
||||
val contentUri = Uri.parse(notification.attachment.contentUri)
|
||||
val intent = Intent(Intent.ACTION_VIEW, contentUri).apply {
|
||||
setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) {
|
||||
if (notification.attachment?.contentUri != null) {
|
||||
val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) {
|
||||
if (notification.attachment?.contentUri == null && listOf(ATTACHMENT_PROGRESS_NONE, ATTACHMENT_PROGRESS_FAILED).contains(notification.attachment?.progress)) {
|
||||
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
|
||||
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_START)
|
||||
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddCancelAction(builder: NotificationCompat.Builder, notification: Notification) {
|
||||
if (notification.attachment?.contentUri == null && notification.attachment?.progress in 0..99) {
|
||||
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
|
||||
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_CANCEL)
|
||||
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAddUserActions(builder: NotificationCompat.Builder, notification: Notification) {
|
||||
notification.actions?.forEach { action ->
|
||||
val actionType = action.action.lowercase(Locale.getDefault())
|
||||
if (actionType == ACTION_VIEW) {
|
||||
// Hack: Action "view" with "clear=true" is a special case, because it's apparently impossible to start a
|
||||
// URL activity from PendingIntent.getActivity() and also close the notification. To clear it, we
|
||||
// launch our own Activity (ViewActionWithClearActivity) which then calls the actual activity
|
||||
|
||||
if (action.clear == true) {
|
||||
addViewUserActionWithClear(builder, notification, action)
|
||||
} else {
|
||||
addViewUserActionWithoutClear(builder, action)
|
||||
}
|
||||
} else {
|
||||
addHttpOrBroadcastUserAction(builder, notification, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the URL and do NOT cancel the notification (clear=false). This uses a normal Intent with the given URL.
|
||||
* The other case is much more interesting.
|
||||
*/
|
||||
private fun addViewUserActionWithoutClear(builder: NotificationCompat.Builder, action: Action) {
|
||||
try {
|
||||
val url = action.url ?: return
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to add open user action", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HACK: Open the URL and CANCEL the notification (clear=true). This is a SPECIAL case with a horrible workaround.
|
||||
* We call our own activity ViewActionWithClearActivity and open the URL from there.
|
||||
*/
|
||||
private fun addViewUserActionWithClear(builder: NotificationCompat.Builder, notification: Notification, action: Action) {
|
||||
try {
|
||||
val url = action.url ?: return
|
||||
val intent = Intent(context, ViewActionWithClearActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
putExtra(VIEW_ACTION_EXTRA_URL, url)
|
||||
putExtra(VIEW_ACTION_EXTRA_NOTIFICATION_ID, notification.notificationId)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(context, Random().nextInt(), intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to add open user action", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addHttpOrBroadcastUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) {
|
||||
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
|
||||
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_USER_ACTION)
|
||||
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
|
||||
putExtra(BROADCAST_EXTRA_ACTION_ID, action.id)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, Random().nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val label = formatActionLabel(action)
|
||||
builder.addAction(NotificationCompat.Action.Builder(0, label, pendingIntent).build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives the broadcast from
|
||||
* - the "http" and "broadcast" action button (the "view" action is handled differently)
|
||||
* - the "download"/"cancel" action button
|
||||
*
|
||||
* Then queues a Worker via WorkManager to execute the action in the background
|
||||
*/
|
||||
class UserActionBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return
|
||||
val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return
|
||||
when (type) {
|
||||
BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true, DownloadType.ATTACHMENT)
|
||||
BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId)
|
||||
BROADCAST_TYPE_USER_ACTION -> {
|
||||
val actionId = intent.getStringExtra(BROADCAST_EXTRA_ACTION_ID) ?: return
|
||||
UserActionManager.enqueue(context, notificationId, actionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a broadcast when a notification is swiped away. This is currently
|
||||
* only called for notifications with an insistent sound.
|
||||
*/
|
||||
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(subscription))
|
||||
putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
|
||||
putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
|
||||
}
|
||||
return TaskStackBuilder.create(context).run {
|
||||
addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack
|
||||
getPendingIntent(Random().nextInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) // Get the PendingIntent containing the entire back stack
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeCreateNotificationChannel(group: String, priority: Int) {
|
||||
if (channelsSupported()) {
|
||||
// Note: To change a notification channel, you must delete the old one and create a new one!
|
||||
|
||||
val channelId = toChannelId(group, priority)
|
||||
val pause = 300L
|
||||
val channel = when (priority) {
|
||||
PRIORITY_MIN -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_min_name), NotificationManager.IMPORTANCE_MIN)
|
||||
PRIORITY_LOW -> NotificationChannel(channelId, context.getString(R.string.channel_notifications_low_name), NotificationManager.IMPORTANCE_LOW)
|
||||
PRIORITY_HIGH -> {
|
||||
val channel = NotificationChannel(channelId, context.getString(R.string.channel_notifications_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.channel_notifications_max_name), NotificationManager.IMPORTANCE_HIGH) // IMPORTANCE_MAX does not exist
|
||||
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.channel_notifications_default_name), NotificationManager.IMPORTANCE_DEFAULT)
|
||||
}
|
||||
channel.group = group
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeDeleteNotificationChannel(group: String, priority: Int) {
|
||||
if (channelsSupported()) {
|
||||
notificationManager.deleteNotificationChannel(toChannelId(group, priority))
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeCreateNotificationGroup(id: String, name: String) {
|
||||
if (channelsSupported()) {
|
||||
notificationManager.createNotificationChannelGroup(NotificationChannelGroup(id, name))
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeDeleteNotificationGroup(id: String) {
|
||||
if (channelsSupported()) {
|
||||
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 {
|
||||
return if (channelsSupported()) {
|
||||
val channelId = toChannelId(groupId, PRIORITY_MAX)
|
||||
val channel = notificationManager.getNotificationChannel(channelId)
|
||||
channel.sound
|
||||
} else {
|
||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity used to launch a URL.
|
||||
* .
|
||||
* Horrible hack: Action "view" with "clear=true" is a special case, because it's apparently impossible to start a
|
||||
* URL activity from PendingIntent.getActivity() and also close the notification. To clear it, we
|
||||
* launch this activity which then calls the actual activity.
|
||||
*/
|
||||
class ViewActionWithClearActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(TAG, "Created $this")
|
||||
val url = intent.getStringExtra(VIEW_ACTION_EXTRA_URL)
|
||||
val notificationId = intent.getIntExtra(VIEW_ACTION_EXTRA_NOTIFICATION_ID, 0)
|
||||
if (url == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Immediately start the actual activity
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to start activity from URL $url", e)
|
||||
val message = if (e is ActivityNotFoundException) url else e.message
|
||||
Toast
|
||||
.makeText(this, getString(R.string.detail_item_cannot_open_url, message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
|
||||
// Cancel notification
|
||||
val notifier = NotificationService(this)
|
||||
notifier.cancel(notificationId)
|
||||
|
||||
// Close this activity
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_VIEW = "view"
|
||||
const val ACTION_HTTP = "http"
|
||||
const val ACTION_BROADCAST = "broadcast"
|
||||
|
||||
const val BROADCAST_EXTRA_TYPE = "type"
|
||||
const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId"
|
||||
const val BROADCAST_EXTRA_ACTION_ID = "actionId"
|
||||
|
||||
const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START"
|
||||
const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL"
|
||||
const val BROADCAST_TYPE_USER_ACTION = "io.heckel.ntfy.USER_ACTION_RUN"
|
||||
|
||||
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"
|
||||
|
||||
private const val VIEW_ACTION_EXTRA_URL = "url"
|
||||
private const val VIEW_ACTION_EXTRA_NOTIFICATION_ID = "notificationId"
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import io.heckel.ntfy.util.Log
|
||||
|
||||
/**
|
||||
* Trigger user actions clicked from notification popups.
|
||||
*
|
||||
* The indirection via WorkManager is required since this code may be executed
|
||||
* in a doze state and Internet may not be available. It's also best practice, apparently.
|
||||
*/
|
||||
object UserActionManager {
|
||||
private const val TAG = "NtfyUserActionEx"
|
||||
private const val WORK_NAME_PREFIX = "io.heckel.ntfy.USER_ACTION_"
|
||||
|
||||
fun enqueue(context: Context, notificationId: String, actionId: String) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val workName = WORK_NAME_PREFIX + notificationId + "_" + actionId
|
||||
Log.d(TAG,"Enqueuing work to execute user action for notification $notificationId, action $actionId, work: $workName")
|
||||
val workRequest = OneTimeWorkRequest.Builder(UserActionWorker::class.java)
|
||||
.setInputData(workDataOf(
|
||||
UserActionWorker.INPUT_DATA_NOTIFICATION_ID to notificationId,
|
||||
UserActionWorker.INPUT_DATA_ACTION_ID to actionId,
|
||||
))
|
||||
.build()
|
||||
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
|
||||
}
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_BROADCAST
|
||||
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_HTTP
|
||||
import io.heckel.ntfy.util.Log
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.callTimeout(60, TimeUnit.SECONDS) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val notifier = NotificationService(context)
|
||||
private val broadcaster = BroadcastService(context)
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var subscription: Subscription
|
||||
private lateinit var notification: Notification
|
||||
private lateinit var action: Action
|
||||
|
||||
override fun doWork(): Result {
|
||||
if (context.applicationContext !is Application) return Result.failure()
|
||||
val notificationId = inputData.getString(INPUT_DATA_NOTIFICATION_ID) ?: return Result.failure()
|
||||
val actionId = inputData.getString(INPUT_DATA_ACTION_ID) ?: return Result.failure()
|
||||
val app = context.applicationContext as Application
|
||||
|
||||
repository = app.repository
|
||||
notification = repository.getNotification(notificationId) ?: return Result.failure()
|
||||
subscription = repository.getSubscription(notification.subscriptionId) ?: return Result.failure()
|
||||
action = notification.actions?.first { it.id == actionId } ?: return Result.failure()
|
||||
|
||||
Log.d(TAG, "Executing action $action for notification $notification")
|
||||
try {
|
||||
when (action.action) {
|
||||
// ACTION_VIEW is not handled here. It's handled in the NotificationService and DetailAdapter.
|
||||
ACTION_BROADCAST -> performBroadcastAction(action)
|
||||
ACTION_HTTP -> performHttpAction(action)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error executing action: ${e.message}", e)
|
||||
save(action.copy(
|
||||
progress = ACTION_PROGRESS_FAILED,
|
||||
error = context.getString(R.string.notification_popup_user_action_failed, action.label, e.message)
|
||||
))
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun performBroadcastAction(action: Action) {
|
||||
broadcaster.sendUserAction(action)
|
||||
if (action.clear == true) {
|
||||
notifier.cancel(notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performHttpAction(action: Action) {
|
||||
save(action.copy(progress = ACTION_PROGRESS_ONGOING, error = null))
|
||||
|
||||
val url = action.url ?: return
|
||||
val method = action.method ?: DEFAULT_HTTP_ACTION_METHOD
|
||||
val defaultBody = if (listOf("GET", "HEAD").contains(method)) null else ""
|
||||
val body = action.body ?: defaultBody
|
||||
val builder = Request.Builder()
|
||||
.url(url)
|
||||
.method(method, body?.toRequestBody())
|
||||
.addHeader("User-Agent", ApiService.USER_AGENT)
|
||||
action.headers?.forEach { (key, value) ->
|
||||
builder.addHeader(key, value)
|
||||
}
|
||||
val request = builder.build()
|
||||
|
||||
Log.d(TAG, "Executing HTTP request: ${method.uppercase(Locale.getDefault())} ${action.url}")
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
save(action.copy(progress = ACTION_PROGRESS_SUCCESS, error = null))
|
||||
return
|
||||
}
|
||||
throw Exception("HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(newAction: Action) {
|
||||
Log.d(TAG, "Updating action: $newAction")
|
||||
val clear = newAction.progress == ACTION_PROGRESS_SUCCESS && action.clear == true
|
||||
val newActions = notification.actions?.map { a -> if (a.id == newAction.id) newAction else a }
|
||||
val newNotification = notification.copy(actions = newActions)
|
||||
action = newAction
|
||||
notification = newNotification
|
||||
repository.updateNotification(notification)
|
||||
if (clear) {
|
||||
notifier.cancel(notification)
|
||||
} else {
|
||||
notifier.update(subscription, notification)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INPUT_DATA_NOTIFICATION_ID = "notificationId"
|
||||
const val INPUT_DATA_ACTION_ID = "actionId"
|
||||
|
||||
private const val DEFAULT_HTTP_ACTION_METHOD = "POST" // Cannot be changed without changing the contract
|
||||
private const val TAG = "NtfyUserActWrk"
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
package io.heckel.ntfy.service
|
||||
|
||||
interface Connection {
|
||||
fun start()
|
||||
fun close()
|
||||
fun since(): String?
|
||||
}
|
||||
|
||||
data class ConnectionId(
|
||||
val baseUrl: String,
|
||||
val topicsToSubscriptionIds: Map<String, Long>,
|
||||
val topicIsUnifiedPush: Map<String, Boolean>
|
||||
)
|
|
@ -1,113 +0,0 @@
|
|||
package io.heckel.ntfy.service
|
||||
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Call
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class JsonConnection(
|
||||
private val connectionId: ConnectionId,
|
||||
private val scope: CoroutineScope,
|
||||
private val repository: Repository,
|
||||
private val api: ApiService,
|
||||
private val user: User?,
|
||||
private val sinceId: String?,
|
||||
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
|
||||
private val notificationListener: (Subscription, Notification) -> Unit,
|
||||
private val serviceActive: () -> Boolean
|
||||
) : Connection {
|
||||
private val baseUrl = connectionId.baseUrl
|
||||
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
|
||||
private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush
|
||||
private val subscriptionIds = topicsToSubscriptionIds.values
|
||||
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
|
||||
private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",")
|
||||
private val url = topicUrl(baseUrl, topicsStr)
|
||||
|
||||
private var since: String? = sinceId
|
||||
private lateinit var call: Call
|
||||
private lateinit var job: Job
|
||||
|
||||
override fun start() {
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
Log.d(TAG, "[$url] Starting connection for subscriptions: $topicsToSubscriptionIds")
|
||||
|
||||
// Retry-loop: if the connection fails, we retry unless the job or service is cancelled/stopped
|
||||
var retryMillis = 0L
|
||||
while (isActive && serviceActive()) {
|
||||
Log.d(TAG, "[$url] (Re-)starting connection for subscriptions: $topicsToSubscriptionIds")
|
||||
val startTime = System.currentTimeMillis()
|
||||
val notify = notify@ { topic: String, notification: Notification ->
|
||||
since = notification.id
|
||||
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify
|
||||
val subscription = repository.getSubscription(subscriptionId) ?: return@notify
|
||||
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
|
||||
notificationListener(subscription, notificationWithSubscriptionId)
|
||||
}
|
||||
val failed = AtomicBoolean(false)
|
||||
val fail = { _: Exception ->
|
||||
failed.set(true)
|
||||
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
|
||||
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
|
||||
}
|
||||
}
|
||||
|
||||
// Call /json subscribe endpoint and loop until the call fails, is canceled,
|
||||
// or the job or service are cancelled/stopped
|
||||
try {
|
||||
call = api.subscribe(baseUrl, topicsStr, unifiedPushTopicsStr, since, user, notify, fail)
|
||||
while (!failed.get() && !call.isCanceled() && isActive && serviceActive()) {
|
||||
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED)
|
||||
Log.d(TAG,"[$url] Connection is active (failed=$failed, callCanceled=${call.isCanceled()}, jobActive=$isActive, serviceStarted=${serviceActive()}")
|
||||
delay(CONNECTION_LOOP_DELAY_MILLIS) // Resumes immediately if job is cancelled
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[$url] Connection failed: ${e.message}", e)
|
||||
if (isActive && serviceActive()) { // Avoid UI update races if we're restarting a connection
|
||||
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
|
||||
}
|
||||
}
|
||||
|
||||
// If we're not cancelled yet, wait little before retrying (incremental back-off)
|
||||
if (isActive && serviceActive()) {
|
||||
retryMillis = nextRetryMillis(retryMillis, startTime)
|
||||
Log.d(TAG, "[$url] Connection failed, retrying connection in ${retryMillis / 1000}s ...")
|
||||
delay(retryMillis)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "[$url] Connection job SHUT DOWN")
|
||||
// FIXME: Do NOT update state here as this can lead to races; this leaks the subscription state map
|
||||
}
|
||||
}
|
||||
|
||||
override fun since(): String? {
|
||||
return since
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
Log.d(TAG, "[$url] Cancelling connection")
|
||||
if (this::job.isInitialized) job.cancel()
|
||||
if (this::call.isInitialized) call.cancel()
|
||||
}
|
||||
|
||||
private fun nextRetryMillis(retryMillis: Long, startTime: Long): Long {
|
||||
val connectionDurationMillis = System.currentTimeMillis() - startTime
|
||||
if (connectionDurationMillis > RETRY_RESET_AFTER_MILLIS) {
|
||||
return RETRY_STEP_MILLIS
|
||||
} else if (retryMillis + RETRY_STEP_MILLIS >= RETRY_MAX_MILLIS) {
|
||||
return RETRY_MAX_MILLIS
|
||||
}
|
||||
return retryMillis + RETRY_STEP_MILLIS
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfySubscriberConn"
|
||||
private const val CONNECTION_LOOP_DELAY_MILLIS = 30_000L
|
||||
private const val RETRY_STEP_MILLIS = 5_000L
|
||||
private const val RETRY_MAX_MILLIS = 60_000L
|
||||
private const val RETRY_RESET_AFTER_MILLIS = 60_000L // Must be larger than CONNECTION_LOOP_DELAY_MILLIS
|
||||
}
|
||||
}
|
|
@ -1,368 +0,0 @@
|
|||
package io.heckel.ntfy.service
|
||||
|
||||
import android.app.*
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.ConnectionState
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.ui.Colors
|
||||
import io.heckel.ntfy.ui.MainActivity
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* The subscriber service manages the foreground service for instant delivery.
|
||||
*
|
||||
* This should be so easy but it's a hot mess due to all the Android restrictions, and all the hoops you have to jump
|
||||
* through to make your service not die or restart.
|
||||
*
|
||||
* Cliff notes:
|
||||
* - If the service is running, we keep one connection per base URL open (we group all topics together)
|
||||
* - Incoming notifications are immediately forwarded and broadcasted
|
||||
*
|
||||
* "Trying to keep the service running" cliff notes:
|
||||
* - Manages the service SHOULD-BE state in a SharedPref, so that we know whether or not to restart the service
|
||||
* - The foreground service is STICKY, so it is restarted by Android if it's killed
|
||||
* - On destroy (onDestroy), we send a broadcast to AutoRestartReceiver (see AndroidManifest.xml) which will schedule
|
||||
* a one-off AutoRestartWorker to restart the service (this is weird, but necessary because services started from
|
||||
* receivers are apparently low priority, see the gist below for details)
|
||||
* - The MainActivity schedules a periodic worker (AutoRestartWorker) which restarts the service
|
||||
* - FCM receives keepalive message from the main ntfy.sh server, which broadcasts an intent to AutoRestartReceiver,
|
||||
* which will schedule a one-off AutoRestartWorker to restart the service (see above)
|
||||
* - On boot, the BootStartReceiver is triggered to restart the service (see AndroidManifest.xml)
|
||||
*
|
||||
* This is all a hot mess, but you do what you gotta do.
|
||||
*
|
||||
* Largely modeled after this fantastic resource:
|
||||
* - https://robertohuertas.com/2019/06/29/android_foreground_services/
|
||||
* - https://github.com/robertohuertasm/endless-service/blob/master/app/src/main/java/com/robertohuertas/endless/EndlessService.kt
|
||||
* - https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd
|
||||
*/
|
||||
class SubscriberService : Service() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var isServiceStarted = false
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
|
||||
private val connections = ConcurrentHashMap<ConnectionId, Connection>()
|
||||
private val api = ApiService()
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
private val refreshMutex = Mutex() // Ensure refreshConnections() is only run one at a time
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand executed with startId: $startId")
|
||||
if (intent != null) {
|
||||
Log.d(TAG, "using an intent with action ${intent.action}")
|
||||
when (intent.action) {
|
||||
Action.START.name -> startService()
|
||||
Action.STOP.name -> stopService()
|
||||
else -> Log.w(TAG, "This should never happen. No action in the received intent")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "with a null intent. It has been probably restarted by the system.")
|
||||
}
|
||||
return START_STICKY // restart if system kills the service
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
Log.init(this) // Init logs in all entry points
|
||||
Log.d(TAG, "Subscriber service has been created")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Subscriber service has been destroyed")
|
||||
stopService()
|
||||
val preferenceKey = getString(R.string.eos_preference_key_is_enabled)
|
||||
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean(preferenceKey, false)) {
|
||||
sendBroadcast(Intent(this, AutoRestartReceiver::class.java))
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
if (isServiceStarted) {
|
||||
refreshConnections()
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "Starting the foreground service task")
|
||||
isServiceStarted = true
|
||||
saveServiceState(this, ServiceState.STARTED)
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
|
||||
}
|
||||
refreshConnections()
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
Log.d(TAG, "Stopping the foreground service")
|
||||
|
||||
// Cancelling all remaining jobs and open HTTP calls
|
||||
connections.values.forEach { connection -> connection.close() }
|
||||
connections.clear()
|
||||
|
||||
// Releasing wake-lock and stopping ourselves
|
||||
try {
|
||||
wakeLock?.let {
|
||||
// Release all acquire()
|
||||
while (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
stopSelf()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
||||
}
|
||||
|
||||
isServiceStarted = false
|
||||
saveServiceState(this, ServiceState.STOPPED)
|
||||
}
|
||||
|
||||
private fun refreshConnections() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (!refreshMutex.tryLock()) {
|
||||
Log.d(TAG, "Refreshing subscriptions already in progress. Skipping.")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
reallyRefreshConnections(this)
|
||||
} finally {
|
||||
refreshMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start/stop connections based on the desired state
|
||||
* It is guaranteed that only one of function is run at a time (see mutex above).
|
||||
*/
|
||||
private suspend fun reallyRefreshConnections(scope: CoroutineScope) {
|
||||
// Group INSTANT subscriptions by base URL, there is only one connection per base URL
|
||||
val instantSubscriptions = repository.getSubscriptions()
|
||||
.filter { s -> s.instant }
|
||||
val activeConnectionIds = connections.keys().toList().toSet()
|
||||
val desiredConnectionIds = instantSubscriptions // Set<ConnectionId>
|
||||
.groupBy { s -> ConnectionId(s.baseUrl, emptyMap(), emptyMap()) }
|
||||
.map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }, topicIsUnifiedPush = entry.value.associate { s -> s.topic to (s.upConnectorToken != null) }) }
|
||||
.toSet()
|
||||
val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds)
|
||||
val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds)
|
||||
val match = activeConnectionIds == desiredConnectionIds
|
||||
val sinceByBaseUrl = connections
|
||||
.map { e -> e.key.baseUrl to e.value.since() } // Use since=<id>, avoid retrieving old messages (see comment below)
|
||||
.toMap()
|
||||
|
||||
Log.d(TAG, "Refreshing subscriptions")
|
||||
Log.d(TAG, "- Desired connections: $desiredConnectionIds")
|
||||
Log.d(TAG, "- Active connections: $activeConnectionIds")
|
||||
Log.d(TAG, "- New connections: $newConnectionIds")
|
||||
Log.d(TAG, "- Obsolete connections: $obsoleteConnectionIds")
|
||||
Log.d(TAG, "- Match? --> $match")
|
||||
|
||||
if (match) {
|
||||
Log.d(TAG, "- No action required.")
|
||||
return
|
||||
}
|
||||
|
||||
// Open new connections
|
||||
newConnectionIds.forEach { connectionId ->
|
||||
// IMPORTANT: Do NOT request old messages for new connections; we call poll() in MainActivity to
|
||||
// retrieve old messages. This is important, so we don't download attachments from old messages.
|
||||
|
||||
val since = sinceByBaseUrl[connectionId.baseUrl] ?: "none"
|
||||
val serviceActive = { isServiceStarted }
|
||||
val user = repository.getUser(connectionId.baseUrl)
|
||||
val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) {
|
||||
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
|
||||
WsConnection(connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager)
|
||||
} else {
|
||||
JsonConnection(connectionId, scope, repository, api, user, since, ::onStateChanged, ::onNotificationReceived, serviceActive)
|
||||
}
|
||||
connections[connectionId] = connection
|
||||
connection.start()
|
||||
}
|
||||
|
||||
// Close connections without subscriptions
|
||||
obsoleteConnectionIds.forEach { connectionId ->
|
||||
val connection = connections.remove(connectionId)
|
||||
connection?.close()
|
||||
}
|
||||
|
||||
// Update foreground service notification popup
|
||||
if (connections.size > 0) {
|
||||
val title = getString(R.string.channel_subscriber_notification_title)
|
||||
val text = if (BuildConfig.FIREBASE_AVAILABLE) {
|
||||
when (instantSubscriptions.size) {
|
||||
1 -> getString(R.string.channel_subscriber_notification_instant_text_one)
|
||||
2 -> getString(R.string.channel_subscriber_notification_instant_text_two)
|
||||
3 -> getString(R.string.channel_subscriber_notification_instant_text_three)
|
||||
4 -> getString(R.string.channel_subscriber_notification_instant_text_four)
|
||||
5 -> getString(R.string.channel_subscriber_notification_instant_text_five)
|
||||
6 -> getString(R.string.channel_subscriber_notification_instant_text_six)
|
||||
else -> getString(R.string.channel_subscriber_notification_instant_text_more, instantSubscriptions.size)
|
||||
}
|
||||
} else {
|
||||
when (instantSubscriptions.size) {
|
||||
1 -> getString(R.string.channel_subscriber_notification_noinstant_text_one)
|
||||
2 -> getString(R.string.channel_subscriber_notification_noinstant_text_two)
|
||||
3 -> getString(R.string.channel_subscriber_notification_noinstant_text_three)
|
||||
4 -> getString(R.string.channel_subscriber_notification_noinstant_text_four)
|
||||
5 -> getString(R.string.channel_subscriber_notification_noinstant_text_five)
|
||||
6 -> getString(R.string.channel_subscriber_notification_noinstant_text_six)
|
||||
else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size)
|
||||
}
|
||||
}
|
||||
serviceNotification = createNotification(title, text)
|
||||
notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStateChanged(subscriptionIds: Collection<Long>, state: ConnectionState) {
|
||||
repository.updateState(subscriptionIds, state)
|
||||
}
|
||||
|
||||
private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) {
|
||||
// Wakelock while notifications are being dispatched
|
||||
// Wakelocks are reference counted by default so that should work neatly here
|
||||
wakeLock?.acquire(NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS)
|
||||
|
||||
val url = topicUrl(subscription.baseUrl, subscription.topic)
|
||||
Log.d(TAG, "[$url] Received notification: $notification")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (repository.addNotification(notification)) {
|
||||
Log.d(TAG, "[$url] Dispatching notification $notification")
|
||||
dispatcher.dispatch(subscription, notification)
|
||||
}
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(): NotificationManager? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channelName = getString(R.string.channel_subscriber_service_name) // Show's up in UI
|
||||
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW).let {
|
||||
it.setShowBadge(false) // Don't show long-press badge
|
||||
it
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
return notificationManager
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createNotification(title: String, text: String): Notification {
|
||||
val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_instant)
|
||||
.setColor(ContextCompat.getColor(this, Colors.notificationIcon(this)))
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setSound(null)
|
||||
.setShowWhen(false) // Don't show date/time
|
||||
.setOngoing(true) // Starting SDK 33 / Android 13, foreground notifications can be swiped away
|
||||
.setGroup(NOTIFICATION_GROUP_ID) // Do not group with other notifications
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null // We don't provide binding, so return null
|
||||
}
|
||||
|
||||
/* This re-schedules the task when the "Clear recent apps" button is pressed */
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
val restartServiceIntent = Intent(applicationContext, SubscriberService::class.java).also {
|
||||
it.setPackage(packageName)
|
||||
}
|
||||
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE)
|
||||
applicationContext.getSystemService(Context.ALARM_SERVICE)
|
||||
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent)
|
||||
}
|
||||
|
||||
/* This re-starts the service on reboot; see manifest */
|
||||
class BootStartReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "BootStartReceiver: onReceive called")
|
||||
SubscriberServiceManager.refresh(context)
|
||||
}
|
||||
}
|
||||
|
||||
// We are starting MyService via a worker and not directly because since Android 7
|
||||
// (but officially since Lollipop!), any process called by a BroadcastReceiver
|
||||
// (only manifest-declared receiver) is run at low priority and hence eventually
|
||||
// killed by Android.
|
||||
class AutoRestartReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "AutoRestartReceiver: onReceive called")
|
||||
SubscriberServiceManager.refresh(context)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Action {
|
||||
START,
|
||||
STOP
|
||||
}
|
||||
|
||||
enum class ServiceState {
|
||||
STARTED,
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfySubscriberService"
|
||||
const val SERVICE_START_WORKER_VERSION = BuildConfig.VERSION_CODE
|
||||
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "NtfyAutoRestartWorkerPeriodic" // Do not change!
|
||||
|
||||
private const val WAKE_LOCK_TAG = "SubscriberService:lock"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber"
|
||||
private const val NOTIFICATION_GROUP_ID = "io.heckel.ntfy.NOTIFICATION_GROUP_SERVICE"
|
||||
private const val NOTIFICATION_SERVICE_ID = 2586
|
||||
private const val NOTIFICATION_RECEIVED_WAKELOCK_TIMEOUT_MILLIS = 10*60*1000L /*10 minutes*/
|
||||
private const val SHARED_PREFS_ID = "SubscriberService"
|
||||
private const val SHARED_PREFS_SERVICE_STATE = "ServiceState"
|
||||
|
||||
fun saveServiceState(context: Context, state: ServiceState) {
|
||||
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
sharedPrefs.edit()
|
||||
.putString(SHARED_PREFS_SERVICE_STATE, state.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun readServiceState(context: Context): ServiceState {
|
||||
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
val value = sharedPrefs.getString(SHARED_PREFS_SERVICE_STATE, ServiceState.STOPPED.name)
|
||||
return ServiceState.valueOf(value!!)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
package io.heckel.ntfy.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.*
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* This class only manages the SubscriberService, i.e. it starts or stops it.
|
||||
* It's used in multiple activities.
|
||||
*
|
||||
* We are starting the service via a worker and not directly because since Android 7
|
||||
* (but officially since Lollipop!), any process called by a BroadcastReceiver
|
||||
* (only manifest-declared receiver) is run at low priority and hence eventually
|
||||
* killed by Android.
|
||||
*/
|
||||
class SubscriberServiceManager(private val context: Context) {
|
||||
fun refresh() {
|
||||
Log.d(TAG, "Enqueuing work to refresh subscriber service")
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build()
|
||||
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
|
||||
}
|
||||
|
||||
fun restart() {
|
||||
Intent(context, SubscriberService::class.java).also { intent ->
|
||||
context.stopService(intent) // Service will auto-restart
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or stops the foreground service by figuring out how many instant delivery subscriptions
|
||||
* exist. If there's > 0, then we need a foreground service.
|
||||
*/
|
||||
class ServiceStartWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val id = this.id
|
||||
if (context.applicationContext !is Application) {
|
||||
Log.d(TAG, "ServiceStartWorker: Failed, no application found (work ID: ${id})")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val app = context.applicationContext as Application
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(app)
|
||||
val preferenceKey = context.getString(R.string.eos_preference_key_is_enabled)
|
||||
val action = if (sharedPreferences.getBoolean(preferenceKey, false)) {
|
||||
SubscriberService.Action.START
|
||||
} else {
|
||||
SubscriberService.Action.STOP
|
||||
}
|
||||
|
||||
val serviceState = SubscriberService.readServiceState(context)
|
||||
if (serviceState == SubscriberService.ServiceState.STOPPED && action == SubscriberService.Action.STOP) {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
Log.d(TAG, "ServiceStartWorker: Starting foreground service with action $action (work ID: ${id})")
|
||||
Intent(context, SubscriberService::class.java).also {
|
||||
it.action = action.name
|
||||
context.startService(it)
|
||||
}
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfySubscriberMgr"
|
||||
const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val manager = SubscriberServiceManager(context)
|
||||
manager.refresh()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,203 +0,0 @@
|
|||
package io.heckel.ntfy.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.ApiService.Companion.requestBuilder
|
||||
import io.heckel.ntfy.msg.NotificationParser
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import io.heckel.ntfy.util.topicUrlWs
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Connect to ntfy server via WebSockets. This connection represents a single connection to a server, with
|
||||
* one or more topics. When the topics are changed, the connection is recreated by the service.
|
||||
*
|
||||
* The connection re-connects on failure, indefinitely. It reports limited status via the stateChangeListener,
|
||||
* and forwards incoming messages via the notificationListener.
|
||||
*
|
||||
* The original class is taken from the fantastic Gotify project (MIT). Thank you:
|
||||
* https://github.com/gotify/android/blob/master/app/src/main/java/com/github/gotify/service/WebSocketConnection.java
|
||||
*/
|
||||
class WsConnection(
|
||||
private val connectionId: ConnectionId,
|
||||
private val repository: Repository,
|
||||
private val user: User?,
|
||||
private val sinceId: String?,
|
||||
private val stateChangeListener: (Collection<Long>, ConnectionState) -> Unit,
|
||||
private val notificationListener: (Subscription, Notification) -> Unit,
|
||||
private val alarmManager: AlarmManager
|
||||
) : Connection {
|
||||
private val parser = NotificationParser()
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.pingInterval(1, TimeUnit.MINUTES) // The server pings us too, so this doesn't matter much
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private var errorCount = 0
|
||||
private var webSocket: WebSocket? = null
|
||||
private var state: State? = null
|
||||
private var closed = false
|
||||
|
||||
private val globalId = GLOBAL_ID.incrementAndGet()
|
||||
private val listenerId = AtomicLong(0)
|
||||
|
||||
private val since = AtomicReference<String?>(sinceId)
|
||||
private val baseUrl = connectionId.baseUrl
|
||||
private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds
|
||||
private val topicIsUnifiedPush = connectionId.topicIsUnifiedPush
|
||||
private val subscriptionIds = topicsToSubscriptionIds.values
|
||||
private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",")
|
||||
private val unifiedPushTopicsStr = topicIsUnifiedPush.filter { entry -> entry.value }.keys.joinToString(separator = ",")
|
||||
private val shortUrl = topicShortUrl(baseUrl, topicsStr)
|
||||
|
||||
init {
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): New connection with global ID $globalId")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start() {
|
||||
if (closed || state == State.Connecting || state == State.Connected) {
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Not (re-)starting, because connection is marked closed/connecting/connected")
|
||||
return
|
||||
}
|
||||
if (webSocket != null) {
|
||||
webSocket!!.close(WS_CLOSE_NORMAL, "")
|
||||
}
|
||||
state = State.Connecting
|
||||
val nextListenerId = listenerId.incrementAndGet()
|
||||
val sinceId = since.get()
|
||||
val sinceVal = sinceId ?: "all"
|
||||
val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal)
|
||||
val request = requestBuilder(urlWithSince, user, unifiedPushTopicsStr).build()
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...")
|
||||
webSocket = client.newWebSocket(request, Listener(nextListenerId))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
closed = true
|
||||
if (webSocket == null) {
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Not closing existing connection, because there is no active web socket")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): Closing connection")
|
||||
state = State.Disconnected
|
||||
webSocket!!.close(WS_CLOSE_NORMAL, "")
|
||||
webSocket = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun since(): String? {
|
||||
return since.get()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun scheduleReconnect(seconds: Int) {
|
||||
if (closed || state == State.Connecting || state == State.Connected) {
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Not rescheduling connection, because connection is marked closed/connecting/connected")
|
||||
return
|
||||
}
|
||||
state = State.Scheduled
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
Log.d(TAG,"$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via alarm manager)")
|
||||
val reconnectTime = Calendar.getInstance()
|
||||
reconnectTime.add(Calendar.SECOND, seconds)
|
||||
alarmManager.setExact(AlarmManager.RTC_WAKEUP, reconnectTime.timeInMillis, RECONNECT_TAG, { start() }, null)
|
||||
} else {
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId): Scheduling a restart in $seconds seconds (via handler)")
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
handler.postDelayed({ start() }, TimeUnit.SECONDS.toMillis(seconds.toLong()))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Listener(private val id: Long) : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
synchronize("onOpen") {
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Opened connection")
|
||||
state = State.Connected
|
||||
if (errorCount > 0) {
|
||||
errorCount = 0
|
||||
}
|
||||
stateChangeListener(subscriptionIds, ConnectionState.CONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
synchronize("onMessage") {
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Received message: $text")
|
||||
val notificationWithTopic = parser.parseWithTopic(text, subscriptionId = 0, notificationId = Random.nextInt())
|
||||
if (notificationWithTopic == null) {
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.")
|
||||
return@synchronize
|
||||
}
|
||||
val topic = notificationWithTopic.topic
|
||||
val notification = notificationWithTopic.notification
|
||||
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@synchronize
|
||||
val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize
|
||||
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
|
||||
notificationListener(subscription, notificationWithSubscriptionId)
|
||||
since.set(notification.id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
synchronize("onClosed") {
|
||||
Log.w(TAG, "$shortUrl (gid=$globalId, lid=$id): Closed connection")
|
||||
state = State.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
synchronize("onFailure") {
|
||||
if (response == null) {
|
||||
Log.e(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection failed (response is null): ${t.message}", t)
|
||||
} else {
|
||||
Log.e(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection failed (response code ${response.code}, message: ${response.message}): ${t.message}", t)
|
||||
}
|
||||
if (closed) {
|
||||
Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection marked as closed. Not retrying.")
|
||||
return@synchronize
|
||||
}
|
||||
stateChangeListener(subscriptionIds, ConnectionState.CONNECTING)
|
||||
state = State.Disconnected
|
||||
errorCount++
|
||||
val retrySeconds = RETRY_SECONDS.getOrNull(errorCount) ?: RETRY_SECONDS.last()
|
||||
scheduleReconnect(retrySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun synchronize(tag: String, fn: () -> Unit) {
|
||||
synchronized(this) {
|
||||
if (listenerId.get() == id) {
|
||||
fn()
|
||||
} else {
|
||||
Log.w(TAG, "$shortUrl (gid=$globalId, lid=$id): Skipping synchronized block '$tag', because listener ID does not match ${listenerId.get()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class State {
|
||||
Scheduled, Connecting, Connected, Disconnected
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfyWsConnection"
|
||||
private const val RECONNECT_TAG = "WsReconnect"
|
||||
private const val WS_CLOSE_NORMAL = 1000
|
||||
private val RETRY_SECONDS = listOf(5, 10, 15, 20, 30, 45, 60, 120)
|
||||
private val GLOBAL_ID = AtomicLong(0)
|
||||
}
|
||||
}
|
|
@ -1,438 +1,85 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.*
|
||||
import android.widget.CheckBox
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.User
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AddFragment : DialogFragment() {
|
||||
private val api = ApiService()
|
||||
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var subscribeListener: SubscribeListener
|
||||
private lateinit var appBaseUrl: String
|
||||
private var defaultBaseUrl: String? = null
|
||||
|
||||
private lateinit var subscribeView: View
|
||||
private lateinit var loginView: View
|
||||
private lateinit var positiveButton: Button
|
||||
private lateinit var negativeButton: Button
|
||||
|
||||
// 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
|
||||
class AddFragment(private val listener: AddSubscriptionListener) : DialogFragment() {
|
||||
interface AddSubscriptionListener {
|
||||
fun onAddSubscription(topic: String, baseUrl: String)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
if (activity == null) {
|
||||
throw IllegalStateException("Activity cannot be null")
|
||||
}
|
||||
return activity?.let {
|
||||
// Build root view
|
||||
val view = requireActivity().layoutInflater.inflate(R.layout.add_dialog_fragment, null)
|
||||
val topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText
|
||||
val baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText
|
||||
val useAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) as CheckBox
|
||||
|
||||
// Dependencies (Fragments need a default constructor)
|
||||
repository = Repository.getInstance(requireActivity())
|
||||
appBaseUrl = getString(R.string.app_base_url)
|
||||
defaultBaseUrl = repository.getDefaultBaseUrl()
|
||||
|
||||
// Build root view
|
||||
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_add_dialog, null)
|
||||
|
||||
// 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))
|
||||
|
||||
// Show/hide based on flavor (faster shortcut for validateInputSubscribeView, which can only run onShow)
|
||||
if (!BuildConfig.FIREBASE_AVAILABLE) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Username/password validation on type
|
||||
val loginTextWatcher = AfterChangedTextWatcher {
|
||||
validateInputLoginView()
|
||||
}
|
||||
loginUsernameText.addTextChangedListener(loginTextWatcher)
|
||||
loginPasswordText.addTextChangedListener(loginTextWatcher)
|
||||
|
||||
// Build dialog
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setView(view)
|
||||
.setPositiveButton(R.string.add_dialog_button_subscribe) { _, _ ->
|
||||
// This will be overridden below to avoid closing the dialog immediately
|
||||
}
|
||||
.setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
|
||||
// This will be overridden below
|
||||
}
|
||||
.create()
|
||||
|
||||
// Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
||||
|
||||
// Add logic to disable "Subscribe" button on invalid input
|
||||
dialog.setOnShowListener {
|
||||
positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
positiveButton.isEnabled = false
|
||||
positiveButton.setOnClickListener {
|
||||
positiveButtonClick()
|
||||
}
|
||||
negativeButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
||||
negativeButton.setOnClickListener {
|
||||
negativeButtonClick()
|
||||
}
|
||||
val subscribeTextWatcher = AfterChangedTextWatcher {
|
||||
validateInputSubscribeView()
|
||||
}
|
||||
subscribeTopicText.addTextChangedListener(subscribeTextWatcher)
|
||||
subscribeBaseUrlText.addTextChangedListener(subscribeTextWatcher)
|
||||
subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, _ ->
|
||||
validateInputSubscribeView()
|
||||
}
|
||||
subscribeUseAnotherServerCheckbox.setOnCheckedChangeListener { _, _ ->
|
||||
validateInputSubscribeView()
|
||||
}
|
||||
validateInputSubscribeView()
|
||||
|
||||
// Focus topic text (keyboard is shown too, see above)
|
||||
subscribeTopicText.requestFocus()
|
||||
}
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun positiveButtonClick() {
|
||||
val topic = subscribeTopicText.text.toString()
|
||||
val baseUrl = getBaseUrl()
|
||||
if (subscribeView.visibility == View.VISIBLE) {
|
||||
checkReadAndMaybeShowLogin(baseUrl, topic)
|
||||
} else if (loginView.visibility == View.VISIBLE) {
|
||||
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 user = repository.getUser(baseUrl) // May be null
|
||||
val authorized = api.checkAuth(baseUrl, topic, user)
|
||||
if (authorized) {
|
||||
Log.d(TAG, "Access granted to topic ${topicUrl(baseUrl, topic)}")
|
||||
dismissDialog()
|
||||
} else {
|
||||
if (user != null) {
|
||||
Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, but user already exists")
|
||||
showErrorAndReenableSubscribeView(getString(R.string.add_dialog_login_error_not_authorized, user.username))
|
||||
// Build dialog
|
||||
val alert = AlertDialog.Builder(it)
|
||||
.setView(view)
|
||||
.setPositiveButton(R.string.add_dialog_button_subscribe) { _, _ ->
|
||||
val topic = topicNameText.text.toString()
|
||||
val baseUrl = if (useAnotherServerCheckbox.isChecked) {
|
||||
baseUrlText.text.toString()
|
||||
} 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)
|
||||
}
|
||||
getString(R.string.add_dialog_base_url_default)
|
||||
}
|
||||
listener.onAddSubscription(topic, baseUrl)
|
||||
}
|
||||
.setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
|
||||
dialog?.cancel()
|
||||
}
|
||||
.create()
|
||||
|
||||
// Add logic to disable "Subscribe" button on invalid input
|
||||
alert.setOnShowListener {
|
||||
val dialog = it as AlertDialog
|
||||
|
||||
val subscribeButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
subscribeButton.isEnabled = false
|
||||
|
||||
val validateInput: () -> Unit = {
|
||||
if (useAnotherServerCheckbox.isChecked) {
|
||||
subscribeButton.isEnabled = topicNameText.text.toString().isNotBlank()
|
||||
&& "[-_A-Za-z0-9]+".toRegex().matches(topicNameText.text.toString())
|
||||
&& baseUrlText.text.toString().isNotBlank()
|
||||
&& "^https?://.+".toRegex().matches(baseUrlText.text.toString())
|
||||
} else {
|
||||
subscribeButton.isEnabled = topicNameText.text.toString().isNotBlank()
|
||||
&& "[-_A-Za-z0-9]+".toRegex().matches(topicNameText.text.toString())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Connection to topic failed: ${e.message}", e)
|
||||
showErrorAndReenableSubscribeView(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 user = User(
|
||||
baseUrl = baseUrl,
|
||||
username = loginUsernameText.text.toString(),
|
||||
password = loginPasswordText.text.toString()
|
||||
)
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Log.d(TAG, "Checking read access for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
|
||||
try {
|
||||
val authorized = api.checkAuth(baseUrl, topic, user)
|
||||
if (authorized) {
|
||||
Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}, adding to database")
|
||||
repository.addUser(user)
|
||||
dismissDialog()
|
||||
} else {
|
||||
Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
|
||||
showErrorAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized, user.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 negativeButtonClick() {
|
||||
if (subscribeView.visibility == View.VISIBLE) {
|
||||
dialog?.cancel()
|
||||
} else if (loginView.visibility == View.VISIBLE) {
|
||||
showSubscribeView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateInputSubscribeView() {
|
||||
if (!this::positiveButton.isInitialized) return // As per crash seen in Google Play
|
||||
|
||||
// Show/hide things: This logic is intentionally kept simple. Do not simplify "just because it's pretty".
|
||||
val instantToggleAllowed = if (!BuildConfig.FIREBASE_AVAILABLE) {
|
||||
false
|
||||
} else if (subscribeUseAnotherServerCheckbox.isChecked && subscribeBaseUrlText.text.toString() == appBaseUrl) {
|
||||
true
|
||||
} else if (!subscribeUseAnotherServerCheckbox.isChecked && defaultBaseUrl == null) {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if (subscribeUseAnotherServerCheckbox.isChecked) {
|
||||
subscribeUseAnotherServerDescription.visibility = View.VISIBLE
|
||||
subscribeBaseUrlLayout.visibility = View.VISIBLE
|
||||
} else {
|
||||
subscribeUseAnotherServerDescription.visibility = View.GONE
|
||||
subscribeBaseUrlLayout.visibility = View.GONE
|
||||
}
|
||||
if (instantToggleAllowed) {
|
||||
subscribeInstantDeliveryBox.visibility = View.VISIBLE
|
||||
subscribeInstantDeliveryDescription.visibility = if (subscribeInstantDeliveryCheckbox.isChecked) View.VISIBLE else View.GONE
|
||||
subscribeForegroundDescription.visibility = View.GONE
|
||||
} else {
|
||||
subscribeInstantDeliveryBox.visibility = View.GONE
|
||||
subscribeInstantDeliveryDescription.visibility = View.GONE
|
||||
subscribeForegroundDescription.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else 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)) {
|
||||
positiveButton.isEnabled = false
|
||||
} else if (subscribeUseAnotherServerCheckbox.isChecked) {
|
||||
positiveButton.isEnabled = validTopic(topic) && validUrl(baseUrl)
|
||||
} else {
|
||||
positiveButton.isEnabled = validTopic(topic)
|
||||
val textWatcher = object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
validateInput()
|
||||
}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
// Nothing
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
// Nothing
|
||||
}
|
||||
}
|
||||
topicNameText.addTextChangedListener(textWatcher)
|
||||
baseUrlText.addTextChangedListener(textWatcher)
|
||||
useAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked) baseUrlText.visibility = View.VISIBLE
|
||||
else baseUrlText.visibility = View.GONE
|
||||
validateInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateInputLoginView() {
|
||||
if (!this::positiveButton.isInitialized || !this::loginUsernameText.isInitialized || !this::loginPasswordText.isInitialized) {
|
||||
return // As per crash seen in Google Play
|
||||
}
|
||||
if (loginUsernameText.visibility == View.GONE) {
|
||||
positiveButton.isEnabled = true
|
||||
} else {
|
||||
positiveButton.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 topic = subscribeTopicText.text.toString()
|
||||
val baseUrl = getBaseUrl()
|
||||
val instant = !BuildConfig.FIREBASE_AVAILABLE || baseUrl != appBaseUrl || subscribeInstantDeliveryCheckbox.isChecked
|
||||
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()
|
||||
positiveButton.text = getString(R.string.add_dialog_button_subscribe)
|
||||
negativeButton.text = getString(R.string.add_dialog_button_cancel)
|
||||
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
|
||||
positiveButton.text = getString(R.string.add_dialog_button_login)
|
||||
negativeButton.text = getString(R.string.add_dialog_button_back)
|
||||
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
|
||||
positiveButton.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
|
||||
positiveButton.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
|
||||
alert
|
||||
} ?: throw IllegalStateException("Activity cannot be null")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
package io.heckel.ntfy.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 io.heckel.ntfy.R
|
||||
|
||||
fun initBaseUrlDropdown(baseUrls: List<String>, 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 = ""
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.util.isDarkThemeOn
|
||||
|
||||
class Colors {
|
||||
companion object {
|
||||
const val refreshProgressIndicator = R.color.teal
|
||||
|
||||
fun notificationIcon(context: Context): Int {
|
||||
return if (isDarkThemeOn(context)) R.color.teal_light else R.color.teal
|
||||
}
|
||||
|
||||
fun itemSelectedBackground(context: Context): Int {
|
||||
return if (isDarkThemeOn(context)) R.color.black_800b else R.color.gray_400
|
||||
}
|
||||
|
||||
fun cardBackground(context: Context): Int {
|
||||
return if (isDarkThemeOn(context)) R.color.black_800b else R.color.white
|
||||
}
|
||||
|
||||
fun cardSelectedBackground(context: Context): Int {
|
||||
return if (isDarkThemeOn(context)) R.color.black_700b else R.color.gray_500
|
||||
}
|
||||
|
||||
fun cardBackgroundColor(context: Context): Int {
|
||||
return ContextCompat.getColor(context, cardBackground(context))
|
||||
}
|
||||
|
||||
fun cardSelectedBackgroundColor(context: Context): Int {
|
||||
return ContextCompat.getColor(context, cardSelectedBackground(context))
|
||||
}
|
||||
|
||||
fun statusBarNormal(context: Context): Int {
|
||||
return if (isDarkThemeOn(context)) R.color.black_900 else R.color.teal
|
||||
}
|
||||
|
||||
fun statusBarActionMode(context: Context): Int {
|
||||
return if (isDarkThemeOn(context)) R.color.black_900 else R.color.teal_dark
|
||||
}
|
||||
|
||||
fun dangerText(context: Context): Int {
|
||||
return if (isDarkThemeOn(context)) R.color.red_light else R.color.red_dark
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,775 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_VIEW
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.view.ActionMode
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.firebase.FirebaseMessenger
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFragment.NotificationSettingsListener {
|
||||
private val viewModel by viewModels<DetailViewModel> {
|
||||
DetailViewModelFactory((application as Application).repository)
|
||||
}
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val api = ApiService()
|
||||
private val messenger = FirebaseMessenger()
|
||||
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 subscriptionInstant: Boolean = false // 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
|
||||
|
||||
// Action mode stuff
|
||||
private var actionMode: ActionMode? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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)
|
||||
|
||||
// Show 'Back' button
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
// 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)
|
||||
val baseUrl = if (secure) "https://${url.host}" else "http://${url.host}"
|
||||
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) {
|
||||
val instant = baseUrl != appBaseUrl
|
||||
subscription = Subscription(
|
||||
id = randomSubscriptionId(),
|
||||
baseUrl = baseUrl,
|
||||
topic = topic,
|
||||
instant = instant,
|
||||
dedicatedChannels = false,
|
||||
mutedUntil = 0,
|
||||
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
|
||||
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
|
||||
insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL,
|
||||
lastNotificationId = null,
|
||||
icon = null,
|
||||
upAppId = null,
|
||||
upConnectorToken = null,
|
||||
displayName = null,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = Date().time/1000
|
||||
)
|
||||
repository.addSubscription(subscription)
|
||||
|
||||
// Subscribe to Firebase topic if ntfy.sh (even if instant, just to be sure!)
|
||||
if (baseUrl == appBaseUrl) {
|
||||
Log.d(TAG, "Subscribing to Firebase topic $topic")
|
||||
messenger.subscribe(topic)
|
||||
}
|
||||
|
||||
// Fetch cached messages
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
|
||||
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(subscription))
|
||||
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
|
||||
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
|
||||
|
||||
runOnUiThread {
|
||||
loadView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
subscriptionInstant = intent.getBooleanExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, false)
|
||||
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)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
howToExample.text = Html.fromHtml(howToText, Html.FROM_HTML_MODE_LEGACY)
|
||||
} else {
|
||||
howToExample.text = Html.fromHtml(howToText)
|
||||
}
|
||||
|
||||
// Swipe to refresh
|
||||
mainListContainer = findViewById(R.id.detail_notification_list_container)
|
||||
mainListContainer.setOnRefreshListener { refresh() }
|
||||
mainListContainer.setColorSchemeResources(Colors.refreshProgressIndicator)
|
||||
|
||||
// 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
|
||||
|
||||
viewModel.list(subscriptionId).observe(this) {
|
||||
it?.let {
|
||||
// Show list view
|
||||
adapter.submitList(it as MutableList<Notification>)
|
||||
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) {
|
||||
SubscriberServiceManager.refresh(this)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
subscriptionInstant = subscription.instant
|
||||
subscriptionMutedUntil = subscription.mutedUntil
|
||||
subscriptionDisplayName = displayName(subscription)
|
||||
|
||||
showHideInstantMenuItems(subscriptionInstant)
|
||||
showHideMutedUntilMenuItems(subscriptionMutedUntil)
|
||||
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<Notification>) {
|
||||
val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 }
|
||||
if (notificationsWithPopups.isNotEmpty()) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
notificationsWithPopups.forEach { notification ->
|
||||
notifier?.cancel(notification)
|
||||
// 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
|
||||
|
||||
// Show and hide buttons
|
||||
showHideInstantMenuItems(subscriptionInstant)
|
||||
showHideMutedUntilMenuItems(subscriptionMutedUntil)
|
||||
|
||||
// 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_enable_instant -> {
|
||||
onInstantEnableClick(enable = true)
|
||||
true
|
||||
}
|
||||
R.id.detail_menu_disable_instant -> {
|
||||
onInstantEnableClick(enable = false)
|
||||
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 user = repository.getUser(subscriptionBaseUrl) // May be null
|
||||
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, user, message, title, priority, tags, delay = "")
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
val message = if (e is ApiService.UnauthorizedException) {
|
||||
if (e.user != null) {
|
||||
getString(R.string.detail_test_message_error_unauthorized_user, e.user.username)
|
||||
} else {
|
||||
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, "Showing notification settings dialog for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||
val notificationFragment = NotificationFragment()
|
||||
notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
|
||||
} else {
|
||||
Log.d(TAG, "Re-enabling notifications ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||
onNotificationMutedUntilChanged(Repository.MUTED_UNTIL_SHOW_ALL)
|
||||
}
|
||||
}
|
||||
|
||||
override 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 {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("topic address", url)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast
|
||||
.makeText(this, getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
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 user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
|
||||
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 onInstantEnableClick(enable: Boolean) {
|
||||
Log.d(TAG, "Toggling instant delivery setting for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val subscription = repository.getSubscription(subscriptionId)
|
||||
val newSubscription = subscription?.copy(instant = enable)
|
||||
newSubscription?.let { repository.updateSubscription(newSubscription) }
|
||||
showHideInstantMenuItems(enable)
|
||||
runOnUiThread {
|
||||
if (enable) {
|
||||
Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_enabled), Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
} else {
|
||||
Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_disabled), Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showHideInstantMenuItems(enable: Boolean) {
|
||||
if (!this::menu.isInitialized) {
|
||||
return
|
||||
}
|
||||
subscriptionInstant = enable
|
||||
runOnUiThread {
|
||||
val appBaseUrl = getString(R.string.app_base_url)
|
||||
val enableInstantItem = menu.findItem(R.id.detail_menu_enable_instant)
|
||||
val disableInstantItem = menu.findItem(R.id.detail_menu_disable_instant)
|
||||
val allowToggleInstant = BuildConfig.FIREBASE_AVAILABLE && subscriptionBaseUrl == appBaseUrl
|
||||
if (allowToggleInstant) {
|
||||
enableInstantItem?.isVisible = !subscriptionInstant
|
||||
disableInstantItem?.isVisible = subscriptionInstant
|
||||
} else {
|
||||
enableInstantItem?.isVisible = false
|
||||
disableInstantItem?.isVisible = 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 updateTitle(subscriptionDisplayName: String) {
|
||||
runOnUiThread {
|
||||
title = subscriptionDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClearClick() {
|
||||
Log.d(TAG, "Clearing all notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||
|
||||
val builder = AlertDialog.Builder(this)
|
||||
val dialog = builder
|
||||
.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(this)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun onSettingsClick() {
|
||||
Log.d(TAG, "Opening subscription settings for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||
|
||||
val intent = Intent(this, DetailSettingsActivity::class.java)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_ID, subscriptionId)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscriptionBaseUrl)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_DISPLAY_NAME, subscriptionDisplayName)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun onDeleteClick() {
|
||||
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
|
||||
|
||||
val builder = AlertDialog.Builder(this)
|
||||
val dialog = builder
|
||||
.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)
|
||||
if (subscriptionBaseUrl == appBaseUrl) {
|
||||
messenger.unsubscribe(subscriptionTopic)
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(R.string.detail_delete_dialog_cancel) { _, _ -> /* Do nothing */ }
|
||||
.create()
|
||||
dialog.setOnShowListener {
|
||||
dialog
|
||||
.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
.dangerButton(this)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun onNotificationClick(notification: Notification) {
|
||||
if (actionMode != null) {
|
||||
handleActionModeClick(notification)
|
||||
} else if (notification.click != "") {
|
||||
try {
|
||||
startActivity(Intent(ACTION_VIEW, Uri.parse(notification.click)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Cannot open click URL", e)
|
||||
runOnUiThread {
|
||||
Toast
|
||||
.makeText(this@DetailActivity, getString(R.string.detail_item_cannot_open_url, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
copyToClipboard(notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(notification: Notification) {
|
||||
runOnUiThread {
|
||||
copyToClipboard(this, 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()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
this.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?): Boolean {
|
||||
return 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
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("notifications", content)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast
|
||||
.makeText(this@DetailActivity, getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
finishActionMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMultiDeleteClick() {
|
||||
Log.d(TAG, "Showing multi-delete dialog for selected items")
|
||||
|
||||
val builder = AlertDialog.Builder(this)
|
||||
val dialog = builder
|
||||
.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(this)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
endActionModeAndRedraw()
|
||||
}
|
||||
|
||||
private fun beginActionMode(notification: Notification) {
|
||||
actionMode = startActionMode(this)
|
||||
adapter.toggleSelection(notification.id)
|
||||
|
||||
// Fade status bar color
|
||||
val fromColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
|
||||
val toColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
|
||||
fadeStatusBarColor(window, fromColor, toColor)
|
||||
}
|
||||
|
||||
private fun finishActionMode() {
|
||||
actionMode!!.finish()
|
||||
endActionModeAndRedraw()
|
||||
}
|
||||
|
||||
private fun endActionModeAndRedraw() {
|
||||
actionMode = null
|
||||
adapter.selected.clear()
|
||||
adapter.notifyItemRangeChanged(0, adapter.currentList.size)
|
||||
|
||||
// Fade status bar color
|
||||
val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
|
||||
val toColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
|
||||
fadeStatusBarColor(window, fromColor, toColor)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -1,551 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
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.google.android.material.button.MaterialButton
|
||||
import com.stfalcon.imageviewer.StfalconImageViewer
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.DownloadAttachmentWorker
|
||||
import io.heckel.ntfy.msg.DownloadManager
|
||||
import io.heckel.ntfy.msg.DownloadType
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.msg.NotificationService.Companion.ACTION_VIEW
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
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<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) {
|
||||
val selected = mutableSetOf<String>() // Notification IDs
|
||||
|
||||
/* Creates and inflates view and return TopicViewHolder. */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.fragment_detail_item, parent, false)
|
||||
return DetailViewHolder(activity, lifecycleScope, repository, view, selected, onClick, onLongClick)
|
||||
}
|
||||
|
||||
/* 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.size != 0) {
|
||||
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, itemView: View, private val selected: Set<String>, 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 iconView: ImageView = itemView.findViewById(R.id.detail_item_icon)
|
||||
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 attachmentImageView: ImageView = itemView.findViewById(R.id.detail_item_attachment_image)
|
||||
private val attachmentBoxView: View = itemView.findViewById(R.id.detail_item_attachment_file_box)
|
||||
private val attachmentIconView: ImageView = itemView.findViewById(R.id.detail_item_attachment_file_icon)
|
||||
private val attachmentInfoView: TextView = itemView.findViewById(R.id.detail_item_attachment_file_info)
|
||||
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))
|
||||
|
||||
dateView.text = formatDateShort(notification.timestamp)
|
||||
messageView.text = maybeAppendActionErrors(formatMessage(notification), notification)
|
||||
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))
|
||||
}
|
||||
val attachment = notification.attachment
|
||||
val attachmentFileStat = maybeFileStat(context, attachment?.contentUri)
|
||||
val iconFileStat = maybeFileStat(context, notification.icon?.contentUri)
|
||||
renderPriority(context, notification)
|
||||
resetCardButtons()
|
||||
maybeRenderMenu(context, notification, attachmentFileStat)
|
||||
maybeRenderAttachment(context, notification, attachmentFileStat)
|
||||
maybeRenderIcon(context, notification, iconFileStat)
|
||||
maybeRenderActions(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 maybeRenderAttachment(context: Context, notification: Notification, attachmentFileStat: FileInfo?) {
|
||||
if (notification.attachment == null) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
attachmentBoxView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
val attachment = notification.attachment
|
||||
val image = attachment.contentUri != null && supportedImage(attachment.type) && previewableImage(attachmentFileStat)
|
||||
val bitmap = if (image) attachment.contentUri?.readBitmapFromUriOrNull(context) else null
|
||||
maybeRenderAttachmentImage(context, bitmap)
|
||||
maybeRenderAttachmentBox(context, notification, attachment, attachmentFileStat, bitmap)
|
||||
}
|
||||
|
||||
private fun maybeRenderIcon(context: Context, notification: Notification, iconStat: FileInfo?) {
|
||||
if (notification.icon == null || !previewableImage(iconStat)) {
|
||||
iconView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
try {
|
||||
val icon = notification.icon
|
||||
val bitmap = icon.contentUri?.readBitmapFromUri(context) ?: throw Exception("uri empty")
|
||||
iconView.setImageBitmap(bitmap)
|
||||
iconView.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
iconView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRenderMenu(context: Context, notification: Notification, attachmentFileStat: FileInfo?) {
|
||||
val menuButtonPopupMenu = maybeCreateMenuPopup(context, menuButton, notification, attachmentFileStat) // Heavy lifting not during on-click
|
||||
if (menuButtonPopupMenu != null) {
|
||||
menuButton.setOnClickListener { menuButtonPopupMenu.show() }
|
||||
menuButton.visibility = View.VISIBLE
|
||||
} else {
|
||||
menuButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRenderActions(context: Context, notification: Notification) {
|
||||
if (!notification.actions.isNullOrEmpty()) {
|
||||
actionsWrapperView.visibility = View.VISIBLE
|
||||
val actionsCount = Math.min(notification.actions.size, 3) // per documentation, only 3 actions are available
|
||||
for (i in 0 until actionsCount) {
|
||||
val action = notification.actions[i]
|
||||
val label = formatActionLabel(action)
|
||||
val actionButton = createCardButton(context, label) { runAction(context, notification, action) }
|
||||
addButtonToCard(actionButton)
|
||||
}
|
||||
} else {
|
||||
actionsWrapperView.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 maybeRenderAttachmentBox(context: Context, notification: Notification, attachment: Attachment, attachmentFileStat: FileInfo?, bitmap: Bitmap?) {
|
||||
if (bitmap != null) {
|
||||
attachmentBoxView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
attachmentInfoView.text = formatAttachmentDetails(context, attachment, attachmentFileStat)
|
||||
attachmentIconView.setImageResource(mimeTypeToIconResource(attachment.type))
|
||||
val attachmentBoxPopupMenu = maybeCreateMenuPopup(context, attachmentBoxView, notification, attachmentFileStat) // Heavy lifting not during on-click
|
||||
if (attachmentBoxPopupMenu != null) {
|
||||
attachmentBoxView.setOnClickListener { attachmentBoxPopupMenu.show() }
|
||||
} else {
|
||||
attachmentBoxView.setOnClickListener {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_download), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
attachmentBoxView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun maybeCreateMenuPopup(context: Context, anchor: View?, notification: Notification, attachmentFileStat: FileInfo?): PopupMenu? {
|
||||
val popup = PopupMenu(context, anchor)
|
||||
popup.menuInflater.inflate(R.menu.menu_detail_attachment, popup.menu)
|
||||
val attachment = notification.attachment // May be null
|
||||
val hasAttachment = attachment != null
|
||||
val attachmentExists = attachmentFileStat != null
|
||||
val hasClickLink = notification.click != ""
|
||||
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)
|
||||
val expired = attachment?.expires != null && attachment.expires < System.currentTimeMillis()/1000
|
||||
val inProgress = attachment?.progress in 0..99
|
||||
if (attachment != null) {
|
||||
openItem.setOnMenuItemClickListener { openFile(context, attachment) }
|
||||
saveFileItem.setOnMenuItemClickListener { saveFile(context, attachment) }
|
||||
deleteItem.setOnMenuItemClickListener { deleteFile(context, notification, attachment) }
|
||||
copyUrlItem.setOnMenuItemClickListener { copyUrl(context, attachment) }
|
||||
downloadItem.setOnMenuItemClickListener { downloadFile(context, notification) }
|
||||
cancelItem.setOnMenuItemClickListener { cancelDownload(context, notification) }
|
||||
}
|
||||
if (hasClickLink) {
|
||||
copyContentsItem.setOnMenuItemClickListener { copyContents(context, notification) }
|
||||
}
|
||||
openItem.isVisible = hasAttachment && attachmentExists
|
||||
downloadItem.isVisible = hasAttachment && !attachmentExists && !expired && !inProgress
|
||||
deleteItem.isVisible = hasAttachment && attachmentExists
|
||||
saveFileItem.isVisible = hasAttachment && attachmentExists
|
||||
copyUrlItem.isVisible = hasAttachment && !expired
|
||||
cancelItem.isVisible = hasAttachment && inProgress
|
||||
copyContentsItem.isVisible = notification.click != ""
|
||||
val noOptions = !openItem.isVisible && !saveFileItem.isVisible && !downloadItem.isVisible
|
||||
&& !copyUrlItem.isVisible && !cancelItem.isVisible && !deleteItem.isVisible
|
||||
&& !copyContentsItem.isVisible
|
||||
if (noOptions) {
|
||||
return null
|
||||
}
|
||||
return popup
|
||||
}
|
||||
|
||||
private fun formatAttachmentDetails(context: Context, attachment: Attachment, attachmentFileStat: FileInfo?): String {
|
||||
val name = attachment.name
|
||||
val exists = attachmentFileStat != null
|
||||
val notYetDownloaded = !exists && attachment.progress == ATTACHMENT_PROGRESS_NONE
|
||||
val downloading = !exists && attachment.progress in 0..99
|
||||
val deleted = !exists && (attachment.progress == ATTACHMENT_PROGRESS_DONE || attachment.progress == ATTACHMENT_PROGRESS_DELETED)
|
||||
val failed = !exists && attachment.progress == ATTACHMENT_PROGRESS_FAILED
|
||||
val expired = attachment.expires != null && attachment.expires < System.currentTimeMillis()/1000
|
||||
val expires = attachment.expires != null && attachment.expires > System.currentTimeMillis()/1000
|
||||
val infos = mutableListOf<String>()
|
||||
if (attachment.size != null) {
|
||||
infos.add(formatBytes(attachment.size))
|
||||
}
|
||||
if (notYetDownloaded) {
|
||||
if (expired) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_not_downloaded_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_not_downloaded))
|
||||
}
|
||||
} else if (downloading) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_downloading_x_percent, attachment.progress))
|
||||
} else if (deleted) {
|
||||
if (expired) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_deleted))
|
||||
}
|
||||
} else if (failed) {
|
||||
if (expired) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed_expired))
|
||||
} else if (expires) {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed_expires_x, formatDateShort(attachment.expires!!)))
|
||||
} else {
|
||||
infos.add(context.getString(R.string.detail_item_download_info_download_failed))
|
||||
}
|
||||
}
|
||||
return if (infos.size > 0) {
|
||||
"$name\n${infos.joinToString(", ")}"
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeRenderAttachmentImage(context: Context, bitmap: Bitmap?) {
|
||||
if (bitmap == null) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
try {
|
||||
attachmentImageView.setImageBitmap(bitmap)
|
||||
attachmentImageView.setOnClickListener {
|
||||
val loadImage = { view: ImageView, image: Bitmap -> view.setImageBitmap(image) }
|
||||
StfalconImageViewer.Builder(context, listOf(bitmap), loadImage)
|
||||
.allowZooming(true)
|
||||
.withTransitionFrom(attachmentImageView)
|
||||
.withHiddenStatusBar(false)
|
||||
.show()
|
||||
}
|
||||
attachmentImageView.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
attachmentImageView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun openFile(context: Context, attachment: Attachment): Boolean {
|
||||
if (!canOpenAttachment(attachment)) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_apk), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
return true
|
||||
}
|
||||
Log.d(TAG, "Opening file ${attachment.contentUri}")
|
||||
try {
|
||||
val contentUri = Uri.parse(attachment.contentUri)
|
||||
val intent = Intent(Intent.ACTION_VIEW, contentUri)
|
||||
intent.setDataAndType(contentUri, attachment.type ?: "application/octet-stream") // Required for Android <= P
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_not_found), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveFile(context: Context, attachment: Attachment): Boolean {
|
||||
Log.d(TAG, "Copying file ${attachment.contentUri}")
|
||||
try {
|
||||
val resolver = context.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, attachment.name)
|
||||
if (attachment.type != null) {
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, attachment.type)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
put(MediaStore.MediaColumns.IS_DOWNLOAD, 1)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1) // While downloading
|
||||
}
|
||||
}
|
||||
val inUri = Uri.parse(attachment.contentUri)
|
||||
val inFile = resolver.openInputStream(inUri) ?: throw Exception("Cannot open input stream")
|
||||
val outUri = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
val file = ensureSafeNewFile(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), attachment.name)
|
||||
FileProvider.getUriForFile(context, DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file)
|
||||
} else {
|
||||
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
resolver.insert(contentUri, values) ?: throw Exception("Cannot insert content")
|
||||
}
|
||||
val outFile = resolver.openOutputStream(outUri) ?: throw Exception("Cannot open output stream")
|
||||
inFile.use { it.copyTo(outFile) }
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||
values.clear() // See #116 to avoid "movement" error
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
resolver.update(outUri, values, null, null)
|
||||
}
|
||||
val actualName = fileName(context, outUri.toString(), attachment.name)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_saved_successfully, actualName), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to save file: ${e.message}", e)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_save, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteFile(context: Context, notification: Notification, attachment: Attachment): Boolean {
|
||||
try {
|
||||
val contentUri = Uri.parse(attachment.contentUri)
|
||||
val resolver = context.applicationContext.contentResolver
|
||||
val deleted = resolver.delete(contentUri, null, null) > 0
|
||||
if (!deleted) throw Exception("no rows deleted")
|
||||
val newAttachment = attachment.copy(
|
||||
contentUri = null,
|
||||
progress = ATTACHMENT_PROGRESS_DELETED
|
||||
)
|
||||
val newNotification = notification.copy(attachment = newAttachment)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
repository.updateNotification(newNotification)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to update notification: ${e.message}", e)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_delete, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun downloadFile(context: Context, notification: Notification): Boolean {
|
||||
val requiresPermission = Build.VERSION.SDK_INT <= Build.VERSION_CODES.P && ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED
|
||||
if (requiresPermission) {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_STORAGE_PERMISSION_FOR_DOWNLOAD)
|
||||
return true
|
||||
}
|
||||
DownloadManager.enqueue(context, notification.id, userAction = true, DownloadType.ATTACHMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun cancelDownload(context: Context, notification: Notification): Boolean {
|
||||
DownloadManager.cancel(context, notification.id)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun copyUrl(context: Context, attachment: Attachment): Boolean {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("attachment url", attachment.url)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_menu_copy_url_copied), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun copyContents(context: Context, notification: Notification): Boolean {
|
||||
copyToClipboard(context, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runAction(context: Context, notification: Notification, action: Action): Boolean {
|
||||
when (action.action) {
|
||||
ACTION_VIEW -> runViewAction(context, action)
|
||||
else -> runOtherUserAction(context, notification, action)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun runViewAction(context: Context, action: Action) {
|
||||
try {
|
||||
val url = action.url ?: return
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to start activity from URL ${action.url}", e)
|
||||
val message = if (e is ActivityNotFoundException) action.url else e.message
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_item_cannot_open_url, message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOtherUserAction(context: Context, notification: Notification, action: Action) {
|
||||
val intent = Intent(context, NotificationService.UserActionBroadcastReceiver::class.java).apply {
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_TYPE, NotificationService.BROADCAST_TYPE_USER_ACTION)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
|
||||
putExtra(NotificationService.BROADCAST_EXTRA_ACTION_ID, action.id)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun previewableImage(fileStat: FileInfo?): Boolean {
|
||||
return if (fileStat != null) fileStat.size <= IMAGE_PREVIEW_MAX_BYTES else false
|
||||
}
|
||||
}
|
||||
|
||||
object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() {
|
||||
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."
|
||||
}
|
||||
}
|
|
@ -1,519 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.text.TextUtils
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.*
|
||||
import androidx.preference.Preference.OnPreferenceClickListener
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.msg.DownloadAttachmentWorker
|
||||
import io.heckel.ntfy.msg.NotificationService
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Subscription settings
|
||||
*/
|
||||
class DetailSettingsActivity : AppCompatActivity() {
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var serviceManager: SubscriberServiceManager
|
||||
private lateinit var settingsFragment: SettingsFragment
|
||||
private lateinit var notificationService: NotificationService
|
||||
private var subscriptionId: Long = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
Log.d(TAG, "Create $this")
|
||||
|
||||
repository = Repository.getInstance(this)
|
||||
serviceManager = SubscriberServiceManager(this)
|
||||
notificationService = NotificationService(this)
|
||||
subscriptionId = intent.getLongExtra(DetailActivity.EXTRA_SUBSCRIPTION_ID, 0)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
settingsFragment = SettingsFragment() // Empty constructor!
|
||||
settingsFragment.arguments = Bundle().apply {
|
||||
this.putLong(DetailActivity.EXTRA_SUBSCRIPTION_ID, subscriptionId)
|
||||
}
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.settings_layout, settingsFragment)
|
||||
.commit()
|
||||
}
|
||||
|
||||
// Title
|
||||
val displayName = intent.getStringExtra(DetailActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME) ?: return
|
||||
title = displayName
|
||||
|
||||
// Show 'Back' button
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
finish() // Return to previous activity when nav "back" is pressed!
|
||||
return true
|
||||
}
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var resolver: ContentResolver
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var serviceManager: SubscriberServiceManager
|
||||
private lateinit var notificationService: NotificationService
|
||||
private lateinit var subscription: Subscription
|
||||
|
||||
private lateinit var iconSetPref: Preference
|
||||
private lateinit var openChannelsPref: Preference
|
||||
private lateinit var iconSetLauncher: ActivityResultLauncher<String>
|
||||
private lateinit var iconRemovePref: Preference
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.detail_preferences, rootKey)
|
||||
|
||||
// Dependencies (Fragments need a default constructor)
|
||||
repository = Repository.getInstance(requireActivity())
|
||||
serviceManager = SubscriberServiceManager(requireActivity())
|
||||
notificationService = NotificationService(requireActivity())
|
||||
resolver = requireContext().applicationContext.contentResolver
|
||||
|
||||
// Create result launcher for custom icon (must be created in onCreatePreferences() directly)
|
||||
iconSetLauncher = createIconPickLauncher()
|
||||
|
||||
// Load subscription and users
|
||||
val subscriptionId = arguments?.getLong(DetailActivity.EXTRA_SUBSCRIPTION_ID) ?: return
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
subscription = repository.getSubscription(subscriptionId) ?: return@withContext
|
||||
activity?.runOnUiThread {
|
||||
loadView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadView() {
|
||||
if (subscription.upAppId == null) {
|
||||
loadInstantPref()
|
||||
loadMutedUntilPref()
|
||||
loadMinPriorityPref()
|
||||
loadAutoDeletePref()
|
||||
loadInsistentMaxPriorityPref()
|
||||
loadIconSetPref()
|
||||
loadIconRemovePref()
|
||||
if (notificationService.channelsSupported()) {
|
||||
loadDedicatedChannelsPrefs()
|
||||
loadOpenChannelsPrefs()
|
||||
}
|
||||
} else {
|
||||
val notificationsHeaderId = context?.getString(R.string.detail_settings_notifications_header_key) ?: return
|
||||
val notificationsHeader: PreferenceCategory? = findPreference(notificationsHeaderId)
|
||||
notificationsHeader?.isVisible = false
|
||||
}
|
||||
loadDisplayNamePref()
|
||||
loadTopicUrlPref()
|
||||
}
|
||||
|
||||
private fun loadInstantPref() {
|
||||
val appBaseUrl = getString(R.string.app_base_url)
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_instant_key) ?: return
|
||||
val pref: SwitchPreference? = findPreference(prefId)
|
||||
pref?.isVisible = BuildConfig.FIREBASE_AVAILABLE && subscription.baseUrl == appBaseUrl
|
||||
pref?.isChecked = subscription.instant
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
save(subscription.copy(instant = value), refresh = true)
|
||||
}
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
return subscription.instant
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { preference ->
|
||||
if (preference.isChecked) {
|
||||
getString(R.string.detail_settings_notifications_instant_summary_on)
|
||||
} else {
|
||||
getString(R.string.detail_settings_notifications_instant_summary_off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadDedicatedChannelsPrefs() {
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_dedicated_channels_key) ?: return
|
||||
val pref: SwitchPreference? = findPreference(prefId)
|
||||
pref?.isVisible = true
|
||||
pref?.isChecked = subscription.dedicatedChannels
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
save(subscription.copy(dedicatedChannels = value))
|
||||
if (value) {
|
||||
notificationService.createSubscriptionNotificationChannels(subscription)
|
||||
} else {
|
||||
notificationService.deleteSubscriptionNotificationChannels(subscription)
|
||||
}
|
||||
openChannelsPref.isVisible = value
|
||||
}
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
return subscription.dedicatedChannels
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { preference ->
|
||||
if (preference.isChecked) {
|
||||
getString(R.string.detail_settings_notifications_dedicated_channels_summary_on)
|
||||
} else {
|
||||
getString(R.string.detail_settings_notifications_dedicated_channels_summary_off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOpenChannelsPrefs() {
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_open_channels_key) ?: return
|
||||
openChannelsPref = findPreference(prefId) ?: return
|
||||
openChannelsPref.isVisible = subscription.dedicatedChannels
|
||||
openChannelsPref.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
openChannelsPref.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
|
||||
val settingsIntent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().applicationContext.packageName)
|
||||
startActivity(settingsIntent);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMutedUntilPref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_muted_until_key) ?: return
|
||||
val pref: ListPreference? = findPreference(prefId)
|
||||
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
|
||||
pref?.value = subscription.mutedUntil.toString()
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val mutedUntilValue = value?.toLongOrNull() ?:return
|
||||
when (mutedUntilValue) {
|
||||
Repository.MUTED_UNTIL_SHOW_ALL -> save(subscription.copy(mutedUntil = mutedUntilValue))
|
||||
Repository.MUTED_UNTIL_FOREVER -> save(subscription.copy(mutedUntil = mutedUntilValue))
|
||||
Repository.MUTED_UNTIL_TOMORROW -> {
|
||||
val date = Calendar.getInstance()
|
||||
date.add(Calendar.DAY_OF_MONTH, 1)
|
||||
date.set(Calendar.HOUR_OF_DAY, 8)
|
||||
date.set(Calendar.MINUTE, 30)
|
||||
date.set(Calendar.SECOND, 0)
|
||||
date.set(Calendar.MILLISECOND, 0)
|
||||
save(subscription.copy(mutedUntil = date.timeInMillis/1000))
|
||||
}
|
||||
else -> {
|
||||
val mutedUntilTimestamp = System.currentTimeMillis()/1000 + mutedUntilValue * 60
|
||||
save(subscription.copy(mutedUntil = mutedUntilTimestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return subscription.mutedUntil.toString()
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<ListPreference> {
|
||||
when (val mutedUntilValue = subscription.mutedUntil) {
|
||||
Repository.MUTED_UNTIL_SHOW_ALL -> getString(R.string.settings_notifications_muted_until_show_all)
|
||||
Repository.MUTED_UNTIL_FOREVER -> getString(R.string.settings_notifications_muted_until_forever)
|
||||
else -> {
|
||||
val formattedDate = formatDateShort(mutedUntilValue)
|
||||
getString(R.string.settings_notifications_muted_until_x, formattedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMinPriorityPref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_min_priority_key) ?: return
|
||||
val pref: ListPreference? = findPreference(prefId)
|
||||
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
|
||||
pref?.value = subscription.minPriority.toString()
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val minPriorityValue = value?.toIntOrNull() ?:return
|
||||
save(subscription.copy(minPriority = minPriorityValue))
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return subscription.minPriority.toString()
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<ListPreference> { preference ->
|
||||
var value = preference.value.toIntOrNull() ?: Repository.MIN_PRIORITY_USE_GLOBAL
|
||||
val global = value == Repository.MIN_PRIORITY_USE_GLOBAL
|
||||
if (value == Repository.MIN_PRIORITY_USE_GLOBAL) {
|
||||
value = repository.getMinPriority()
|
||||
}
|
||||
val summary = when (value) {
|
||||
PRIORITY_MIN -> getString(R.string.settings_notifications_min_priority_summary_any)
|
||||
PRIORITY_MAX -> getString(R.string.settings_notifications_min_priority_summary_max)
|
||||
else -> {
|
||||
val minPriorityString = toPriorityString(requireContext(), value)
|
||||
getString(R.string.settings_notifications_min_priority_summary_x_or_higher, value, minPriorityString)
|
||||
}
|
||||
}
|
||||
maybeAppendGlobal(summary, global)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAutoDeletePref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_auto_delete_key) ?: return
|
||||
val pref: ListPreference? = findPreference(prefId)
|
||||
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
|
||||
pref?.value = subscription.autoDelete.toString()
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val seconds = value?.toLongOrNull() ?:return
|
||||
save(subscription.copy(autoDelete = seconds))
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return subscription.autoDelete.toString()
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<ListPreference> { preference ->
|
||||
var seconds = preference.value.toLongOrNull() ?: Repository.AUTO_DELETE_USE_GLOBAL
|
||||
val global = seconds == Repository.AUTO_DELETE_USE_GLOBAL
|
||||
if (global) {
|
||||
seconds = repository.getAutoDeleteSeconds()
|
||||
}
|
||||
val summary = when (seconds) {
|
||||
Repository.AUTO_DELETE_NEVER -> getString(R.string.settings_notifications_auto_delete_summary_never)
|
||||
Repository.AUTO_DELETE_ONE_DAY_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_day)
|
||||
Repository.AUTO_DELETE_THREE_DAYS_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_three_days)
|
||||
Repository.AUTO_DELETE_ONE_WEEK_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_week)
|
||||
Repository.AUTO_DELETE_ONE_MONTH_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_month)
|
||||
Repository.AUTO_DELETE_THREE_MONTHS_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_three_months)
|
||||
else -> getString(R.string.settings_notifications_auto_delete_summary_one_month) // Must match default const
|
||||
}
|
||||
maybeAppendGlobal(summary, global)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInsistentMaxPriorityPref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_insistent_max_priority_key) ?: return
|
||||
val pref: ListPreference? = findPreference(prefId)
|
||||
pref?.isVisible = true
|
||||
pref?.value = subscription.insistent.toString()
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val intValue = value?.toIntOrNull() ?:return
|
||||
save(subscription.copy(insistent = intValue))
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return subscription.insistent.toString()
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<ListPreference> { preference ->
|
||||
val value = preference.value.toIntOrNull() ?: Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL
|
||||
val global = value == Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL
|
||||
val enabled = if (global) repository.getInsistentMaxPriorityEnabled() else value == Repository.INSISTENT_MAX_PRIORITY_ENABLED
|
||||
val summary = if (enabled) {
|
||||
getString(R.string.settings_notifications_insistent_max_priority_summary_enabled)
|
||||
} else {
|
||||
getString(R.string.settings_notifications_insistent_max_priority_summary_disabled)
|
||||
}
|
||||
maybeAppendGlobal(summary, global)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadIconSetPref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_appearance_icon_set_key) ?: return
|
||||
iconSetPref = findPreference(prefId) ?: return
|
||||
iconSetPref.isVisible = subscription.icon == null
|
||||
iconSetPref.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
iconSetPref.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
iconSetLauncher.launch("image/*")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadIconRemovePref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_appearance_icon_remove_key) ?: return
|
||||
iconRemovePref = findPreference(prefId) ?: return
|
||||
iconRemovePref.isVisible = subscription.icon != null
|
||||
iconRemovePref.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
iconRemovePref.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
iconRemovePref.isVisible = false
|
||||
iconSetPref.isVisible = true
|
||||
deleteIcon(subscription.icon)
|
||||
save(subscription.copy(icon = null))
|
||||
true
|
||||
}
|
||||
|
||||
// Set icon (if it exists)
|
||||
if (subscription.icon != null) {
|
||||
try {
|
||||
val bitmap = subscription.icon!!.readBitmapFromUri(requireContext())
|
||||
iconRemovePref.icon = bitmap.toDrawable(resources)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to set icon ${subscription.icon}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadDisplayNamePref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_appearance_display_name_key) ?: return
|
||||
val pref: EditTextPreference? = findPreference(prefId)
|
||||
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
|
||||
pref?.text = subscription.displayName
|
||||
pref?.dialogMessage = getString(R.string.detail_settings_appearance_display_name_message, topicShortUrl(subscription.baseUrl, subscription.topic))
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val displayName = if (value != "") value else null
|
||||
val newSubscription = subscription.copy(displayName = displayName)
|
||||
save(newSubscription)
|
||||
// Update activity title
|
||||
activity?.runOnUiThread {
|
||||
activity?.title = displayName(newSubscription)
|
||||
}
|
||||
// Update dedicated notification channel
|
||||
if (newSubscription.dedicatedChannels) {
|
||||
notificationService.createSubscriptionNotificationChannels(newSubscription)
|
||||
}
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return subscription.displayName ?: ""
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { provider ->
|
||||
if (TextUtils.isEmpty(provider.text)) {
|
||||
getString(
|
||||
R.string.detail_settings_appearance_display_name_default_summary,
|
||||
displayName(subscription)
|
||||
)
|
||||
} else {
|
||||
provider.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadTopicUrlPref() {
|
||||
// Topic URL
|
||||
val topicUrlPrefId = context?.getString(R.string.detail_settings_about_topic_url_key) ?: return
|
||||
val topicUrlPref: Preference? = findPreference(topicUrlPrefId)
|
||||
val topicUrl = topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||
topicUrlPref?.summary = topicUrl
|
||||
topicUrlPref?.onPreferenceClickListener = OnPreferenceClickListener {
|
||||
val context = context ?: return@OnPreferenceClickListener false
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("topic url", topicUrl)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast
|
||||
.makeText(context, getString(R.string.detail_settings_about_topic_url_copied_to_clipboard_message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun createIconPickLauncher(): ActivityResultLauncher<String> {
|
||||
return registerForActivityResult(ActivityResultContracts.GetContent()) { inputUri ->
|
||||
if (inputUri == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val outputUri = createUri() ?: return@launch
|
||||
try {
|
||||
// Early size & mime type check
|
||||
val mimeType = resolver.getType(inputUri)
|
||||
if (!supportedImage(mimeType)) {
|
||||
throw IOException("unknown image type or not supported")
|
||||
}
|
||||
val stat = fileStat(requireContext(), inputUri) // May throw
|
||||
if (stat.size > SUBSCRIPTION_ICON_MAX_SIZE_BYTES) {
|
||||
throw IOException("image too large, max supported is ${SUBSCRIPTION_ICON_MAX_SIZE_BYTES/1024/1024}MB")
|
||||
}
|
||||
|
||||
// Write to cache storage
|
||||
val inputStream = resolver.openInputStream(inputUri) ?: throw IOException("Couldn't open content URI for reading")
|
||||
val outputStream = resolver.openOutputStream(outputUri) ?: throw IOException("Couldn't open content URI for writing")
|
||||
inputStream.use {
|
||||
it.copyTo(outputStream)
|
||||
}
|
||||
|
||||
// Read image, check dimensions
|
||||
val bitmap = outputUri.readBitmapFromUri(requireContext())
|
||||
if (bitmap.width > SUBSCRIPTION_ICON_MAX_WIDTH || bitmap.height > SUBSCRIPTION_ICON_MAX_HEIGHT) {
|
||||
throw IOException("image exceeds max dimensions of ${SUBSCRIPTION_ICON_MAX_WIDTH}x${SUBSCRIPTION_ICON_MAX_HEIGHT}")
|
||||
}
|
||||
|
||||
// Display "remove" preference
|
||||
iconRemovePref.icon = bitmap.toDrawable(resources)
|
||||
iconRemovePref.isVisible = true
|
||||
iconSetPref.isVisible = false
|
||||
|
||||
// Finally, save (this is last!)
|
||||
save(subscription.copy(icon = outputUri.toString()))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Saving icon failed", e)
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(context, getString(R.string.detail_settings_appearance_icon_error_saving, e.message), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUri(): Uri? {
|
||||
val dir = File(requireContext().cacheDir, SUBSCRIPTION_ICONS)
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
return null
|
||||
}
|
||||
val file = File(dir, subscription.id.toString())
|
||||
return FileProvider.getUriForFile(requireContext(), DownloadAttachmentWorker.FILE_PROVIDER_AUTHORITY, file)
|
||||
}
|
||||
|
||||
private fun deleteIcon(uri: String?) {
|
||||
if (uri == null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolver.delete(Uri.parse(uri), null, null)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to delete $uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(newSubscription: Subscription, refresh: Boolean = false) {
|
||||
subscription = newSubscription
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.updateSubscription(newSubscription)
|
||||
if (refresh) {
|
||||
SubscriberServiceManager.refresh(requireContext())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeAppendGlobal(summary: String, global: Boolean): String {
|
||||
return if (global) {
|
||||
summary + " (" + getString(R.string.detail_settings_global_setting_suffix) + ")"
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfyDetailSettingsActiv"
|
||||
private const val SUBSCRIPTION_ICONS = "subscriptionIcons"
|
||||
private const val SUBSCRIPTION_ICON_MAX_SIZE_BYTES = 4194304
|
||||
private const val SUBSCRIPTION_ICON_MAX_WIDTH = 2048
|
||||
private const val SUBSCRIPTION_ICON_MAX_HEIGHT = 2048
|
||||
}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DetailViewModel(private val repository: Repository) : ViewModel() {
|
||||
fun list(subscriptionId: Long): LiveData<List<Notification>> {
|
||||
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 <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
with(modelClass){
|
||||
when {
|
||||
isAssignableFrom(DetailViewModel::class.java) -> DetailViewModel(repository) as T
|
||||
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,711 +1,146 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.ActionMode
|
||||
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.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import androidx.work.*
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonObject
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.firebase.FirebaseMessenger
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.DownloadManager
|
||||
import io.heckel.ntfy.msg.DownloadType
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.service.SubscriberService
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
import io.heckel.ntfy.util.*
|
||||
import io.heckel.ntfy.work.DeleteWorker
|
||||
import io.heckel.ntfy.work.PollWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import io.heckel.ntfy.data.*
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.SubscribeListener, NotificationFragment.NotificationSettingsListener {
|
||||
private val viewModel by viewModels<SubscriptionsViewModel> {
|
||||
SubscriptionsViewModelFactory((application as Application).repository)
|
||||
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
||||
private val uniqueWorkName = "connectionWorker"
|
||||
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> {
|
||||
SubscriptionsViewModelFactory()
|
||||
}
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val api = ApiService()
|
||||
private val messenger = FirebaseMessenger()
|
||||
|
||||
// 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 actionMode: ActionMode? = null
|
||||
private var workManager: WorkManager? = null // Context-dependent
|
||||
private var dispatcher: NotificationDispatcher? = null // Context-dependent
|
||||
private var appBaseUrl: String? = null // Context-dependent
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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)
|
||||
setContentView(R.layout.main_activity)
|
||||
|
||||
// Action bar
|
||||
title = getString(R.string.main_action_bar_title)
|
||||
supportActionBar?.setIcon(R.drawable.ntfy) // FIXME this doesn't work
|
||||
|
||||
// Floating action button ("+")
|
||||
fab = findViewById(R.id.fab)
|
||||
val fab: View = findViewById(R.id.fab)
|
||||
fab.setOnClickListener {
|
||||
onSubscribeButtonClick()
|
||||
onAddButtonClick()
|
||||
}
|
||||
|
||||
// Swipe to refresh
|
||||
mainListContainer = findViewById(R.id.main_subscriptions_list_container)
|
||||
mainListContainer.setOnRefreshListener { refreshAllSubscriptions() }
|
||||
mainListContainer.setColorSchemeResources(Colors.refreshProgressIndicator)
|
||||
|
||||
// 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)
|
||||
// Update main list based on topicsViewModel (& its datasource/livedata)
|
||||
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text)
|
||||
val adapter = SubscriptionsAdapter(this) { subscription -> onUnsubscribe(subscription) }
|
||||
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
|
||||
mainList.adapter = adapter
|
||||
|
||||
viewModel.list().observe(this) {
|
||||
it?.let { subscriptions ->
|
||||
// Update main list
|
||||
adapter.submitList(subscriptions as MutableList<Subscription>)
|
||||
subscriptionsViewModel.list().observe(this) {
|
||||
it?.let {
|
||||
adapter.submitList(it as MutableList<Subscription>)
|
||||
if (it.isEmpty()) {
|
||||
mainListContainer.visibility = View.GONE
|
||||
noEntries.visibility = View.VISIBLE
|
||||
mainList.visibility = View.GONE
|
||||
noSubscriptionsText.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 banner + WebSocket banner
|
||||
showHideBatteryBanner(subscriptions)
|
||||
showHideWebSocketBanner(subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
// Add scrub terms to log (in case it gets exported) // FIXME this should be in Log.getFormatted
|
||||
repository.getUsersLiveData().observe(this) {
|
||||
it?.let { users ->
|
||||
users.forEach { u ->
|
||||
Log.addScrubTerm(shortUrl(u.baseUrl), Log.TermType.Domain)
|
||||
Log.addScrubTerm(u.username, Log.TermType.Username)
|
||||
Log.addScrubTerm(u.password, Log.TermType.Password)
|
||||
mainList.visibility = View.VISIBLE
|
||||
noSubscriptionsText.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
SubscriberServiceManager.refresh(this)
|
||||
}
|
||||
|
||||
// Battery banner
|
||||
val batteryBanner = findViewById<View>(R.id.main_banner_battery) // Banner visibility is toggled in onResume()
|
||||
val dontAskAgainButton = findViewById<Button>(R.id.main_banner_battery_dontaskagain)
|
||||
val askLaterButton = findViewById<Button>(R.id.main_banner_battery_ask_later)
|
||||
val fixNowButton = findViewById<Button>(R.id.main_banner_battery_fix_now)
|
||||
dontAskAgainButton.setOnClickListener {
|
||||
batteryBanner.visibility = View.GONE
|
||||
repository.setBatteryOptimizationsRemindTime(Repository.BATTERY_OPTIMIZATIONS_REMIND_TIME_NEVER)
|
||||
}
|
||||
askLaterButton.setOnClickListener {
|
||||
batteryBanner.visibility = View.GONE
|
||||
repository.setBatteryOptimizationsRemindTime(System.currentTimeMillis() + ONE_DAY_MILLIS)
|
||||
}
|
||||
fixNowButton.setOnClickListener {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket banner
|
||||
val wsBanner = findViewById<View>(R.id.main_banner_websocket) // Banner visibility is toggled in onResume()
|
||||
val wsText = findViewById<TextView>(R.id.main_banner_websocket_text)
|
||||
val wsDismissButton = findViewById<Button>(R.id.main_banner_websocket_dontaskagain)
|
||||
val wsRemindButton = findViewById<Button>(R.id.main_banner_websocket_remind_later)
|
||||
val wsEnableButton = findViewById<Button>(R.id.main_banner_websocket_enable)
|
||||
wsText.movementMethod = LinkMovementMethod.getInstance() // Make links clickable
|
||||
wsDismissButton.setOnClickListener {
|
||||
wsBanner.visibility = View.GONE
|
||||
repository.setWebSocketRemindTime(Repository.WEBSOCKET_REMIND_TIME_NEVER)
|
||||
}
|
||||
wsRemindButton.setOnClickListener {
|
||||
wsBanner.visibility = View.GONE
|
||||
repository.setWebSocketRemindTime(System.currentTimeMillis() + ONE_DAY_MILLIS)
|
||||
}
|
||||
wsEnableButton.setOnClickListener {
|
||||
repository.setConnectionProtocol(Repository.CONNECTION_PROTOCOL_WS)
|
||||
SubscriberServiceManager(this).restart()
|
||||
wsBanner.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Create notification channels right away, so we can configure them immediately after installing the app
|
||||
dispatcher?.init()
|
||||
|
||||
// Subscribe to control Firebase channel (so we can re-start the foreground service if it dies)
|
||||
messenger.subscribe(ApiService.CONTROL_TOPIC)
|
||||
|
||||
// Darrkkkk mode
|
||||
AppCompatDelegate.setDefaultNightMode(repository.getDarkMode())
|
||||
|
||||
// Background things
|
||||
schedulePeriodicPollWorker()
|
||||
schedulePeriodicServiceRestartWorker()
|
||||
schedulePeriodicDeleteWorker()
|
||||
|
||||
// Permissions
|
||||
maybeRequestNotificationPermission()
|
||||
}
|
||||
|
||||
private fun maybeRequestNotificationPermission() {
|
||||
// Android 13 (SDK 33) requires that we ask for permission to post notifications
|
||||
// https://developer.android.com/develop/ui/views/notifications/notification-permission
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_DENIED) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
showHideNotificationMenuItems()
|
||||
redrawList()
|
||||
}
|
||||
|
||||
private fun showHideBatteryBanner(subscriptions: List<Subscription>) {
|
||||
val hasInstantSubscriptions = subscriptions.count { it.instant } > 0
|
||||
val batteryRemindTimeReached = repository.getBatteryOptimizationsRemindTime() < System.currentTimeMillis()
|
||||
val ignoringOptimizations = isIgnoringBatteryOptimizations(this@MainActivity)
|
||||
val showBanner = hasInstantSubscriptions && batteryRemindTimeReached && !ignoringOptimizations
|
||||
val batteryBanner = findViewById<View>(R.id.main_banner_battery)
|
||||
batteryBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
|
||||
Log.d(TAG, "Battery: ignoring optimizations = $ignoringOptimizations (we want this to be true); instant subscriptions = $hasInstantSubscriptions; remind time reached = $batteryRemindTimeReached; banner = $showBanner")
|
||||
}
|
||||
|
||||
private fun showHideWebSocketBanner(subscriptions: List<Subscription>) {
|
||||
val hasSelfHostedSubscriptions = subscriptions.count { it.baseUrl != appBaseUrl } > 0
|
||||
val usingWebSockets = repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS
|
||||
val wsRemindTimeReached = repository.getWebSocketRemindTime() < System.currentTimeMillis()
|
||||
val showBanner = hasSelfHostedSubscriptions && wsRemindTimeReached && !usingWebSockets
|
||||
val wsBanner = findViewById<View>(R.id.main_banner_websocket)
|
||||
wsBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun schedulePeriodicPollWorker() {
|
||||
val workerVersion = repository.getPollWorkerVersion()
|
||||
val workPolicy = if (workerVersion == PollWorker.VERSION) {
|
||||
Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
repository.setPollWorkerVersion(PollWorker.VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val work = PeriodicWorkRequestBuilder<PollWorker>(POLL_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.setConstraints(constraints)
|
||||
.addTag(PollWorker.TAG)
|
||||
.addTag(PollWorker.WORK_NAME_PERIODIC_ALL)
|
||||
.build()
|
||||
Log.d(TAG, "Poll worker: Scheduling period work every $POLL_WORKER_INTERVAL_MINUTES minutes")
|
||||
workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC_ALL, workPolicy, work)
|
||||
}
|
||||
|
||||
private fun schedulePeriodicDeleteWorker() {
|
||||
val workerVersion = repository.getDeleteWorkerVersion()
|
||||
val workPolicy = if (workerVersion == DeleteWorker.VERSION) {
|
||||
Log.d(TAG, "Delete worker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "Delete worker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
repository.setDeleteWorkerVersion(DeleteWorker.VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<DeleteWorker>(DELETE_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(DeleteWorker.TAG)
|
||||
.addTag(DeleteWorker.WORK_NAME_PERIODIC_ALL)
|
||||
.build()
|
||||
Log.d(TAG, "Delete worker: Scheduling period work every $DELETE_WORKER_INTERVAL_MINUTES minutes")
|
||||
workManager!!.enqueueUniquePeriodicWork(DeleteWorker.WORK_NAME_PERIODIC_ALL, workPolicy, work)
|
||||
}
|
||||
|
||||
private fun schedulePeriodicServiceRestartWorker() {
|
||||
val workerVersion = repository.getAutoRestartWorkerVersion()
|
||||
val workPolicy = if (workerVersion == SubscriberService.SERVICE_START_WORKER_VERSION) {
|
||||
Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy")
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
} else {
|
||||
Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy")
|
||||
repository.setAutoRestartWorkerVersion(SubscriberService.SERVICE_START_WORKER_VERSION)
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
}
|
||||
val work = PeriodicWorkRequestBuilder<SubscriberServiceManager.ServiceStartWorker>(SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
|
||||
.addTag(SubscriberService.TAG)
|
||||
.addTag(SubscriberService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
.build()
|
||||
Log.d(TAG, "ServiceStartWorker: Scheduling period work every $SERVICE_START_WORKER_INTERVAL_MINUTES minutes")
|
||||
workManager?.enqueueUniquePeriodicWork(SubscriberService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
|
||||
// Set up notification channel
|
||||
createNotificationChannel()
|
||||
subscriptionsViewModel.setListener { n -> displayNotification(n) }
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main_action_bar, menu)
|
||||
this.menu = menu
|
||||
showHideNotificationMenuItems()
|
||||
checkSubscriptionsMuted() // This is done here, because then we know that we've initialized the menu
|
||||
menuInflater.inflate(R.menu.main_action_bar_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun checkSubscriptionsMuted(delayMillis: Long = 0L) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
delay(delayMillis) // Just to be sure we've initialized all the things, we wait a bit ...
|
||||
Log.d(TAG, "Checking global and subscription-specific 'muted until' timestamp")
|
||||
|
||||
// Check global
|
||||
val changed = repository.checkGlobalMutedUntil()
|
||||
if (changed) {
|
||||
Log.d(TAG, "Global muted until timestamp expired; updating prefs")
|
||||
showHideNotificationMenuItems()
|
||||
}
|
||||
|
||||
// Check subscriptions
|
||||
var rerenderList = false
|
||||
repository.getSubscriptions().forEach { subscription ->
|
||||
val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil
|
||||
if (mutedUntilExpired) {
|
||||
Log.d(TAG, "Subscription ${subscription.id}: Muted until timestamp expired, updating subscription")
|
||||
val newSubscription = subscription.copy(mutedUntil = 0L)
|
||||
repository.updateSubscription(newSubscription)
|
||||
rerenderList = true
|
||||
}
|
||||
}
|
||||
if (rerenderList) {
|
||||
redrawList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showHideNotificationMenuItems() {
|
||||
if (!this::menu.isInitialized) {
|
||||
return
|
||||
}
|
||||
val mutedUntilSeconds = repository.getGlobalMutedUntil()
|
||||
runOnUiThread {
|
||||
// Show/hide in-app rate widget
|
||||
val rateAppItem = menu.findItem(R.id.main_menu_rate)
|
||||
rateAppItem.isVisible = BuildConfig.RATE_APP_AVAILABLE
|
||||
|
||||
// Pause notification icons
|
||||
val notificationsEnabledItem = menu.findItem(R.id.main_menu_notifications_enabled)
|
||||
val notificationsDisabledUntilItem = menu.findItem(R.id.main_menu_notifications_disabled_until)
|
||||
val notificationsDisabledForeverItem = menu.findItem(R.id.main_menu_notifications_disabled_forever)
|
||||
notificationsEnabledItem?.isVisible = mutedUntilSeconds == 0L
|
||||
notificationsDisabledForeverItem?.isVisible = mutedUntilSeconds == 1L
|
||||
notificationsDisabledUntilItem?.isVisible = mutedUntilSeconds > 1L
|
||||
if (mutedUntilSeconds > 1L) {
|
||||
val formattedDate = formatDateShort(mutedUntilSeconds)
|
||||
notificationsDisabledUntilItem?.title = getString(R.string.main_menu_notifications_disabled_until, formattedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.main_menu_notifications_enabled -> {
|
||||
onNotificationSettingsClick(enable = false)
|
||||
R.id.menu_action_source -> {
|
||||
// startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
|
||||
enqueueConnectionWorker()
|
||||
true
|
||||
}
|
||||
R.id.main_menu_notifications_disabled_forever -> {
|
||||
onNotificationSettingsClick(enable = true)
|
||||
true
|
||||
}
|
||||
R.id.main_menu_notifications_disabled_until -> {
|
||||
onNotificationSettingsClick(enable = true)
|
||||
true
|
||||
}
|
||||
R.id.main_menu_settings -> {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
true
|
||||
}
|
||||
R.id.main_menu_report_bug -> {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_report_bug_url))))
|
||||
true
|
||||
}
|
||||
R.id.main_menu_rate -> {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageName")))
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.main_menu_donate -> {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_donate_url))))
|
||||
true
|
||||
}
|
||||
R.id.main_menu_docs -> {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_docs_url))))
|
||||
R.id.menu_action_website -> {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_website_url))))
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNotificationSettingsClick(enable: Boolean) {
|
||||
if (!enable) {
|
||||
Log.d(TAG, "Showing global notification settings dialog")
|
||||
val notificationFragment = NotificationFragment()
|
||||
notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
|
||||
} else {
|
||||
Log.d(TAG, "Re-enabling global notifications")
|
||||
onNotificationMutedUntilChanged(Repository.MUTED_UNTIL_SHOW_ALL)
|
||||
private fun onUnsubscribe(subscription: Subscription) {
|
||||
subscriptionsViewModel.remove(subscription)
|
||||
}
|
||||
|
||||
private fun onAddButtonClick() {
|
||||
val newFragment = AddFragment(this)
|
||||
newFragment.show(supportFragmentManager, "AddFragment")
|
||||
}
|
||||
|
||||
override fun onAddSubscription(topic: String, baseUrl: String) {
|
||||
val subscription = Subscription(Random.nextLong(), topic, baseUrl, Status.CONNECTING, 0)
|
||||
subscriptionsViewModel.add(subscription)
|
||||
}
|
||||
|
||||
private fun enqueueConnectionWorker() {
|
||||
val workRequest =
|
||||
PeriodicWorkRequestBuilder<ConnectionWorker>(1, TimeUnit.MINUTES)
|
||||
.build()
|
||||
WorkManager
|
||||
.getInstance(this)
|
||||
.enqueueUniquePeriodicWork(uniqueWorkName, ExistingPeriodicWorkPolicy.KEEP, workRequest)
|
||||
}
|
||||
|
||||
private fun displayNotification(n: Notification) {
|
||||
val channelId = getString(R.string.notification_channel_id)
|
||||
val notification = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ntfy)
|
||||
.setContentTitle(topicShortUrl(n.subscription))
|
||||
.setContentText(n.message)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.build()
|
||||
with(NotificationManagerCompat.from(this)) {
|
||||
notify(Random.nextInt(), notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
|
||||
repository.setGlobalMutedUntil(mutedUntilTimestamp)
|
||||
showHideNotificationMenuItems()
|
||||
runOnUiThread {
|
||||
redrawList() // Update the "muted until" icons
|
||||
when (mutedUntilTimestamp) {
|
||||
0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
|
||||
1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
|
||||
else -> {
|
||||
val formattedDate = formatDateShort(mutedUntilTimestamp)
|
||||
Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
private fun createNotificationChannel() {
|
||||
// Create the NotificationChannel, but only on API 26+ because
|
||||
// the NotificationChannel class is new and not in the support library
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = getString(R.string.notification_channel_id)
|
||||
val name = getString(R.string.notification_channel_name)
|
||||
val descriptionText = getString(R.string.notification_channel_name)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubscribeButtonClick() {
|
||||
val newFragment = AddFragment()
|
||||
newFragment.show(supportFragmentManager, AddFragment.TAG)
|
||||
}
|
||||
|
||||
override fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) {
|
||||
Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)} (instant = $instant)")
|
||||
|
||||
// Add subscription to database
|
||||
val subscription = Subscription(
|
||||
id = randomSubscriptionId(),
|
||||
baseUrl = baseUrl,
|
||||
topic = topic,
|
||||
instant = instant,
|
||||
dedicatedChannels = false,
|
||||
mutedUntil = 0,
|
||||
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
|
||||
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
|
||||
insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL,
|
||||
lastNotificationId = null,
|
||||
icon = null,
|
||||
upAppId = null,
|
||||
upConnectorToken = null,
|
||||
displayName = null,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = Date().time/1000
|
||||
)
|
||||
viewModel.add(subscription)
|
||||
|
||||
// Subscribe to Firebase topic if ntfy.sh (even if instant, just to be sure!)
|
||||
if (baseUrl == appBaseUrl) {
|
||||
Log.d(TAG, "Subscribing to Firebase topic $topic")
|
||||
messenger.subscribe(topic)
|
||||
}
|
||||
|
||||
// Fetch cached messages
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
|
||||
notifications.forEach { notification ->
|
||||
repository.addNotification(notification)
|
||||
if (notification.icon != null) {
|
||||
DownloadManager.enqueue(this@MainActivity, notification.id, userAction = false, DownloadType.ICON)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to detail view after adding it
|
||||
onSubscriptionItemClick(subscription)
|
||||
}
|
||||
|
||||
private fun onSubscriptionItemClick(subscription: Subscription) {
|
||||
if (actionMode != null) {
|
||||
handleActionModeClick(subscription)
|
||||
} else if (subscription.upAppId != null) { // UnifiedPush
|
||||
startDetailSettingsView(subscription)
|
||||
} else {
|
||||
startDetailView(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubscriptionItemLongClick(subscription: Subscription) {
|
||||
if (actionMode == null) {
|
||||
beginActionMode(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAllSubscriptions() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Log.d(TAG, "Polling for new notifications")
|
||||
var errors = 0
|
||||
var errorMessage = "" // First error
|
||||
var newNotificationsCount = 0
|
||||
repository.getSubscriptions().forEach { subscription ->
|
||||
Log.d(TAG, "subscription: ${subscription}")
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
|
||||
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
|
||||
newNotifications.forEach { notification ->
|
||||
newNotificationsCount++
|
||||
val notificationWithId = notification.copy(notificationId = Random.nextInt())
|
||||
if (repository.addNotification(notificationWithId)) {
|
||||
dispatcher?.dispatch(subscription, notificationWithId)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val topic = displayName(subscription)
|
||||
if (errorMessage == "") errorMessage = "$topic: ${e.message}"
|
||||
errors++
|
||||
}
|
||||
}
|
||||
val toastMessage = if (errors > 0) {
|
||||
getString(R.string.refresh_message_error, errors, errorMessage)
|
||||
} else if (newNotificationsCount == 0) {
|
||||
getString(R.string.refresh_message_no_results)
|
||||
} else {
|
||||
getString(R.string.refresh_message_result, newNotificationsCount)
|
||||
}
|
||||
runOnUiThread {
|
||||
Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show()
|
||||
mainListContainer.isRefreshing = false
|
||||
}
|
||||
Log.d(TAG, "Finished polling for new notifications")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDetailView(subscription: Subscription) {
|
||||
Log.d(TAG, "Entering detail view for subscription $subscription")
|
||||
|
||||
val intent = Intent(this, DetailActivity::class.java)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_ID, subscription.id)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(subscription))
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
|
||||
intent.putExtra(EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun startDetailSettingsView(subscription: Subscription) {
|
||||
Log.d(TAG, "Opening subscription settings for ${topicShortUrl(subscription.baseUrl, subscription.topic)}")
|
||||
|
||||
val intent = Intent(this, DetailSettingsActivity::class.java)
|
||||
intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
|
||||
intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
|
||||
intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
|
||||
intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(subscription))
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
|
||||
private fun handleActionModeClick(subscription: Subscription) {
|
||||
adapter.toggleSelection(subscription.id)
|
||||
if (adapter.selected.size == 0) {
|
||||
finishActionMode()
|
||||
} else {
|
||||
actionMode!!.title = adapter.selected.size.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
this.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?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
|
||||
return when (item?.itemId) {
|
||||
R.id.main_action_mode_delete -> {
|
||||
onMultiDeleteClick()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMultiDeleteClick() {
|
||||
Log.d(DetailActivity.TAG, "Showing multi-delete dialog for selected items")
|
||||
|
||||
val builder = AlertDialog.Builder(this)
|
||||
val dialog = builder
|
||||
.setMessage(R.string.main_action_mode_delete_dialog_message)
|
||||
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
|
||||
adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) }
|
||||
finishActionMode()
|
||||
}
|
||||
.setNegativeButton(R.string.main_action_mode_delete_dialog_cancel) { _, _ ->
|
||||
finishActionMode()
|
||||
}
|
||||
.create()
|
||||
dialog.setOnShowListener {
|
||||
dialog
|
||||
.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
.dangerButton(this)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
endActionModeAndRedraw()
|
||||
}
|
||||
|
||||
private fun beginActionMode(subscription: Subscription) {
|
||||
actionMode = startActionMode(this)
|
||||
adapter.toggleSelection(subscription.id)
|
||||
|
||||
// Fade out FAB
|
||||
fab.alpha = 1f
|
||||
fab
|
||||
.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
fab.visibility = View.GONE
|
||||
}
|
||||
})
|
||||
|
||||
// Fade status bar color
|
||||
val fromColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
|
||||
val toColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
|
||||
fadeStatusBarColor(window, fromColor, toColor)
|
||||
}
|
||||
|
||||
private fun finishActionMode() {
|
||||
actionMode!!.finish()
|
||||
endActionModeAndRedraw()
|
||||
}
|
||||
|
||||
private fun endActionModeAndRedraw() {
|
||||
actionMode = null
|
||||
adapter.selected.clear()
|
||||
redrawList()
|
||||
|
||||
// Fade in FAB
|
||||
fab.alpha = 0f
|
||||
fab.visibility = View.VISIBLE
|
||||
fab
|
||||
.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
fab.visibility = View.VISIBLE // Required to replace the old listener
|
||||
}
|
||||
})
|
||||
|
||||
// Fade status bar color
|
||||
val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
|
||||
val toColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
|
||||
fadeStatusBarColor(window, fromColor, toColor)
|
||||
}
|
||||
|
||||
private fun redrawList() {
|
||||
if (!this::mainList.isInitialized) {
|
||||
return
|
||||
}
|
||||
adapter.notifyItemRangeChanged(0, adapter.currentList.size)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfyMainActivity"
|
||||
const val EXTRA_SUBSCRIPTION_ID = "subscriptionId"
|
||||
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
|
||||
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
|
||||
const val EXTRA_SUBSCRIPTION_DISPLAY_NAME = "subscriptionDisplayName"
|
||||
const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant"
|
||||
const val EXTRA_SUBSCRIPTION_MUTED_UNTIL = "subscriptionMutedUntil"
|
||||
const val ANIMATION_DURATION = 80L
|
||||
const val ONE_DAY_MILLIS = 86400000L
|
||||
|
||||
// As per documentation: The minimum repeat interval that can be defined is 15 minutes
|
||||
// (same as the JobScheduler API), but in practice 15 doesn't work. Using 16 here.
|
||||
// Thanks to varunon9 (https://gist.github.com/varunon9/f2beec0a743c96708eb0ef971a9ff9cd) for this!
|
||||
|
||||
const val POLL_WORKER_INTERVAL_MINUTES = 60L
|
||||
const val DELETE_WORKER_INTERVAL_MINUTES = 8 * 60L
|
||||
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,138 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.ConnectionState
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.util.displayName
|
||||
import io.heckel.ntfy.util.readBitmapFromUriOrNull
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
class MainAdapter(private val repository: Repository, private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
|
||||
ListAdapter<Subscription, MainAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
|
||||
val selected = mutableSetOf<Long>() // Subscription IDs
|
||||
|
||||
/* Creates and inflates view and return TopicViewHolder. */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.fragment_main_item, parent, false)
|
||||
return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick)
|
||||
}
|
||||
|
||||
/* Gets current topic and uses it to bind view. */
|
||||
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
|
||||
val subscription = getItem(position)
|
||||
holder.bind(subscription)
|
||||
}
|
||||
|
||||
fun toggleSelection(subscriptionId: Long) {
|
||||
if (selected.contains(subscriptionId)) {
|
||||
selected.remove(subscriptionId)
|
||||
} else {
|
||||
selected.add(subscriptionId)
|
||||
}
|
||||
|
||||
if (selected.size != 0) {
|
||||
val listIds = currentList.map { subscription -> subscription.id }
|
||||
val subscriptionPosition = listIds.indexOf(subscriptionId)
|
||||
notifyItemChanged(subscriptionPosition)
|
||||
}
|
||||
}
|
||||
|
||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
||||
class SubscriptionViewHolder(itemView: View, private val repository: Repository, private val selected: Set<Long>, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
private var subscription: Subscription? = null
|
||||
private val context: Context = itemView.context
|
||||
private val imageView: ImageView = itemView.findViewById(R.id.main_item_image)
|
||||
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
|
||||
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
|
||||
private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
|
||||
private val notificationDisabledUntilImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_until_image)
|
||||
private val notificationDisabledForeverImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_forever_image)
|
||||
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
|
||||
private val newItemsView: TextView = itemView.findViewById(R.id.main_item_new)
|
||||
|
||||
fun bind(subscription: Subscription) {
|
||||
this.subscription = subscription
|
||||
val isUnifiedPush = subscription.upAppId != null
|
||||
var statusMessage = if (isUnifiedPush) {
|
||||
context.getString(R.string.main_item_status_unified_push, subscription.upAppId)
|
||||
} else if (subscription.totalCount == 1) {
|
||||
context.getString(R.string.main_item_status_text_one, subscription.totalCount)
|
||||
} else {
|
||||
context.getString(R.string.main_item_status_text_not_one, subscription.totalCount)
|
||||
}
|
||||
if (subscription.instant && subscription.state == ConnectionState.CONNECTING) {
|
||||
statusMessage += ", " + context.getString(R.string.main_item_status_reconnecting)
|
||||
}
|
||||
val date = Date(subscription.lastActive * 1000)
|
||||
val dateStr = DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
||||
val moreThanOneDay = System.currentTimeMillis()/1000 - subscription.lastActive > 24 * 60 * 60
|
||||
val sameDay = dateStr == DateFormat.getDateInstance(DateFormat.SHORT).format(Date()) // Omg this is horrible
|
||||
val dateText = if (subscription.lastActive == 0L) {
|
||||
""
|
||||
} else if (sameDay) {
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
|
||||
} else if (!moreThanOneDay) {
|
||||
context.getString(R.string.main_item_date_yesterday)
|
||||
} else {
|
||||
dateStr
|
||||
}
|
||||
val globalMutedUntil = repository.getGlobalMutedUntil()
|
||||
val showMutedForeverIcon = (subscription.mutedUntil == 1L || globalMutedUntil == 1L) && !isUnifiedPush
|
||||
val showMutedUntilIcon = !showMutedForeverIcon && (subscription.mutedUntil > 1L || globalMutedUntil > 1L) && !isUnifiedPush
|
||||
if (subscription.icon != null) {
|
||||
imageView.setImageBitmap(subscription.icon.readBitmapFromUriOrNull(context))
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.ic_sms_gray_24dp)
|
||||
}
|
||||
nameView.text = displayName(subscription)
|
||||
statusView.text = statusMessage
|
||||
dateView.text = dateText
|
||||
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
|
||||
notificationDisabledUntilImageView.visibility = if (showMutedUntilIcon) View.VISIBLE else View.GONE
|
||||
notificationDisabledForeverImageView.visibility = if (showMutedForeverIcon) View.VISIBLE else View.GONE
|
||||
instantImageView.visibility = if (subscription.instant && BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
|
||||
if (isUnifiedPush || subscription.newCount == 0) {
|
||||
newItemsView.visibility = View.GONE
|
||||
} else {
|
||||
newItemsView.visibility = View.VISIBLE
|
||||
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
|
||||
}
|
||||
itemView.setOnClickListener { onClick(subscription) }
|
||||
itemView.setOnLongClickListener { onLongClick(subscription); true }
|
||||
if (selected.contains(subscription.id)) {
|
||||
itemView.setBackgroundResource(Colors.itemSelectedBackground(context))
|
||||
} else {
|
||||
itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
|
||||
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfyMainAdapter"
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowInsetsController
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.databinding.MainSettingsActivityBinding
|
||||
|
||||
class MainSettingsActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var mBinding: MainSettingsActivityBinding
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
mBinding = MainSettingsActivityBinding.inflate(layoutInflater)
|
||||
setContentView(mBinding.root)
|
||||
|
||||
setupToolbar()
|
||||
setSystemBarsAppearance()
|
||||
showPreferencesFragment()
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
mBinding.toolbar.setNavigationOnClickListener {
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun setSystemBarsAppearance() {
|
||||
val insetsController = window.insetsController ?: return
|
||||
|
||||
val isLightMode = isSystemInLightMode()
|
||||
if (isLightMode) {
|
||||
insetsController.setSystemBarsAppearance(
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
|
||||
)
|
||||
insetsController.setSystemBarsAppearance(
|
||||
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
|
||||
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
|
||||
)
|
||||
} else {
|
||||
insetsController.setSystemBarsAppearance(
|
||||
0,
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
|
||||
)
|
||||
insetsController.setSystemBarsAppearance(
|
||||
0,
|
||||
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSystemInLightMode(): Boolean {
|
||||
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
return nightModeFlags != Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
private fun showPreferencesFragment() {
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.fragment_container, PreferencesFragment())
|
||||
.commit()
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.up.Distributor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
|
||||
fun list(): LiveData<List<Subscription>> {
|
||||
return repository.getSubscriptionsLiveData()
|
||||
}
|
||||
|
||||
fun listIdsWithInstantStatus(): LiveData<Set<Pair<Long, Boolean>>> {
|
||||
return repository.getSubscriptionIdsWithInstantStatusLiveData()
|
||||
}
|
||||
|
||||
fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
||||
repository.addSubscription(subscription)
|
||||
}
|
||||
|
||||
fun remove(context: Context, subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
|
||||
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
|
||||
if (subscription.upAppId != null && subscription.upConnectorToken != null) {
|
||||
val distributor = Distributor(context)
|
||||
distributor.sendUnregistered(subscription.upAppId, subscription.upConnectorToken)
|
||||
}
|
||||
repository.removeAllNotifications(subscriptionId)
|
||||
repository.removeSubscription(subscriptionId)
|
||||
if (subscription.icon != null) {
|
||||
val resolver = context.applicationContext.contentResolver
|
||||
try {
|
||||
resolver.delete(Uri.parse(subscription.icon), null, null)
|
||||
} catch (_: Exception) {
|
||||
// Don't care
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(baseUrl: String, topic: String): Subscription? {
|
||||
return repository.getSubscription(baseUrl, topic)
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionsViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
with(modelClass){
|
||||
when {
|
||||
isAssignableFrom(SubscriptionsViewModel::class.java) -> SubscriptionsViewModel(repository) as T
|
||||
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,97 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.RadioButton
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class NotificationFragment : DialogFragment() {
|
||||
var settingsListener: NotificationSettingsListener? = null
|
||||
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var muteFor30minButton: RadioButton
|
||||
private lateinit var muteFor1hButton: RadioButton
|
||||
private lateinit var muteFor2hButton: RadioButton
|
||||
private lateinit var muteFor8hButton: RadioButton
|
||||
private lateinit var muteUntilTomorrowButton: RadioButton
|
||||
private lateinit var muteForeverButton: RadioButton
|
||||
|
||||
interface NotificationSettingsListener {
|
||||
fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
if (settingsListener == null) {
|
||||
settingsListener = activity as NotificationSettingsListener
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
if (activity == null) {
|
||||
throw IllegalStateException("Activity cannot be null")
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
repository = Repository.getInstance(requireContext())
|
||||
|
||||
// Build root view
|
||||
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_notification_dialog, null)
|
||||
|
||||
muteFor30minButton = view.findViewById(R.id.notification_dialog_30min)
|
||||
muteFor30minButton.setOnClickListener { onClickMinutes(30) }
|
||||
|
||||
muteFor1hButton = view.findViewById(R.id.notification_dialog_1h)
|
||||
muteFor1hButton.setOnClickListener { onClickMinutes(60) }
|
||||
|
||||
muteFor2hButton = view.findViewById(R.id.notification_dialog_2h)
|
||||
muteFor2hButton.setOnClickListener { onClickMinutes(2 * 60) }
|
||||
|
||||
muteFor8hButton = view.findViewById(R.id.notification_dialog_8h)
|
||||
muteFor8hButton.setOnClickListener{ onClickMinutes(8 * 60) }
|
||||
|
||||
muteUntilTomorrowButton = view.findViewById(R.id.notification_dialog_tomorrow)
|
||||
muteUntilTomorrowButton.setOnClickListener {
|
||||
// Duplicate code in SettingsActivity, :shrug: ...
|
||||
val date = Calendar.getInstance()
|
||||
date.add(Calendar.DAY_OF_MONTH, 1)
|
||||
date.set(Calendar.HOUR_OF_DAY, 8)
|
||||
date.set(Calendar.MINUTE, 30)
|
||||
date.set(Calendar.SECOND, 0)
|
||||
date.set(Calendar.MILLISECOND, 0)
|
||||
onClick(date.timeInMillis/1000)
|
||||
}
|
||||
|
||||
muteForeverButton = view.findViewById(R.id.notification_dialog_forever)
|
||||
muteForeverButton.setOnClickListener{ onClick(Repository.MUTED_UNTIL_FOREVER) }
|
||||
|
||||
return AlertDialog.Builder(activity)
|
||||
.setView(view)
|
||||
.create()
|
||||
}
|
||||
|
||||
private fun onClickMinutes(minutes: Int) {
|
||||
onClick(System.currentTimeMillis()/1000 + minutes * 60)
|
||||
}
|
||||
|
||||
private fun onClick(mutedUntilTimestamp: Long) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
delay(150) // Another hack: Let the animation finish before dismissing the window
|
||||
settingsListener?.onNotificationMutedUntilChanged(mutedUntilTimestamp)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfyNotificationFragment"
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toolbar
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.service.SubscriberService
|
||||
import io.heckel.ntfy.util.Log
|
||||
|
||||
class PreferencesFragment : PreferenceFragmentCompat() {
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings_preferences, rootKey)
|
||||
|
||||
val preference: SwitchPreferenceCompat? =
|
||||
findPreference(getString(R.string.eos_preference_key_is_enabled))
|
||||
|
||||
preference?.setOnPreferenceChangeListener { _, newValue ->
|
||||
val isChecked = newValue as Boolean
|
||||
val intent = Intent(context, SubscriberService::class.java)
|
||||
intent.action = if (isChecked) {
|
||||
SubscriberService.Action.START.name
|
||||
} else {
|
||||
SubscriberService.Action.STOP.name
|
||||
}
|
||||
|
||||
requireContext().startService(intent)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,827 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlertDialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.text.TextUtils
|
||||
import android.widget.Button
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.Keep
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.*
|
||||
import androidx.preference.Preference.OnPreferenceClickListener
|
||||
import com.google.gson.Gson
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.backup.Backuper
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.User
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Main settings
|
||||
*
|
||||
* The "nested screen" navigation stuff (for user management) has been taken from
|
||||
* https://github.com/googlearchive/android-preferences/blob/master/app/src/main/java/com/example/androidx/preference/sample/MainActivity.kt
|
||||
*/
|
||||
class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
|
||||
UserFragment.UserDialogListener {
|
||||
private lateinit var settingsFragment: SettingsFragment
|
||||
private lateinit var userSettingsFragment: UserSettingsFragment
|
||||
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var serviceManager: SubscriberServiceManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
Log.d(TAG, "Create $this")
|
||||
|
||||
repository = Repository.getInstance(this)
|
||||
serviceManager = SubscriberServiceManager(this)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
settingsFragment = SettingsFragment() // Empty constructor!
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.settings_layout, settingsFragment)
|
||||
.commit()
|
||||
title = getString(R.string.settings_title)
|
||||
} else {
|
||||
title = savedInstanceState.getCharSequence(TITLE_TAG)
|
||||
}
|
||||
supportFragmentManager.addOnBackStackChangedListener {
|
||||
if (supportFragmentManager.backStackEntryCount == 0) {
|
||||
setTitle(R.string.settings_title)
|
||||
}
|
||||
}
|
||||
|
||||
// Show 'Back' button
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
// Save current activity title, so we can set it again after a configuration change
|
||||
outState.putCharSequence(TITLE_TAG, title)
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
if (supportFragmentManager.popBackStackImmediate()) {
|
||||
return true
|
||||
}
|
||||
return super.onSupportNavigateUp()
|
||||
}
|
||||
|
||||
override fun onPreferenceStartFragment(
|
||||
caller: PreferenceFragmentCompat,
|
||||
pref: Preference
|
||||
): Boolean {
|
||||
// Instantiate the new Fragment
|
||||
val fragmentClass = pref.fragment ?: return false
|
||||
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, fragmentClass)
|
||||
fragment.arguments = pref.extras
|
||||
|
||||
// Replace the existing Fragment with the new Fragment
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.settings_layout, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
title = pref.title
|
||||
|
||||
// Save user settings fragment for later
|
||||
if (fragment is UserSettingsFragment) {
|
||||
userSettingsFragment = fragment
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var serviceManager: SubscriberServiceManager
|
||||
private var autoDownloadSelection = AUTO_DOWNLOAD_SELECTION_NOT_SET
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.main_preferences, rootKey)
|
||||
|
||||
// Dependencies (Fragments need a default constructor)
|
||||
repository = Repository.getInstance(requireActivity())
|
||||
serviceManager = SubscriberServiceManager(requireActivity())
|
||||
autoDownloadSelection = repository.getAutoDownloadMaxSize() // Only used for <= Android P, due to permissions request
|
||||
|
||||
// Important note: We do not use the default shared prefs to store settings. Every
|
||||
// preferenceDataStore is overridden to use the repository. This is convenient, because
|
||||
// everybody has access to the repository.
|
||||
|
||||
// Notifications muted until (global)
|
||||
val mutedUntilPrefId = context?.getString(R.string.settings_notifications_muted_until_key) ?: return
|
||||
val mutedUntil: ListPreference? = findPreference(mutedUntilPrefId)
|
||||
mutedUntil?.value = repository.getGlobalMutedUntil().toString()
|
||||
mutedUntil?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val mutedUntilValue = value?.toLongOrNull() ?:return
|
||||
when (mutedUntilValue) {
|
||||
Repository.MUTED_UNTIL_SHOW_ALL -> repository.setGlobalMutedUntil(mutedUntilValue)
|
||||
Repository.MUTED_UNTIL_FOREVER -> repository.setGlobalMutedUntil(mutedUntilValue)
|
||||
Repository.MUTED_UNTIL_TOMORROW -> {
|
||||
val date = Calendar.getInstance()
|
||||
date.add(Calendar.DAY_OF_MONTH, 1)
|
||||
date.set(Calendar.HOUR_OF_DAY, 8)
|
||||
date.set(Calendar.MINUTE, 30)
|
||||
date.set(Calendar.SECOND, 0)
|
||||
date.set(Calendar.MILLISECOND, 0)
|
||||
repository.setGlobalMutedUntil(date.timeInMillis/1000)
|
||||
}
|
||||
else -> {
|
||||
val mutedUntilTimestamp = System.currentTimeMillis()/1000 + mutedUntilValue * 60
|
||||
repository.setGlobalMutedUntil(mutedUntilTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return repository.getGlobalMutedUntil().toString()
|
||||
}
|
||||
}
|
||||
mutedUntil?.summaryProvider = Preference.SummaryProvider<ListPreference> {
|
||||
when (val mutedUntilValue = repository.getGlobalMutedUntil()) {
|
||||
Repository.MUTED_UNTIL_SHOW_ALL -> getString(R.string.settings_notifications_muted_until_show_all)
|
||||
Repository.MUTED_UNTIL_FOREVER -> getString(R.string.settings_notifications_muted_until_forever)
|
||||
else -> {
|
||||
val formattedDate = formatDateShort(mutedUntilValue)
|
||||
getString(R.string.settings_notifications_muted_until_x, formattedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum priority
|
||||
val minPriorityPrefId = context?.getString(R.string.settings_notifications_min_priority_key) ?: return
|
||||
val minPriority: ListPreference? = findPreference(minPriorityPrefId)
|
||||
minPriority?.value = repository.getMinPriority().toString()
|
||||
minPriority?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val minPriorityValue = value?.toIntOrNull() ?:return
|
||||
repository.setMinPriority(minPriorityValue)
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return repository.getMinPriority().toString()
|
||||
}
|
||||
}
|
||||
minPriority?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
|
||||
when (val minPriorityValue = pref.value.toIntOrNull() ?: 1) { // 1/low means all priorities
|
||||
PRIORITY_MIN -> getString(R.string.settings_notifications_min_priority_summary_any)
|
||||
PRIORITY_MAX -> getString(R.string.settings_notifications_min_priority_summary_max)
|
||||
else -> {
|
||||
val minPriorityString = toPriorityString(requireContext(), minPriorityValue)
|
||||
getString(R.string.settings_notifications_min_priority_summary_x_or_higher, minPriorityValue, minPriorityString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep alerting for max priority
|
||||
val insistentMaxPriorityPrefId = context?.getString(R.string.settings_notifications_insistent_max_priority_key) ?: return
|
||||
val insistentMaxPriority: SwitchPreference? = findPreference(insistentMaxPriorityPrefId)
|
||||
insistentMaxPriority?.isChecked = repository.getInsistentMaxPriorityEnabled()
|
||||
insistentMaxPriority?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
repository.setInsistentMaxPriorityEnabled(value)
|
||||
}
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
return repository.getInsistentMaxPriorityEnabled()
|
||||
}
|
||||
}
|
||||
insistentMaxPriority?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
|
||||
if (pref.isChecked) {
|
||||
getString(R.string.settings_notifications_insistent_max_priority_summary_enabled)
|
||||
} else {
|
||||
getString(R.string.settings_notifications_insistent_max_priority_summary_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel settings
|
||||
val channelPrefsPrefId = context?.getString(R.string.settings_notifications_channel_prefs_key) ?: return
|
||||
val channelPrefs: Preference? = findPreference(channelPrefsPrefId)
|
||||
channelPrefs?.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
channelPrefs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
channelPrefs?.onPreferenceClickListener = OnPreferenceClickListener {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
|
||||
})
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Auto download
|
||||
val autoDownloadPrefId = context?.getString(R.string.settings_notifications_auto_download_key) ?: return
|
||||
val autoDownload: ListPreference? = findPreference(autoDownloadPrefId)
|
||||
autoDownload?.value = repository.getAutoDownloadMaxSize().toString()
|
||||
autoDownload?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val maxSize = value?.toLongOrNull() ?:return
|
||||
repository.setAutoDownloadMaxSize(maxSize)
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return repository.getAutoDownloadMaxSize().toString()
|
||||
}
|
||||
}
|
||||
autoDownload?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
|
||||
when (val maxSize = pref.value.toLongOrNull() ?: repository.getAutoDownloadMaxSize()) {
|
||||
Repository.AUTO_DOWNLOAD_NEVER -> getString(R.string.settings_notifications_auto_download_summary_never)
|
||||
Repository.AUTO_DOWNLOAD_ALWAYS -> getString(R.string.settings_notifications_auto_download_summary_always)
|
||||
else -> getString(R.string.settings_notifications_auto_download_summary_smaller_than_x, formatBytes(maxSize, decimals = 0))
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
autoDownload?.setOnPreferenceChangeListener { _, v ->
|
||||
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
|
||||
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD)
|
||||
autoDownloadSelection = v.toString().toLongOrNull() ?: repository.getAutoDownloadMaxSize()
|
||||
false // If permission is granted, auto-download will be enabled in onRequestPermissionsResult()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto delete
|
||||
val autoDeletePrefId = context?.getString(R.string.settings_notifications_auto_delete_key) ?: return
|
||||
val autoDelete: ListPreference? = findPreference(autoDeletePrefId)
|
||||
autoDelete?.value = repository.getAutoDeleteSeconds().toString()
|
||||
autoDelete?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val seconds = value?.toLongOrNull() ?:return
|
||||
repository.setAutoDeleteSeconds(seconds)
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return repository.getAutoDeleteSeconds().toString()
|
||||
}
|
||||
}
|
||||
autoDelete?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
|
||||
when (pref.value.toLongOrNull() ?: repository.getAutoDeleteSeconds()) {
|
||||
Repository.AUTO_DELETE_NEVER -> getString(R.string.settings_notifications_auto_delete_summary_never)
|
||||
Repository.AUTO_DELETE_ONE_DAY_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_day)
|
||||
Repository.AUTO_DELETE_THREE_DAYS_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_three_days)
|
||||
Repository.AUTO_DELETE_ONE_WEEK_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_week)
|
||||
Repository.AUTO_DELETE_ONE_MONTH_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_month)
|
||||
Repository.AUTO_DELETE_THREE_MONTHS_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_three_months)
|
||||
else -> getString(R.string.settings_notifications_auto_delete_summary_one_month) // Must match default const
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
val darkModePrefId = context?.getString(R.string.settings_general_dark_mode_key) ?: return
|
||||
val darkMode: ListPreference? = findPreference(darkModePrefId)
|
||||
darkMode?.value = repository.getDarkMode().toString()
|
||||
darkMode?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val darkModeValue = value?.toIntOrNull() ?: return
|
||||
repository.setDarkMode(darkModeValue)
|
||||
AppCompatDelegate.setDefaultNightMode(darkModeValue)
|
||||
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return repository.getDarkMode().toString()
|
||||
}
|
||||
}
|
||||
darkMode?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
|
||||
val darkModeValue = pref.value.toIntOrNull() ?: repository.getDarkMode()
|
||||
when (darkModeValue) {
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> getString(R.string.settings_general_dark_mode_summary_light)
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> getString(R.string.settings_general_dark_mode_summary_dark)
|
||||
else -> getString(R.string.settings_general_dark_mode_summary_system)
|
||||
}
|
||||
}
|
||||
|
||||
// Default Base URL
|
||||
val appBaseUrl = getString(R.string.app_base_url)
|
||||
val defaultBaseUrlPrefId = context?.getString(R.string.settings_general_default_base_url_key) ?: return
|
||||
val defaultBaseUrl: EditTextPreference? = findPreference(defaultBaseUrlPrefId)
|
||||
defaultBaseUrl?.text = repository.getDefaultBaseUrl() ?: ""
|
||||
defaultBaseUrl?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String, value: String?) {
|
||||
val baseUrl = value ?: return
|
||||
repository.setDefaultBaseUrl(baseUrl)
|
||||
}
|
||||
override fun getString(key: String, defValue: String?): String? {
|
||||
return repository.getDefaultBaseUrl()
|
||||
}
|
||||
}
|
||||
defaultBaseUrl?.setOnBindEditTextListener { editText ->
|
||||
editText.addTextChangedListener(AfterChangedTextWatcher {
|
||||
val okayButton: Button = editText.rootView.findViewById(android.R.id.button1)
|
||||
val value = editText.text.toString()
|
||||
okayButton.isEnabled = value.isEmpty() || validUrl(value)
|
||||
})
|
||||
}
|
||||
defaultBaseUrl?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref ->
|
||||
if (TextUtils.isEmpty(pref.text)) {
|
||||
getString(R.string.settings_general_default_base_url_default_summary, appBaseUrl)
|
||||
} else {
|
||||
pref.text
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast enabled
|
||||
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
|
||||
val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)
|
||||
broadcastEnabled?.isChecked = repository.getBroadcastEnabled()
|
||||
broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
repository.setBroadcastEnabled(value)
|
||||
}
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
return repository.getBroadcastEnabled()
|
||||
}
|
||||
}
|
||||
broadcastEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
|
||||
if (pref.isChecked) {
|
||||
getString(R.string.settings_advanced_broadcast_summary_enabled)
|
||||
} else {
|
||||
getString(R.string.settings_advanced_broadcast_summary_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable UnifiedPush
|
||||
val unifiedPushEnabledPrefId = context?.getString(R.string.settings_advanced_unifiedpush_key) ?: return
|
||||
val unifiedPushEnabled: SwitchPreference? = findPreference(unifiedPushEnabledPrefId)
|
||||
unifiedPushEnabled?.isChecked = repository.getUnifiedPushEnabled()
|
||||
unifiedPushEnabled?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
repository.setUnifiedPushEnabled(value)
|
||||
}
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
return repository.getUnifiedPushEnabled()
|
||||
}
|
||||
}
|
||||
unifiedPushEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
|
||||
if (pref.isChecked) {
|
||||
getString(R.string.settings_advanced_unifiedpush_summary_enabled)
|
||||
} else {
|
||||
getString(R.string.settings_advanced_unifiedpush_summary_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Export logs
|
||||
val exportLogsPrefId = context?.getString(R.string.settings_advanced_export_logs_key) ?: return
|
||||
val exportLogs: ListPreference? = findPreference(exportLogsPrefId)
|
||||
exportLogs?.isVisible = Log.getRecord()
|
||||
exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
|
||||
when (v) {
|
||||
EXPORT_LOGS_COPY_ORIGINAL -> copyLogsToClipboard(scrub = false)
|
||||
EXPORT_LOGS_COPY_SCRUBBED -> copyLogsToClipboard(scrub = true)
|
||||
EXPORT_LOGS_UPLOAD_ORIGINAL -> uploadLogsToNopaste(scrub = false)
|
||||
EXPORT_LOGS_UPLOAD_SCRUBBED -> uploadLogsToNopaste(scrub = true)
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Clear logs
|
||||
val clearLogsPrefId = context?.getString(R.string.settings_advanced_clear_logs_key) ?: return
|
||||
val clearLogs: Preference? = findPreference(clearLogsPrefId)
|
||||
clearLogs?.isVisible = Log.getRecord()
|
||||
clearLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
clearLogs?.onPreferenceClickListener = OnPreferenceClickListener {
|
||||
deleteLogs()
|
||||
false
|
||||
}
|
||||
|
||||
// Record logs
|
||||
val recordLogsPrefId = context?.getString(R.string.settings_advanced_record_logs_key) ?: return
|
||||
val recordLogsEnabled: SwitchPreference? = findPreference(recordLogsPrefId)
|
||||
recordLogsEnabled?.isChecked = Log.getRecord()
|
||||
recordLogsEnabled?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putBoolean(key: String?, value: Boolean) {
|
||||
repository.setRecordLogsEnabled(value)
|
||||
Log.setRecord(value)
|
||||
exportLogs?.isVisible = value
|
||||
clearLogs?.isVisible = value
|
||||
}
|
||||
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
|
||||
return Log.getRecord()
|
||||
}
|
||||
}
|
||||
recordLogsEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
|
||||
if (pref.isChecked) {
|
||||
getString(R.string.settings_advanced_record_logs_summary_enabled)
|
||||
} else {
|
||||
getString(R.string.settings_advanced_record_logs_summary_disabled)
|
||||
}
|
||||
}
|
||||
recordLogsEnabled?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.getSubscriptions().forEach { s ->
|
||||
Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain)
|
||||
Log.addScrubTerm(s.topic)
|
||||
}
|
||||
repository.getUsers().forEach { u ->
|
||||
Log.addScrubTerm(shortUrl(u.baseUrl), Log.TermType.Domain)
|
||||
Log.addScrubTerm(u.username, Log.TermType.Username)
|
||||
Log.addScrubTerm(u.password, Log.TermType.Password)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// Backup
|
||||
val backuper = Backuper(requireContext())
|
||||
val backupPrefId = context?.getString(R.string.settings_backup_restore_backup_key) ?: return
|
||||
val backup: ListPreference? = findPreference(backupPrefId)
|
||||
var backupSelection = BACKUP_EVERYTHING
|
||||
val backupResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
|
||||
if (uri == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
when (backupSelection) {
|
||||
BACKUP_EVERYTHING -> backuper.backup(uri)
|
||||
BACKUP_EVERYTHING_NO_USERS -> backuper.backup(uri, withUsers = false)
|
||||
BACKUP_SETTINGS_ONLY -> backuper.backup(uri, withUsers = false, withSubscriptions = false)
|
||||
}
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(context, getString(R.string.settings_backup_restore_backup_successful), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Backup failed", e)
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(context, getString(R.string.settings_backup_restore_backup_failed, e.message), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
backup?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
|
||||
backup?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
|
||||
backupSelection = v.toString()
|
||||
val timestamp = SimpleDateFormat("yyMMdd-HHmm").format(Date())
|
||||
val suggestedFilename = when (backupSelection) {
|
||||
BACKUP_EVERYTHING_NO_USERS -> "ntfy-backup-no-users-$timestamp.json"
|
||||
BACKUP_SETTINGS_ONLY -> "ntfy-settings-$timestamp.json"
|
||||
else -> "ntfy-backup-$timestamp.json"
|
||||
}
|
||||
backupResultLauncher.launch(suggestedFilename)
|
||||
false
|
||||
}
|
||||
backup?.onPreferenceClickListener = OnPreferenceClickListener {
|
||||
true
|
||||
}
|
||||
|
||||
// Restore
|
||||
val restorePrefId = context?.getString(R.string.settings_backup_restore_restore_key) ?: return
|
||||
val restore: Preference? = findPreference(restorePrefId)
|
||||
val restoreResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val currentDarkMode = repository.getDarkMode()
|
||||
backuper.restore(uri)
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(context, getString(R.string.settings_backup_restore_restore_successful), Toast.LENGTH_LONG).show()
|
||||
requireActivity().recreate()
|
||||
val newDarkMode = repository.getDarkMode()
|
||||
if (newDarkMode != currentDarkMode) {
|
||||
AppCompatDelegate.setDefaultNightMode(newDarkMode)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Restore failed", e)
|
||||
requireActivity().runOnUiThread {
|
||||
Toast.makeText(context, getString(R.string.settings_backup_restore_restore_failed, e.message), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
restore?.onPreferenceClickListener = OnPreferenceClickListener {
|
||||
// Overly open mime type filter (because of https://github.com/binwiederhier/ntfy/issues/223).
|
||||
// This filter could likely be stricter if we'd write the mime type properly in Backuper.backup(),
|
||||
// but just in case we want to restore from a file we didn't write outselves, we'll keep this "*/*".
|
||||
restoreResultLauncher.launch("*/*")
|
||||
true
|
||||
}
|
||||
|
||||
// Connection protocol
|
||||
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
|
||||
val connectionProtocol: ListPreference? = findPreference(connectionProtocolPrefId)
|
||||
connectionProtocol?.value = repository.getConnectionProtocol()
|
||||
connectionProtocol?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val proto = value ?: repository.getConnectionProtocol()
|
||||
repository.setConnectionProtocol(proto)
|
||||
restartService()
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return repository.getConnectionProtocol()
|
||||
}
|
||||
}
|
||||
connectionProtocol?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
|
||||
when (pref.value) {
|
||||
Repository.CONNECTION_PROTOCOL_WS -> getString(R.string.settings_advanced_connection_protocol_summary_ws)
|
||||
else -> getString(R.string.settings_advanced_connection_protocol_summary_jsonhttp)
|
||||
}
|
||||
}
|
||||
|
||||
// Version
|
||||
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
|
||||
val versionPref: Preference? = findPreference(versionPrefId)
|
||||
val version = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)
|
||||
versionPref?.summary = version
|
||||
versionPref?.onPreferenceClickListener = OnPreferenceClickListener {
|
||||
val context = context ?: return@OnPreferenceClickListener false
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("ntfy version", version)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_about_version_copied_to_clipboard_message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoDownload() {
|
||||
val autoDownloadSelectionCopy = autoDownloadSelection
|
||||
if (autoDownloadSelectionCopy == AUTO_DOWNLOAD_SELECTION_NOT_SET) return
|
||||
val autoDownloadPrefId = context?.getString(R.string.settings_notifications_auto_download_key) ?: return
|
||||
val autoDownload: ListPreference? = findPreference(autoDownloadPrefId)
|
||||
autoDownload?.value = autoDownloadSelectionCopy.toString()
|
||||
repository.setAutoDownloadMaxSize(autoDownloadSelectionCopy)
|
||||
}
|
||||
|
||||
private fun restartService() {
|
||||
serviceManager.restart() // Service will auto-restart
|
||||
}
|
||||
|
||||
private fun copyLogsToClipboard(scrub: Boolean) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val context = context ?: return@launch
|
||||
val log = Log.getFormatted(context, scrub = scrub)
|
||||
requireActivity().runOnUiThread {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("ntfy logs", log)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (scrub) {
|
||||
showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_logs))
|
||||
} else {
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadLogsToNopaste(scrub: Boolean) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...")
|
||||
val context = context ?: return@launch
|
||||
val log = Log.getFormatted(context, scrub = scrub)
|
||||
if (log.length > EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD) {
|
||||
requireActivity().runOnUiThread {
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_advanced_export_logs_uploading), Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
val gson = Gson()
|
||||
val request = Request.Builder()
|
||||
.url(EXPORT_LOGS_UPLOAD_URL)
|
||||
.put(log.toRequestBody())
|
||||
.build()
|
||||
val client = OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw Exception("Unexpected response ${response.code}")
|
||||
}
|
||||
val body = response.body?.string()?.trim()
|
||||
if (body.isNullOrEmpty()) throw Exception("Return body is empty")
|
||||
Log.d(TAG, "Logs uploaded successfully: $body")
|
||||
val resp = gson.fromJson(body.toString(), NopasteResponse::class.java)
|
||||
requireActivity().runOnUiThread {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("logs URL", resp.url)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (scrub) {
|
||||
showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_url))
|
||||
} else {
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error uploading logs", e)
|
||||
requireActivity().runOnUiThread {
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_advanced_export_logs_error_uploading, e.message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showScrubDialog(title: String) {
|
||||
val scrubbed = Log.getScrubTerms()
|
||||
val scrubbedText = if (scrubbed.isNotEmpty()) {
|
||||
val scrubTerms = scrubbed.map { e -> "${e.key} -> ${e.value}"}.joinToString(separator = "\n")
|
||||
getString(R.string.settings_advanced_export_logs_scrub_dialog_text, scrubTerms)
|
||||
} else {
|
||||
getString(R.string.settings_advanced_export_logs_scrub_dialog_empty)
|
||||
}
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(title)
|
||||
.setMessage(scrubbedText)
|
||||
.setPositiveButton(R.string.settings_advanced_export_logs_scrub_dialog_button_ok) { _, _ -> /* Nothing */ }
|
||||
.create()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun deleteLogs() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
Log.deleteAll()
|
||||
val context = context ?: return@launch
|
||||
requireActivity().runOnUiThread {
|
||||
Toast
|
||||
.makeText(context, getString(R.string.settings_advanced_clear_logs_deleted_toast), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class NopasteResponse(val url: String)
|
||||
}
|
||||
|
||||
class UserSettingsFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var repository: Repository
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.user_preferences, rootKey)
|
||||
repository = Repository.getInstance(requireActivity())
|
||||
reload()
|
||||
}
|
||||
|
||||
data class UserWithMetadata(
|
||||
val user: User,
|
||||
val topics: List<String>
|
||||
)
|
||||
|
||||
fun reload() {
|
||||
preferenceScreen.removeAll()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val baseUrlsWithTopics = repository.getSubscriptions()
|
||||
.groupBy { it.baseUrl }
|
||||
.mapValues { e -> e.value.map { it.topic } }
|
||||
val usersByBaseUrl = repository.getUsers()
|
||||
.map { user ->
|
||||
val topics = baseUrlsWithTopics[user.baseUrl] ?: emptyList()
|
||||
UserWithMetadata(user, topics)
|
||||
}
|
||||
.groupBy { it.user.baseUrl }
|
||||
activity?.runOnUiThread {
|
||||
addUserPreferences(usersByBaseUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addUserPreferences(usersByBaseUrl: Map<String, List<UserWithMetadata>>) {
|
||||
val baseUrlsInUse = ArrayList(usersByBaseUrl.keys)
|
||||
usersByBaseUrl.forEach { entry ->
|
||||
val baseUrl = entry.key
|
||||
val users = entry.value
|
||||
|
||||
val preferenceCategory = PreferenceCategory(preferenceScreen.context)
|
||||
preferenceCategory.title = shortUrl(baseUrl)
|
||||
preferenceScreen.addPreference(preferenceCategory)
|
||||
|
||||
users.forEach { user ->
|
||||
val preference = Preference(preferenceScreen.context)
|
||||
preference.title = user.user.username
|
||||
preference.summary = if (user.topics.isEmpty()) {
|
||||
getString(R.string.settings_general_users_prefs_user_not_used)
|
||||
} else if (user.topics.size == 1) {
|
||||
getString(R.string.settings_general_users_prefs_user_used_by_one, user.topics[0])
|
||||
} else {
|
||||
getString(R.string.settings_general_users_prefs_user_used_by_many, user.topics.joinToString(", "))
|
||||
}
|
||||
preference.onPreferenceClickListener = OnPreferenceClickListener { _ ->
|
||||
activity?.let {
|
||||
UserFragment
|
||||
.newInstance(user.user, baseUrlsInUse)
|
||||
.show(it.supportFragmentManager, UserFragment.TAG)
|
||||
}
|
||||
true
|
||||
}
|
||||
preferenceCategory.addPreference(preference)
|
||||
}
|
||||
}
|
||||
|
||||
// Add user
|
||||
val userAddCategory = PreferenceCategory(preferenceScreen.context)
|
||||
userAddCategory.title = getString(R.string.settings_general_users_prefs_user_add)
|
||||
preferenceScreen.addPreference(userAddCategory)
|
||||
|
||||
val userAddPref = Preference(preferenceScreen.context)
|
||||
userAddPref.title = getString(R.string.settings_general_users_prefs_user_add_title)
|
||||
userAddPref.summary = getString(R.string.settings_general_users_prefs_user_add_summary)
|
||||
userAddPref.onPreferenceClickListener = OnPreferenceClickListener { _ ->
|
||||
activity?.let {
|
||||
UserFragment
|
||||
.newInstance(user = null, baseUrlsInUse = baseUrlsInUse)
|
||||
.show(it.supportFragmentManager, UserFragment.TAG)
|
||||
}
|
||||
true
|
||||
}
|
||||
userAddCategory.addPreference(userAddPref)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
setAutoDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddUser(dialog: DialogFragment, user: User) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.addUser(user) // New users are not used, so no service refresh required
|
||||
runOnUiThread {
|
||||
userSettingsFragment.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateUser(dialog: DialogFragment, user: User) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.updateUser(user)
|
||||
serviceManager.restart() // Editing does not change the user ID
|
||||
runOnUiThread {
|
||||
userSettingsFragment.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteUser(dialog: DialogFragment, baseUrl: String) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.deleteUser(baseUrl)
|
||||
serviceManager.restart()
|
||||
runOnUiThread {
|
||||
userSettingsFragment.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAutoDownload() {
|
||||
if (!this::settingsFragment.isInitialized) return
|
||||
settingsFragment.setAutoDownload()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfySettingsActivity"
|
||||
private const val TITLE_TAG = "title"
|
||||
private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586
|
||||
private const val AUTO_DOWNLOAD_SELECTION_NOT_SET = -99L
|
||||
private const val BACKUP_EVERYTHING = "everything"
|
||||
private const val BACKUP_EVERYTHING_NO_USERS = "everything_no_users"
|
||||
private const val BACKUP_SETTINGS_ONLY = "settings_only"
|
||||
private const val EXPORT_LOGS_COPY_ORIGINAL = "copy_original"
|
||||
private const val EXPORT_LOGS_COPY_SCRUBBED = "copy_scrubbed"
|
||||
private const val EXPORT_LOGS_UPLOAD_ORIGINAL = "upload_original"
|
||||
private const val EXPORT_LOGS_UPLOAD_SCRUBBED = "upload_scrubbed"
|
||||
private const val EXPORT_LOGS_UPLOAD_URL = "https://nopaste.net/?f=json" // Run by binwiederhier; see https://github.com/binwiederhier/pcopy
|
||||
private const val EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD = 100 * 1024 // Show "Uploading ..." if log larger than X
|
||||
}
|
||||
}
|
|
@ -1,360 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.*
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ShareActivity : AppCompatActivity() {
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val api = ApiService()
|
||||
|
||||
// File to share
|
||||
private var fileUri: Uri? = null
|
||||
|
||||
// Context-dependent things
|
||||
private lateinit var appBaseUrl: String
|
||||
private var defaultBaseUrl: String? = null
|
||||
|
||||
// UI elements
|
||||
private lateinit var menu: Menu
|
||||
private lateinit var sendItem: MenuItem
|
||||
private lateinit var contentImage: ImageView
|
||||
private lateinit var contentFileBox: View
|
||||
private lateinit var contentFileInfo: TextView
|
||||
private lateinit var contentFileIcon: ImageView
|
||||
private lateinit var contentText: TextView
|
||||
private lateinit var topicText: TextView
|
||||
private lateinit var baseUrlLayout: TextInputLayout
|
||||
private lateinit var baseUrlText: AutoCompleteTextView
|
||||
private lateinit var useAnotherServerCheckbox: CheckBox
|
||||
private lateinit var suggestedTopicsList: RecyclerView
|
||||
private lateinit var progress: ProgressBar
|
||||
private lateinit var errorText: TextView
|
||||
private lateinit var errorImage: ImageView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_share)
|
||||
|
||||
Log.init(this) // Init logs in all entry points
|
||||
Log.d(TAG, "Create $this with intent $intent")
|
||||
|
||||
// Action bar
|
||||
title = getString(R.string.share_title)
|
||||
|
||||
// Show 'Back' button
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
// Context-dependent things
|
||||
appBaseUrl = getString(R.string.app_base_url)
|
||||
defaultBaseUrl = repository.getDefaultBaseUrl()
|
||||
|
||||
// UI elements
|
||||
val root: View = findViewById(R.id.share_root_view)
|
||||
contentText = findViewById(R.id.share_content_text)
|
||||
contentImage = findViewById(R.id.share_content_image)
|
||||
contentFileBox = findViewById(R.id.share_content_file_box)
|
||||
contentFileInfo = findViewById(R.id.share_content_file_info)
|
||||
contentFileIcon = findViewById(R.id.share_content_file_icon)
|
||||
topicText = findViewById(R.id.share_topic_text)
|
||||
baseUrlLayout = findViewById(R.id.share_base_url_layout)
|
||||
baseUrlLayout.background = root.background
|
||||
baseUrlLayout.makeEndIconSmaller(resources) // Hack!
|
||||
baseUrlText = findViewById(R.id.share_base_url_text)
|
||||
baseUrlText.background = root.background
|
||||
baseUrlText.hint = defaultBaseUrl ?: appBaseUrl
|
||||
useAnotherServerCheckbox = findViewById(R.id.share_use_another_server_checkbox)
|
||||
suggestedTopicsList = findViewById(R.id.share_suggested_topics)
|
||||
progress = findViewById(R.id.share_progress)
|
||||
progress.visibility = View.GONE
|
||||
errorText = findViewById(R.id.share_error_text)
|
||||
errorText.visibility = View.GONE
|
||||
errorImage = findViewById(R.id.share_error_image)
|
||||
errorImage.visibility = View.GONE
|
||||
|
||||
val textWatcher = object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
validateInput()
|
||||
}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
// Nothing
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
// Nothing
|
||||
}
|
||||
}
|
||||
contentText.addTextChangedListener(textWatcher)
|
||||
topicText.addTextChangedListener(textWatcher)
|
||||
baseUrlText.addTextChangedListener(textWatcher)
|
||||
|
||||
// Add behavior to "use another" checkbox
|
||||
useAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
baseUrlLayout.visibility = if (isChecked) View.VISIBLE else View.GONE
|
||||
validateInput()
|
||||
}
|
||||
|
||||
// Things that need the database
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
// Populate "suggested topics"
|
||||
val subscriptions = repository.getSubscriptions()
|
||||
val lastShareTopics = repository.getLastShareTopics()
|
||||
val subscribedTopics = subscriptions
|
||||
.map { topicUrl(it.baseUrl, it.topic) }
|
||||
.toSet()
|
||||
.subtract(lastShareTopics.toSet())
|
||||
val suggestedTopics = (lastShareTopics.reversed() + subscribedTopics).distinct()
|
||||
val baseUrlsRaw = suggestedTopics
|
||||
.mapNotNull {
|
||||
try { splitTopicUrl(it).first }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
.distinct()
|
||||
val baseUrls = if (defaultBaseUrl != null) {
|
||||
baseUrlsRaw.filterNot { it == defaultBaseUrl }
|
||||
} else {
|
||||
baseUrlsRaw.filterNot { it == appBaseUrl }
|
||||
}
|
||||
suggestedTopicsList.adapter = TopicAdapter(suggestedTopics) { topicUrl ->
|
||||
try {
|
||||
val (baseUrl, topic) = splitTopicUrl(topicUrl)
|
||||
val defaultUrl = defaultBaseUrl ?: appBaseUrl
|
||||
topicText.text = topic
|
||||
if (baseUrl == defaultUrl) {
|
||||
useAnotherServerCheckbox.isChecked = false
|
||||
} else {
|
||||
useAnotherServerCheckbox.isChecked = true
|
||||
baseUrlText.setText(baseUrl)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Invalid topicUrl $topicUrl", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Add baseUrl auto-complete behavior
|
||||
val activity = this@ShareActivity
|
||||
activity.runOnUiThread {
|
||||
initBaseUrlDropdown(baseUrls, baseUrlText, baseUrlLayout)
|
||||
useAnotherServerCheckbox.isChecked = if (suggestedTopics.isNotEmpty()) {
|
||||
try {
|
||||
val (baseUrl, _) = splitTopicUrl(suggestedTopics.first())
|
||||
val defaultUrl = defaultBaseUrl ?: appBaseUrl
|
||||
baseUrl != defaultUrl
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
baseUrls.count() == 1
|
||||
}
|
||||
baseUrlLayout.visibility = if (useAnotherServerCheckbox.isChecked) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming intent
|
||||
val intent = intent ?: return
|
||||
val type = intent.type ?: return
|
||||
if (intent.action != Intent.ACTION_SEND) return
|
||||
if (type == "text/plain") {
|
||||
handleSendText(intent)
|
||||
} else if (type.startsWith("image/")) {
|
||||
handleSendImage(intent)
|
||||
} else {
|
||||
handleSendFile(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSendText(intent: Intent) {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "(no text)"
|
||||
Log.d(TAG, "Shared content is text: $text")
|
||||
contentText.text = text
|
||||
show()
|
||||
}
|
||||
|
||||
private fun handleSendImage(intent: Intent) {
|
||||
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
Log.d(TAG, "Shared content is an image with URI $fileUri")
|
||||
if (fileUri == null) {
|
||||
Log.w(TAG, "Null URI is not allowed. Aborting.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
contentImage.setImageBitmap(fileUri!!.readBitmapFromUri(applicationContext))
|
||||
contentText.text = getString(R.string.share_content_image_text)
|
||||
show(image = true)
|
||||
} catch (e: Exception) {
|
||||
fileUri = null
|
||||
contentText.text = ""
|
||||
errorText.text = getString(R.string.share_content_image_error, e.message)
|
||||
show(error = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSendFile(intent: Intent) {
|
||||
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
Log.d(TAG, "Shared content is a file with URI $fileUri")
|
||||
if (fileUri == null) {
|
||||
Log.w(TAG, "Null URI is not allowed. Aborting.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val resolver = applicationContext.contentResolver
|
||||
val info = fileStat(this, fileUri)
|
||||
val mimeType = resolver.getType(fileUri!!)
|
||||
contentText.text = getString(R.string.share_content_file_text)
|
||||
contentFileInfo.text = "${info.filename}\n${formatBytes(info.size)}"
|
||||
contentFileIcon.setImageResource(mimeTypeToIconResource(mimeType))
|
||||
show(file = true)
|
||||
} catch (e: Exception) {
|
||||
fileUri = null
|
||||
contentText.text = ""
|
||||
errorText.text = getString(R.string.share_content_file_error, e.message)
|
||||
show(error = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_share_action_bar, menu)
|
||||
this.menu = menu
|
||||
sendItem = menu.findItem(R.id.share_menu_send)
|
||||
validateInput() // Disable icon
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.share_menu_send -> {
|
||||
onShareClick()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun show(image: Boolean = false, file: Boolean = false, error: Boolean = false) {
|
||||
contentImage.visibility = if (image) View.VISIBLE else View.GONE
|
||||
contentFileBox.visibility = if (file) View.VISIBLE else View.GONE
|
||||
errorImage.visibility = if (error) View.VISIBLE else View.GONE
|
||||
errorText.visibility = if (error) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun onShareClick() {
|
||||
val baseUrl = getBaseUrl()
|
||||
val topic = topicText.text.toString()
|
||||
val message = contentText.text.toString()
|
||||
progress.visibility = View.VISIBLE
|
||||
contentText.isEnabled = false
|
||||
topicText.isEnabled = false
|
||||
useAnotherServerCheckbox.isEnabled = false
|
||||
baseUrlText.isEnabled = false
|
||||
suggestedTopicsList.isEnabled = false
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val user = repository.getUser(baseUrl)
|
||||
try {
|
||||
val (filename, body) = if (fileUri != null) {
|
||||
val stat = fileStat(this@ShareActivity, fileUri)
|
||||
val body = ContentUriRequestBody(applicationContext.contentResolver, fileUri!!, stat.size)
|
||||
Pair(stat.filename, body)
|
||||
} else {
|
||||
Pair("", null)
|
||||
}
|
||||
api.publish(
|
||||
baseUrl = baseUrl,
|
||||
topic = topic,
|
||||
user = user,
|
||||
message = message,
|
||||
body = body, // May be null
|
||||
filename = filename, // May be empty
|
||||
)
|
||||
runOnUiThread {
|
||||
repository.addLastShareTopic(topicUrl(baseUrl, topic))
|
||||
Log.addScrubTerm(shortUrl(baseUrl), Log.TermType.Domain)
|
||||
Log.addScrubTerm(topic, Log.TermType.Term)
|
||||
finish()
|
||||
Toast
|
||||
.makeText(this@ShareActivity, getString(R.string.share_successful), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errorMessage = if (e is ApiService.UnauthorizedException) {
|
||||
if (e.user != null) {
|
||||
getString(R.string.detail_test_message_error_unauthorized_user, e.user.username)
|
||||
} else {
|
||||
getString(R.string.detail_test_message_error_unauthorized_anon)
|
||||
}
|
||||
} else if (e is ApiService.EntityTooLargeException) {
|
||||
getString(R.string.detail_test_message_error_too_large)
|
||||
} else {
|
||||
getString(R.string.detail_test_message_error, e.message)
|
||||
}
|
||||
runOnUiThread {
|
||||
progress.visibility = View.GONE
|
||||
errorText.text = errorMessage
|
||||
errorImage.visibility = View.VISIBLE
|
||||
errorText.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateInput() {
|
||||
if (!this::sendItem.isInitialized || !this::useAnotherServerCheckbox.isInitialized || !this::contentText.isInitialized || !this::topicText.isInitialized) {
|
||||
return // sendItem is initialized late in onCreateOptionsMenu
|
||||
}
|
||||
val enabled = if (useAnotherServerCheckbox.isChecked) {
|
||||
contentText.text.isNotEmpty() && validTopic(topicText.text.toString()) && validUrl(baseUrlText.text.toString())
|
||||
} else {
|
||||
contentText.text.isNotEmpty() && topicText.text.isNotEmpty()
|
||||
}
|
||||
sendItem.isEnabled = enabled
|
||||
sendItem.icon?.alpha = if (enabled) 255 else 130
|
||||
}
|
||||
|
||||
private fun getBaseUrl(): String {
|
||||
return if (useAnotherServerCheckbox.isChecked) {
|
||||
baseUrlText.text.toString()
|
||||
} else {
|
||||
defaultBaseUrl ?: appBaseUrl
|
||||
}
|
||||
}
|
||||
|
||||
class TopicAdapter(private val topicUrls: List<String>, val onClick: (String) -> Unit) : RecyclerView.Adapter<TopicAdapter.ViewHolder>() {
|
||||
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.fragment_share_item, viewGroup, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
|
||||
viewHolder.topicName.text = shortUrl(topicUrls[position])
|
||||
viewHolder.view.setOnClickListener { onClick(topicUrls[position]) }
|
||||
}
|
||||
|
||||
override fun getItemCount() = topicUrls.size
|
||||
|
||||
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
|
||||
val topicName: TextView = view.findViewById(R.id.share_item_text)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfyShareActivity"
|
||||
}
|
||||
}
|
85
app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt
Normal file
85
app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt
Normal file
|
@ -0,0 +1,85 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.data.Status
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.data.topicShortUrl
|
||||
|
||||
class SubscriptionsAdapter(private val context: Context, private val onClick: (Subscription) -> Unit) :
|
||||
ListAdapter<Subscription, SubscriptionsAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
|
||||
|
||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
||||
class SubscriptionViewHolder(itemView: View, val onUnsubscribe: (Subscription) -> Unit) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
private var subscription: Subscription? = null
|
||||
private val context: Context = itemView.context
|
||||
private val nameView: TextView = itemView.findViewById(R.id.topic_text)
|
||||
private val statusView: TextView = itemView.findViewById(R.id.topic_status)
|
||||
|
||||
init {
|
||||
val popup = PopupMenu(context, itemView)
|
||||
popup.inflate(R.menu.main_item_popup_menu)
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.main_item_popup_unsubscribe -> {
|
||||
subscription?.let { s -> onUnsubscribe(s) }
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
subscription?.let { popup.show() }
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(subscription: Subscription) {
|
||||
this.subscription = subscription
|
||||
val notificationsCountMessage = if (subscription.messages == 1) {
|
||||
context.getString(R.string.main_item_status_text_one, subscription.messages)
|
||||
} else {
|
||||
context.getString(R.string.main_item_status_text_not_one, subscription.messages)
|
||||
}
|
||||
val statusText = when (subscription.status) {
|
||||
Status.CONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_connecting)
|
||||
Status.RECONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_reconnecting)
|
||||
else -> notificationsCountMessage
|
||||
}
|
||||
nameView.text = topicShortUrl(subscription)
|
||||
statusView.text = statusText
|
||||
}
|
||||
}
|
||||
|
||||
/* Creates and inflates view and return TopicViewHolder. */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.main_fragment_item, parent, false)
|
||||
return SubscriptionViewHolder(view, onClick)
|
||||
}
|
||||
|
||||
/* Gets current topic and uses it to bind view. */
|
||||
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
|
||||
val subscription = getItem(position)
|
||||
holder.bind(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
|
||||
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.heckel.ntfy.data.*
|
||||
import kotlin.collections.List
|
||||
|
||||
class SubscriptionsViewModel(private val repository: Repository, private val connectionManager: ConnectionManager) : ViewModel() {
|
||||
fun add(topic: Subscription) {
|
||||
repository.add(topic)
|
||||
connectionManager.start(topic)
|
||||
}
|
||||
|
||||
fun get(id: Long) : Subscription? {
|
||||
return repository.get(id)
|
||||
}
|
||||
|
||||
fun list(): LiveData<List<Subscription>> {
|
||||
return repository.list()
|
||||
}
|
||||
|
||||
fun remove(topic: Subscription) {
|
||||
repository.remove(topic)
|
||||
connectionManager.stop(topic)
|
||||
}
|
||||
|
||||
fun setListener(listener: NotificationListener) {
|
||||
connectionManager.setListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionsViewModelFactory : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>) =
|
||||
with(modelClass){
|
||||
when {
|
||||
isAssignableFrom(SubscriptionsViewModel::class.java) -> {
|
||||
val repository = Repository.getInstance()
|
||||
val connectionManager = ConnectionManager.getInstance(repository)
|
||||
SubscriptionsViewModel(repository, connectionManager) as T
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,183 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.User
|
||||
import io.heckel.ntfy.util.AfterChangedTextWatcher
|
||||
import io.heckel.ntfy.util.dangerButton
|
||||
import io.heckel.ntfy.util.validUrl
|
||||
|
||||
class UserFragment : DialogFragment() {
|
||||
private var user: User? = null
|
||||
private lateinit var baseUrlsInUse: ArrayList<String>
|
||||
private lateinit var listener: UserDialogListener
|
||||
|
||||
private lateinit var baseUrlView: TextInputEditText
|
||||
private lateinit var usernameView: TextInputEditText
|
||||
private lateinit var passwordView: TextInputEditText
|
||||
private lateinit var positiveButton: Button
|
||||
|
||||
interface UserDialogListener {
|
||||
fun onAddUser(dialog: DialogFragment, user: User)
|
||||
fun onUpdateUser(dialog: DialogFragment, user: User)
|
||||
fun onDeleteUser(dialog: DialogFragment, baseUrl: String)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
listener = activity as UserDialogListener
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
// Reconstruct user (if it is present in the bundle)
|
||||
val baseUrl = arguments?.getString(BUNDLE_BASE_URL)
|
||||
val username = arguments?.getString(BUNDLE_USERNAME)
|
||||
val password = arguments?.getString(BUNDLE_PASSWORD)
|
||||
|
||||
if (baseUrl != null && username != null && password != null) {
|
||||
user = User(baseUrl, username, password)
|
||||
}
|
||||
|
||||
// Required for validation
|
||||
baseUrlsInUse = arguments?.getStringArrayList(BUNDLE_BASE_URLS_IN_USE) ?: arrayListOf()
|
||||
|
||||
// Build root view
|
||||
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_user_dialog, null)
|
||||
|
||||
val positiveButtonTextResId = if (user == null) R.string.user_dialog_button_add else R.string.user_dialog_button_save
|
||||
val titleView = view.findViewById(R.id.user_dialog_title) as TextView
|
||||
val descriptionView = view.findViewById(R.id.user_dialog_description) as TextView
|
||||
|
||||
baseUrlView = view.findViewById(R.id.user_dialog_base_url)
|
||||
usernameView = view.findViewById(R.id.user_dialog_username)
|
||||
passwordView = view.findViewById(R.id.user_dialog_password)
|
||||
|
||||
if (user == null) {
|
||||
titleView.text = getString(R.string.user_dialog_title_add)
|
||||
descriptionView.text = getString(R.string.user_dialog_description_add)
|
||||
baseUrlView.visibility = View.VISIBLE
|
||||
passwordView.hint = getString(R.string.user_dialog_password_hint_add)
|
||||
} else {
|
||||
titleView.text = getString(R.string.user_dialog_title_edit)
|
||||
descriptionView.text = getString(R.string.user_dialog_description_edit)
|
||||
baseUrlView.visibility = View.GONE
|
||||
usernameView.setText(user!!.username)
|
||||
passwordView.hint = getString(R.string.user_dialog_password_hint_edit)
|
||||
}
|
||||
|
||||
// Build dialog
|
||||
val builder = AlertDialog.Builder(activity)
|
||||
.setView(view)
|
||||
.setPositiveButton(positiveButtonTextResId) { _, _ ->
|
||||
saveClicked()
|
||||
}
|
||||
.setNegativeButton(R.string.user_dialog_button_cancel) { _, _ ->
|
||||
// Do nothing
|
||||
}
|
||||
if (user != null) {
|
||||
builder.setNeutralButton(R.string.user_dialog_button_delete) { _, _ ->
|
||||
if (this::listener.isInitialized) {
|
||||
listener.onDeleteUser(this, user!!.baseUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
val dialog = builder.create()
|
||||
dialog.setOnShowListener {
|
||||
positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
|
||||
// Delete button should be red
|
||||
if (user != null) {
|
||||
dialog
|
||||
.getButton(AlertDialog.BUTTON_NEUTRAL)
|
||||
.dangerButton(requireContext())
|
||||
}
|
||||
|
||||
// Validate input when typing
|
||||
val textWatcher = AfterChangedTextWatcher {
|
||||
validateInput()
|
||||
}
|
||||
baseUrlView.addTextChangedListener(textWatcher)
|
||||
usernameView.addTextChangedListener(textWatcher)
|
||||
passwordView.addTextChangedListener(textWatcher)
|
||||
|
||||
// Focus
|
||||
if (user != null) {
|
||||
usernameView.requestFocus()
|
||||
if (usernameView.text != null) {
|
||||
usernameView.setSelection(usernameView.text!!.length)
|
||||
}
|
||||
} else {
|
||||
baseUrlView.requestFocus()
|
||||
}
|
||||
|
||||
// Validate now!
|
||||
validateInput()
|
||||
}
|
||||
|
||||
// Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun saveClicked() {
|
||||
if (!this::listener.isInitialized) return
|
||||
val baseUrl = baseUrlView.text?.toString() ?: ""
|
||||
val username = usernameView.text?.toString() ?: ""
|
||||
val password = passwordView.text?.toString() ?: ""
|
||||
if (user == null) {
|
||||
user = User(baseUrl, username, password)
|
||||
listener.onAddUser(this, user!!)
|
||||
} else {
|
||||
user = if (password.isNotEmpty()) {
|
||||
user!!.copy(username = username, password = password)
|
||||
} else {
|
||||
user!!.copy(username = username)
|
||||
}
|
||||
listener.onUpdateUser(this, user!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateInput() {
|
||||
val baseUrl = baseUrlView.text?.toString() ?: ""
|
||||
val username = usernameView.text?.toString() ?: ""
|
||||
val password = passwordView.text?.toString() ?: ""
|
||||
if (user == null) {
|
||||
positiveButton.isEnabled = validUrl(baseUrl)
|
||||
&& !baseUrlsInUse.contains(baseUrl)
|
||||
&& username.isNotEmpty() && password.isNotEmpty()
|
||||
} else {
|
||||
positiveButton.isEnabled = username.isNotEmpty() // Unchanged if left blank
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfyUserFragment"
|
||||
private const val BUNDLE_BASE_URL = "baseUrl"
|
||||
private const val BUNDLE_USERNAME = "username"
|
||||
private const val BUNDLE_PASSWORD = "password"
|
||||
private const val BUNDLE_BASE_URLS_IN_USE = "baseUrlsInUse"
|
||||
|
||||
fun newInstance(user: User?, baseUrlsInUse: ArrayList<String>): UserFragment {
|
||||
val fragment = UserFragment()
|
||||
val args = Bundle()
|
||||
args.putStringArrayList(BUNDLE_BASE_URLS_IN_USE, baseUrlsInUse)
|
||||
if (user != null) {
|
||||
args.putString(BUNDLE_BASE_URL, user.baseUrl)
|
||||
args.putString(BUNDLE_USERNAME, user.username)
|
||||
args.putString(BUNDLE_PASSWORD, user.password)
|
||||
}
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,147 +0,0 @@
|
|||
package io.heckel.ntfy.up
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.service.SubscriberServiceManager
|
||||
import io.heckel.ntfy.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* This is the UnifiedPush broadcast receiver to handle the distributor actions REGISTER and UNREGISTER.
|
||||
* See https://unifiedpush.org/spec/android/ for details.
|
||||
*/
|
||||
class BroadcastReceiver : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (context == null || intent == null) {
|
||||
return
|
||||
}
|
||||
Log.init(context) // Init in all entrypoints
|
||||
when (intent.action) {
|
||||
ACTION_REGISTER -> register(context, intent)
|
||||
ACTION_UNREGISTER -> unregister(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun register(context: Context, intent: Intent) {
|
||||
val appId = intent.getStringExtra(EXTRA_APPLICATION) ?: return
|
||||
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
|
||||
val app = context.applicationContext as Application
|
||||
val repository = app.repository
|
||||
val distributor = Distributor(app)
|
||||
Log.d(TAG, "REGISTER received for app $appId (connectorToken=$connectorToken)")
|
||||
if (!repository.getUnifiedPushEnabled()) {
|
||||
Log.w(TAG, "Refusing registration because 'EnableUP' is disabled")
|
||||
distributor.sendRegistrationFailed(appId, connectorToken, "UnifiedPush is disabled in ntfy")
|
||||
return
|
||||
}
|
||||
if (appId.isBlank()) {
|
||||
Log.w(TAG, "Refusing registration: Empty application")
|
||||
distributor.sendRegistrationFailed(appId, connectorToken, "Empty application string")
|
||||
return
|
||||
}
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// We're doing all of this inside a critical section, because of possible races.
|
||||
// See https://github.com/binwiederhier/ntfy/issues/230 for details.
|
||||
|
||||
mutex.withLock {
|
||||
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
||||
if (existingSubscription != null) {
|
||||
if (existingSubscription.upAppId == appId) {
|
||||
val endpoint = topicUrlUp(existingSubscription.baseUrl, existingSubscription.topic)
|
||||
Log.d(TAG, "Subscription with connectorToken $connectorToken exists. Sending endpoint $endpoint.")
|
||||
distributor.sendEndpoint(appId, connectorToken, endpoint)
|
||||
} else {
|
||||
Log.d(TAG, "Subscription with connectorToken $connectorToken exists for a different app. Refusing registration.")
|
||||
distributor.sendRegistrationFailed(appId, connectorToken, "Connector token already exists")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Add subscription
|
||||
val baseUrl = repository.getDefaultBaseUrl() ?: context.getString(R.string.app_base_url)
|
||||
val topic = UP_PREFIX + randomString(TOPIC_RANDOM_ID_LENGTH)
|
||||
val endpoint = topicUrlUp(baseUrl, topic)
|
||||
val subscription = Subscription(
|
||||
id = randomSubscriptionId(),
|
||||
baseUrl = baseUrl,
|
||||
topic = topic,
|
||||
instant = true, // No Firebase, always instant!
|
||||
dedicatedChannels = false,
|
||||
mutedUntil = 0,
|
||||
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
|
||||
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
|
||||
insistent = Repository.INSISTENT_MAX_PRIORITY_USE_GLOBAL,
|
||||
lastNotificationId = null,
|
||||
icon = null,
|
||||
upAppId = appId,
|
||||
upConnectorToken = connectorToken,
|
||||
displayName = null,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = Date().time/1000
|
||||
)
|
||||
Log.d(TAG, "Adding subscription with for app $appId (connectorToken $connectorToken): $subscription")
|
||||
try {
|
||||
// Note, this may fail due to a SQL constraint exception, see https://github.com/binwiederhier/ntfy/issues/185
|
||||
repository.addSubscription(subscription)
|
||||
distributor.sendEndpoint(appId, connectorToken, endpoint)
|
||||
|
||||
// Refresh (and maybe start) foreground service
|
||||
SubscriberServiceManager.refresh(app)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to add subscription", e)
|
||||
distributor.sendRegistrationFailed(appId, connectorToken, e.message)
|
||||
}
|
||||
|
||||
// Add to log scrubber
|
||||
Log.addScrubTerm(shortUrl(baseUrl), Log.TermType.Domain)
|
||||
Log.addScrubTerm(topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregister(context: Context, intent: Intent) {
|
||||
val connectorToken = intent.getStringExtra(EXTRA_TOKEN) ?: return
|
||||
val app = context.applicationContext as Application
|
||||
val repository = app.repository
|
||||
val distributor = Distributor(app)
|
||||
Log.d(TAG, "UNREGISTER received (connectorToken=$connectorToken)")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// We're doing all of this inside a critical section, because of possible races.
|
||||
// See https://github.com/binwiederhier/ntfy/issues/230 for details.
|
||||
|
||||
mutex.withLock {
|
||||
val existingSubscription = repository.getSubscriptionByConnectorToken(connectorToken)
|
||||
if (existingSubscription == null) {
|
||||
Log.d(TAG, "Subscription with connectorToken $connectorToken does not exist. Ignoring.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Remove subscription
|
||||
Log.d(TAG, "Removing subscription ${existingSubscription.id} with connectorToken $connectorToken")
|
||||
repository.removeSubscription(existingSubscription.id)
|
||||
existingSubscription.upAppId?.let { appId -> distributor.sendUnregistered(appId, connectorToken) }
|
||||
|
||||
// Refresh (and maybe stop) foreground service
|
||||
SubscriberServiceManager.refresh(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfyUpBroadcastRecv"
|
||||
private const val UP_PREFIX = "up"
|
||||
private const val TOPIC_RANDOM_ID_LENGTH = 12
|
||||
|
||||
val mutex = Mutex() // https://github.com/binwiederhier/ntfy/issues/230
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
package io.heckel.ntfy.up
|
||||
|
||||
/**
|
||||
* Constants as defined on the specs
|
||||
* https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md
|
||||
*/
|
||||
|
||||
const val ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT"
|
||||
const val ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED"
|
||||
const val ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED"
|
||||
const val ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"
|
||||
|
||||
const val ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"
|
||||
const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"
|
||||
|
||||
const val FEATURE_BYTES_MESSAGE = "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"
|
||||
|
||||
const val EXTRA_APPLICATION = "application"
|
||||
const val EXTRA_TOKEN = "token"
|
||||
const val EXTRA_ENDPOINT = "endpoint"
|
||||
const val EXTRA_MESSAGE = "message"
|
||||
const val EXTRA_BYTES_MESSAGE = "bytesMessage"
|
|
@ -1,57 +0,0 @@
|
|||
package io.heckel.ntfy.up
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import io.heckel.ntfy.util.Log
|
||||
|
||||
/**
|
||||
* This is the UnifiedPush distributor, an amalgamation of messages to be sent as part of the spec.
|
||||
* See https://unifiedpush.org/spec/android/ for details.
|
||||
*/
|
||||
class Distributor(val context: Context) {
|
||||
fun sendMessage(app: String, connectorToken: String, message: ByteArray) {
|
||||
Log.d(TAG, "Sending MESSAGE to $app (token=$connectorToken): ${message.size} bytes")
|
||||
val broadcastIntent = Intent()
|
||||
broadcastIntent.`package` = app
|
||||
broadcastIntent.action = ACTION_MESSAGE
|
||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
||||
broadcastIntent.putExtra(EXTRA_MESSAGE, String(message)) // UTF-8
|
||||
broadcastIntent.putExtra(EXTRA_BYTES_MESSAGE, message)
|
||||
context.sendBroadcast(broadcastIntent)
|
||||
}
|
||||
|
||||
fun sendEndpoint(app: String, connectorToken: String, endpoint: String) {
|
||||
Log.d(TAG, "Sending NEW_ENDPOINT to $app (token=$connectorToken): $endpoint")
|
||||
val broadcastIntent = Intent()
|
||||
broadcastIntent.`package` = app
|
||||
broadcastIntent.action = ACTION_NEW_ENDPOINT
|
||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
||||
broadcastIntent.putExtra(EXTRA_ENDPOINT, endpoint)
|
||||
context.sendBroadcast(broadcastIntent)
|
||||
}
|
||||
|
||||
fun sendUnregistered(app: String, connectorToken: String) {
|
||||
Log.d(TAG, "Sending UNREGISTERED to $app (token=$connectorToken)")
|
||||
val broadcastIntent = Intent()
|
||||
broadcastIntent.`package` = app
|
||||
broadcastIntent.action = ACTION_UNREGISTERED
|
||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
||||
context.sendBroadcast(broadcastIntent)
|
||||
}
|
||||
|
||||
fun sendRegistrationFailed(app: String, connectorToken: String, message: String?) {
|
||||
Log.d(TAG, "Sending REGISTRATION_FAILED to $app (token=$connectorToken)")
|
||||
val broadcastIntent = Intent()
|
||||
broadcastIntent.`package` = app
|
||||
broadcastIntent.action = ACTION_REGISTRATION_FAILED
|
||||
broadcastIntent.putExtra(EXTRA_TOKEN, connectorToken)
|
||||
if (message != null) {
|
||||
broadcastIntent.putExtra(EXTRA_MESSAGE, message)
|
||||
}
|
||||
context.sendBroadcast(broadcastIntent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfyUpDistributor"
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package io.heckel.ntfy.util
|
||||
|
||||
const val ANDROID_APP_MIME_TYPE = "application/vnd.android.package-archive"
|
||||
|
||||
const val PRIORITY_MIN = 1
|
||||
const val PRIORITY_LOW = 2
|
||||
const val PRIORITY_DEFAULT = 3
|
||||
const val PRIORITY_HIGH = 4
|
||||
const val PRIORITY_MAX = 5
|
||||
|
||||
val ALL_PRIORITIES = listOf(PRIORITY_MIN, PRIORITY_LOW, PRIORITY_DEFAULT, PRIORITY_HIGH, PRIORITY_MAX)
|
|
@ -1,33 +0,0 @@
|
|||
package io.heckel.ntfy.util;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* This class represents an emoji.
|
||||
*
|
||||
* This class was originally written by Vincent DURMONT (vdurmont@gmail.com) as part of
|
||||
* https://github.com/vdurmont/emoji-java, but has since been heavily stripped and modified.
|
||||
*/
|
||||
public class Emoji {
|
||||
private final List<String> aliases;
|
||||
private final String unicode;
|
||||
|
||||
protected Emoji(List<String> aliases, byte... bytes) {
|
||||
this.aliases = Collections.unmodifiableList(aliases);
|
||||
try {
|
||||
this.unicode = new String(bytes, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getAliases() {
|
||||
return this.aliases;
|
||||
}
|
||||
|
||||
public String getUnicode() {
|
||||
return this.unicode;
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
package io.heckel.ntfy.util;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Loads the emojis from a JSON database.
|
||||
*
|
||||
* This was originally written to load
|
||||
* https://github.com/vdurmont/emoji-java/blob/master/src/main/resources/emojis.json
|
||||
*
|
||||
* But now uses
|
||||
* https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
*
|
||||
* This class was originally written by Vincent DURMONT (vdurmont@gmail.com) as part of
|
||||
* https://github.com/vdurmont/emoji-java, but has since been heavily stripped and modified.
|
||||
*/
|
||||
public class EmojiLoader {
|
||||
public static List<Emoji> loadEmojis(InputStream stream) throws IOException, JSONException {
|
||||
JSONArray emojisJSON = new JSONArray(inputStreamToString(stream));
|
||||
List<Emoji> emojis = new ArrayList<Emoji>(emojisJSON.length());
|
||||
for (int i = 0; i < emojisJSON.length(); i++) {
|
||||
Emoji emoji = buildEmojiFromJSON(emojisJSON.getJSONObject(i));
|
||||
if (emoji != null) {
|
||||
emojis.add(emoji);
|
||||
}
|
||||
}
|
||||
return emojis;
|
||||
}
|
||||
|
||||
private static String inputStreamToString(
|
||||
InputStream stream
|
||||
) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
InputStreamReader isr = new InputStreamReader(stream, "UTF-8");
|
||||
BufferedReader br = new BufferedReader(isr);
|
||||
String read;
|
||||
while((read = br.readLine()) != null) {
|
||||
sb.append(read);
|
||||
}
|
||||
br.close();
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
protected static Emoji buildEmojiFromJSON(
|
||||
JSONObject json
|
||||
) throws UnsupportedEncodingException, JSONException {
|
||||
if (!json.has("emoji")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] bytes = json.getString("emoji").getBytes("UTF-8");
|
||||
List<String> aliases = jsonArrayToStringList(json.getJSONArray("aliases"));
|
||||
return new Emoji(aliases, bytes);
|
||||
}
|
||||
|
||||
private static List<String> jsonArrayToStringList(JSONArray array) throws JSONException {
|
||||
List<String> strings = new ArrayList<String>(array.length());
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
strings.add(array.getString(i));
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
package io.heckel.ntfy.util;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Holds the loaded emojis and provides search functions.
|
||||
*
|
||||
* This class was originally written by Vincent DURMONT (vdurmont@gmail.com) as part of
|
||||
* https://github.com/vdurmont/emoji-java, but has since been heavily stripped and modified.
|
||||
*/
|
||||
public class EmojiManager {
|
||||
private static final String PATH = "/emoji.json"; // https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
private static final Map<String, Emoji> EMOJIS_BY_ALIAS = new HashMap<String, Emoji>();
|
||||
|
||||
static {
|
||||
try {
|
||||
InputStream stream = EmojiLoader.class.getResourceAsStream(PATH);
|
||||
List<Emoji> emojis = EmojiLoader.loadEmojis(stream);
|
||||
for (Emoji emoji : emojis) {
|
||||
for (String alias : emoji.getAliases()) {
|
||||
EMOJIS_BY_ALIAS.put(alias, emoji);
|
||||
}
|
||||
}
|
||||
stream.close();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static Emoji getForAlias(String alias) {
|
||||
if (alias == null || alias.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return EMOJIS_BY_ALIAS.get(trimAlias(alias));
|
||||
}
|
||||
|
||||
private static String trimAlias(String alias) {
|
||||
int len = alias.length();
|
||||
return alias.substring(
|
||||
alias.charAt(0) == ':' ? 1 : 0,
|
||||
alias.charAt(len - 1) == ':' ? len - 1 : len);
|
||||
}
|
||||
}
|
|
@ -1,212 +0,0 @@
|
|||
package io.heckel.ntfy.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.backup.Backuper
|
||||
import io.heckel.ntfy.db.Database
|
||||
import io.heckel.ntfy.db.LogDao
|
||||
import io.heckel.ntfy.db.LogEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class Log(private val logsDao: LogDao) {
|
||||
private val record: AtomicBoolean = AtomicBoolean(false)
|
||||
private val count: AtomicInteger = AtomicInteger(0)
|
||||
private val scrubNum: AtomicInteger = AtomicInteger(-1)
|
||||
private val scrubTerms = Collections.synchronizedMap(mutableMapOf<String, ReplaceTerm>())
|
||||
|
||||
private fun log(level: Int, tag: String, message: String, exception: Throwable?) {
|
||||
if (!record.get()) return
|
||||
GlobalScope.launch(Dispatchers.IO) { // FIXME This does not guarantee the log order
|
||||
logsDao.insert(LogEntry(System.currentTimeMillis(), tag, level, message, exception?.stackTraceToString()))
|
||||
val current = count.incrementAndGet()
|
||||
if (current >= PRUNE_EVERY) {
|
||||
logsDao.prune(ENTRIES_MAX)
|
||||
count.set(0) // I know there is a race here, but this is good enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFormatted(context: Context, scrub: Boolean): String {
|
||||
val backuper = Backuper(context)
|
||||
return if (scrub) {
|
||||
val logs = formatEntries(scrubEntries(logsDao.getAll()))
|
||||
val settings = scrub(backuper.settingsAsString()) ?: ""
|
||||
prependDeviceInfo(logs, settings, scrubLine = true)
|
||||
} else {
|
||||
val logs = formatEntries(logsDao.getAll())
|
||||
val settings = backuper.settingsAsString()
|
||||
prependDeviceInfo(logs, settings, scrubLine = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prependDeviceInfo(logs: String, settings: String, scrubLine: Boolean): String {
|
||||
val maybeScrubLine = if (scrubLine) "Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.\n" else ""
|
||||
return """
|
||||
This is a log of the ntfy Android app. The log shows up to 1,000 entries.
|
||||
$maybeScrubLine
|
||||
Device info:
|
||||
--
|
||||
ntfy: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR})
|
||||
OS: ${System.getProperty("os.version")}
|
||||
Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})
|
||||
Model: ${Build.DEVICE}
|
||||
Product: ${Build.PRODUCT}
|
||||
|
||||
--
|
||||
Settings:
|
||||
""".trimIndent() + "\n$settings\n\nLogs\n--\n\n$logs"
|
||||
}
|
||||
|
||||
fun addScrubTerm(term: String, type: TermType = TermType.Term) {
|
||||
if (scrubTerms[term] != null || IGNORE_TERMS.contains(term)) {
|
||||
return
|
||||
}
|
||||
if (type == TermType.Password) {
|
||||
scrubTerms[term] = ReplaceTerm(type, "********")
|
||||
return
|
||||
}
|
||||
val replaceTermIndex = scrubNum.incrementAndGet()
|
||||
val replaceTerm = REPLACE_TERMS.getOrNull(replaceTermIndex) ?: "fruit${replaceTermIndex}"
|
||||
scrubTerms[term] = ReplaceTerm(type, when (type) {
|
||||
TermType.Domain -> "$replaceTerm.example.com"
|
||||
TermType.Username -> "${replaceTerm}user"
|
||||
else -> replaceTerm
|
||||
})
|
||||
}
|
||||
|
||||
private fun scrubEntries(entries: List<LogEntry>): List<LogEntry> {
|
||||
return entries
|
||||
.map { e ->
|
||||
e.copy(
|
||||
message = scrub(e.message)!!,
|
||||
exception = scrub(e.exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrub(line: String?): String? {
|
||||
var newLine = line ?: return null
|
||||
scrubTerms.forEach { (scrubTerm, replaceTerm) ->
|
||||
newLine = newLine.replace(scrubTerm, replaceTerm.replaceTerm)
|
||||
}
|
||||
return newLine
|
||||
}
|
||||
|
||||
private fun formatEntries(entries: List<LogEntry>): String {
|
||||
return entries.joinToString(separator = "\n") { e ->
|
||||
val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(Date(e.timestamp))
|
||||
val level = when (e.level) {
|
||||
android.util.Log.DEBUG -> "D"
|
||||
android.util.Log.INFO -> "I"
|
||||
android.util.Log.WARN -> "W"
|
||||
android.util.Log.ERROR -> "E"
|
||||
else -> "?"
|
||||
}
|
||||
val tag = e.tag.format("%23s")
|
||||
val prefix = "${e.timestamp} $date $level $tag"
|
||||
val message = if (e.exception != null) {
|
||||
"${e.message}\nException:\n${e.exception}"
|
||||
} else {
|
||||
e.message
|
||||
}
|
||||
"$prefix $message"
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteAll() {
|
||||
return logsDao.deleteAll()
|
||||
}
|
||||
|
||||
enum class TermType {
|
||||
Domain, Username, Password, Term
|
||||
}
|
||||
|
||||
data class ReplaceTerm(
|
||||
val termType: TermType,
|
||||
val replaceTerm: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NtfyLog"
|
||||
private const val PRUNE_EVERY = 100
|
||||
private const val ENTRIES_MAX = 1000
|
||||
private val IGNORE_TERMS = listOf("ntfy.sh")
|
||||
private val REPLACE_TERMS = listOf(
|
||||
"banana", "kiwi", "lemon", "coconut", "avocado", "orange", "apple", "peach",
|
||||
"pineapple", "dragonfruit", "durian", "starfruit"
|
||||
)
|
||||
private var instance: Log? = null
|
||||
|
||||
fun d(tag: String, message: String, exception: Throwable? = null) {
|
||||
if (exception == null) android.util.Log.d(tag, message) else android.util.Log.d(tag, message, exception)
|
||||
getInstance()?.log(android.util.Log.DEBUG, tag, message, exception)
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String, exception: Throwable? = null) {
|
||||
if (exception == null) android.util.Log.i(tag, message) else android.util.Log.i(tag, message, exception)
|
||||
getInstance()?.log(android.util.Log.INFO, tag, message, exception)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String, exception: Throwable? = null) {
|
||||
if (exception == null) android.util.Log.w(tag, message) else android.util.Log.w(tag, message, exception)
|
||||
getInstance()?.log(android.util.Log.WARN, tag, message, exception)
|
||||
}
|
||||
|
||||
fun e(tag: String, message: String, exception: Throwable? = null) {
|
||||
if (exception == null) android.util.Log.e(tag, message) else android.util.Log.e(tag, message, exception)
|
||||
getInstance()?.log(android.util.Log.ERROR, tag, message, exception)
|
||||
}
|
||||
|
||||
fun setRecord(enable: Boolean) {
|
||||
if (!enable) d(TAG, "Disabled log recording")
|
||||
getInstance()?.record?.set(enable)
|
||||
if (enable) d(TAG, "Enabled log recording")
|
||||
}
|
||||
|
||||
fun getRecord(): Boolean {
|
||||
return getInstance()?.record?.get() ?: false
|
||||
}
|
||||
|
||||
fun getFormatted(context: Context, scrub: Boolean): String {
|
||||
return getInstance()?.getFormatted(context, scrub) ?: "(no logs)"
|
||||
}
|
||||
|
||||
fun getScrubTerms(): Map<String, String> {
|
||||
return getInstance()?.scrubTerms!!
|
||||
.filter { e -> e.value.termType != TermType.Password } // We do not want to display passwords
|
||||
.map { e -> e.key to e.value.replaceTerm }
|
||||
.toMap()
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
getInstance()?.deleteAll()
|
||||
d(TAG, "Log was truncated")
|
||||
}
|
||||
|
||||
fun addScrubTerm(term: String, type: TermType = TermType.Term) {
|
||||
getInstance()?.addScrubTerm(term, type)
|
||||
}
|
||||
|
||||
fun init(context: Context) {
|
||||
return synchronized(Log::class) {
|
||||
if (instance == null) {
|
||||
val database = Database.getInstance(context.applicationContext)
|
||||
instance = Log(database.logDao())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInstance(): Log? {
|
||||
return synchronized(Log::class) {
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,511 +0,0 @@
|
|||
package io.heckel.ntfy.util
|
||||
|
||||
import android.animation.ArgbEvaluator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.RippleDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.Base64
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
|
||||
import io.heckel.ntfy.ui.Colors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.text.DateFormat
|
||||
import java.text.StringCharacterIterator
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
|
||||
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
|
||||
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
|
||||
fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"
|
||||
fun topicUrlAuth(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/auth"
|
||||
fun topicUrlJsonPoll(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=$since"
|
||||
fun topicShortUrl(baseUrl: String, topic: String) = shortUrl(topicUrl(baseUrl, topic))
|
||||
|
||||
fun subscriptionTopicShortUrl(subscription: Subscription) : String {
|
||||
return topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||
}
|
||||
|
||||
fun displayName(subscription: Subscription) : String {
|
||||
return subscription.displayName ?: subscriptionTopicShortUrl(subscription)
|
||||
}
|
||||
|
||||
fun shortUrl(url: String) = url
|
||||
.replace("http://", "")
|
||||
.replace("https://", "")
|
||||
|
||||
fun splitTopicUrl(topicUrl: String): Pair<String, String> {
|
||||
if (topicUrl.lastIndexOf("/") == -1) throw Exception("Invalid argument $topicUrl")
|
||||
return Pair(topicUrl.substringBeforeLast("/"), topicUrl.substringAfterLast("/"))
|
||||
}
|
||||
|
||||
fun maybeSplitTopicUrl(topicUrl: String): Pair<String, String>? {
|
||||
return try {
|
||||
splitTopicUrl(topicUrl)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun validTopic(topic: String): Boolean {
|
||||
return "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic) // Must match server side!
|
||||
}
|
||||
|
||||
fun validUrl(url: String): Boolean {
|
||||
return "^https?://\\S+".toRegex().matches(url)
|
||||
}
|
||||
|
||||
fun formatDateShort(timestampSecs: Long): String {
|
||||
val date = Date(timestampSecs*1000)
|
||||
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
|
||||
}
|
||||
|
||||
fun toPriority(priority: Int?): Int {
|
||||
return if (priority != null && ALL_PRIORITIES.contains(priority)) priority else PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
fun toPriorityString(context: Context, priority: Int): String {
|
||||
return when (priority) {
|
||||
PRIORITY_MIN -> context.getString(R.string.settings_notifications_priority_min)
|
||||
PRIORITY_LOW -> context.getString(R.string.settings_notifications_priority_low)
|
||||
PRIORITY_DEFAULT -> context.getString(R.string.settings_notifications_priority_default)
|
||||
PRIORITY_HIGH -> context.getString(R.string.settings_notifications_priority_high)
|
||||
PRIORITY_MAX -> context.getString(R.string.settings_notifications_priority_max)
|
||||
else -> context.getString(R.string.settings_notifications_priority_default)
|
||||
}
|
||||
}
|
||||
|
||||
fun joinTags(tags: List<String>?): String {
|
||||
return tags?.joinToString(",") ?: ""
|
||||
}
|
||||
|
||||
fun joinTagsMap(tags: List<String>?): String {
|
||||
return tags?.mapIndexed { i, tag -> "${i+1}=${tag}" }?.joinToString(",") ?: ""
|
||||
}
|
||||
|
||||
fun splitTags(tags: String?): List<String> {
|
||||
return if (tags == null || tags == "") {
|
||||
emptyList()
|
||||
} else {
|
||||
tags.split(",")
|
||||
}
|
||||
}
|
||||
|
||||
fun toEmojis(tags: List<String>): List<String> {
|
||||
return tags.mapNotNull { tag -> toEmoji(tag) }
|
||||
}
|
||||
|
||||
fun toEmoji(tag: String): String? {
|
||||
return EmojiManager.getForAlias(tag)?.unicode
|
||||
}
|
||||
|
||||
fun unmatchedTags(tags: List<String>): List<String> {
|
||||
return tags.filter { tag -> toEmoji(tag) == null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend tags/emojis to message, but only if there is a non-empty title.
|
||||
* Otherwise, the tags will be prepended to the title.
|
||||
*/
|
||||
fun formatMessage(notification: Notification): String {
|
||||
return if (notification.title != "") {
|
||||
decodeMessage(notification)
|
||||
} else {
|
||||
val emojis = toEmojis(splitTags(notification.tags))
|
||||
if (emojis.isEmpty()) {
|
||||
decodeMessage(notification)
|
||||
} else {
|
||||
emojis.joinToString("") + " " + decodeMessage(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeMessage(notification: Notification): String {
|
||||
return try {
|
||||
if (notification.encoding == MESSAGE_ENCODING_BASE64) {
|
||||
String(Base64.decode(notification.message, Base64.DEFAULT))
|
||||
} else {
|
||||
notification.message
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
notification.message + "(invalid base64)"
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeBytesMessage(notification: Notification): ByteArray {
|
||||
return try {
|
||||
if (notification.encoding == MESSAGE_ENCODING_BASE64) {
|
||||
Base64.decode(notification.message, Base64.DEFAULT)
|
||||
} else {
|
||||
notification.message.toByteArray()
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
notification.message.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* See above; prepend emojis to title if the title is non-empty.
|
||||
* Otherwise, they are prepended to the message.
|
||||
*/
|
||||
fun formatTitle(subscription: Subscription, notification: Notification): String {
|
||||
return if (notification.title != "") {
|
||||
formatTitle(notification)
|
||||
} else {
|
||||
displayName(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatTitle(notification: Notification): String {
|
||||
val emojis = toEmojis(splitTags(notification.tags))
|
||||
return if (emojis.isEmpty()) {
|
||||
notification.title
|
||||
} else {
|
||||
emojis.joinToString("") + " " + notification.title
|
||||
}
|
||||
}
|
||||
|
||||
fun formatActionLabel(action: Action): String {
|
||||
return when (action.progress) {
|
||||
ACTION_PROGRESS_ONGOING -> action.label + " …"
|
||||
ACTION_PROGRESS_SUCCESS -> action.label + " ✔️"
|
||||
ACTION_PROGRESS_FAILED -> action.label + " ❌️"
|
||||
else -> action.label
|
||||
}
|
||||
}
|
||||
|
||||
fun maybeAppendActionErrors(message: String, notification: Notification): String {
|
||||
val actionErrors = notification.actions
|
||||
.orEmpty()
|
||||
.mapNotNull { action -> action.error }
|
||||
.joinToString("\n")
|
||||
if (actionErrors.isEmpty()) {
|
||||
return message
|
||||
} else {
|
||||
return "${message}\n\n${actionErrors}"
|
||||
}
|
||||
}
|
||||
|
||||
// Queries the filename of a content URI
|
||||
fun fileName(context: Context, contentUri: String?, fallbackName: String): String {
|
||||
return try {
|
||||
val info = fileStat(context, Uri.parse(contentUri))
|
||||
info.filename
|
||||
} catch (_: Exception) {
|
||||
fallbackName
|
||||
}
|
||||
}
|
||||
|
||||
fun fileStat(context: Context, contentUri: Uri?): FileInfo {
|
||||
if (contentUri == null) {
|
||||
throw FileNotFoundException("URI is null")
|
||||
}
|
||||
val resolver = context.applicationContext.contentResolver
|
||||
val cursor = resolver.query(contentUri, null, null, null, null) ?: throw Exception("Query returned null")
|
||||
return cursor.use { c ->
|
||||
val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIndex = c.getColumnIndexOrThrow(OpenableColumns.SIZE)
|
||||
if (!c.moveToFirst()) {
|
||||
throw FileNotFoundException("Not found: $contentUri")
|
||||
}
|
||||
val size = c.getLong(sizeIndex)
|
||||
if (size == 0L) {
|
||||
// Content provider URIs (e.g. content://io.heckel.ntfy.provider/cache_files/DQ4o7DitZAmw) return an entry, even
|
||||
// when they do not exist, but with an empty size. This is a practical/fast way to weed out non-existing files.
|
||||
throw FileNotFoundException("Not found or empty: $contentUri")
|
||||
}
|
||||
FileInfo(
|
||||
filename = c.getString(nameIndex),
|
||||
size = c.getLong(sizeIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun maybeFileStat(context: Context, contentUri: String?): FileInfo? {
|
||||
return try {
|
||||
fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class FileInfo(
|
||||
val filename: String,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
|
||||
fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
|
||||
val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor)
|
||||
statusBarColorAnimation.addUpdateListener { animator ->
|
||||
val color = animator.animatedValue as Int
|
||||
window.statusBarColor = color
|
||||
}
|
||||
statusBarColorAnimation.start()
|
||||
}
|
||||
|
||||
// Generates a (cryptographically secure) random string of a certain length
|
||||
fun randomString(len: Int): String {
|
||||
val random = SecureRandom()
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray()
|
||||
return (1..len).map { chars[random.nextInt(chars.size)] }.joinToString("")
|
||||
}
|
||||
|
||||
// Generates a random, positive subscription ID between 0-10M. This ensures that it doesn't have issues
|
||||
// when exported to JSON. It uses SecureRandom, because Random causes issues in the emulator (generating the
|
||||
// same value again and again), sometimes.
|
||||
fun randomSubscriptionId(): Long {
|
||||
return SecureRandom().nextLong().absoluteValue % 100_000_000
|
||||
}
|
||||
|
||||
// Allows letting multiple variables at once, see https://stackoverflow.com/a/35522422/1440785
|
||||
inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
|
||||
return if (p1 != null && p2 != null) block(p1, p2) else null
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long, decimals: Int = 1): String {
|
||||
val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else abs(bytes)
|
||||
if (absB < 1024) {
|
||||
return "$bytes B"
|
||||
}
|
||||
var value = absB
|
||||
val ci = StringCharacterIterator("KMGTPE")
|
||||
var i = 40
|
||||
while (i >= 0 && absB > 0xfffccccccccccccL shr i) {
|
||||
value = value shr 10
|
||||
ci.next()
|
||||
i -= 10
|
||||
}
|
||||
value *= java.lang.Long.signum(bytes).toLong()
|
||||
return java.lang.String.format("%.${decimals}f %cB", value / 1024.0, ci.current())
|
||||
}
|
||||
|
||||
fun mimeTypeToIconResource(mimeType: String?): Int {
|
||||
return if (mimeType?.startsWith("image/") == true) {
|
||||
R.drawable.ic_file_image_red_24dp
|
||||
} else if (mimeType?.startsWith("video/") == true) {
|
||||
R.drawable.ic_file_video_orange_24dp
|
||||
} else if (mimeType?.startsWith("audio/") == true) {
|
||||
R.drawable.ic_file_audio_purple_24dp
|
||||
} else if (mimeType == ANDROID_APP_MIME_TYPE) {
|
||||
R.drawable.ic_file_app_gray_24dp
|
||||
} else {
|
||||
R.drawable.ic_file_document_blue_24dp
|
||||
}
|
||||
}
|
||||
|
||||
fun supportedImage(mimeType: String?): Boolean {
|
||||
return listOf("image/jpeg", "image/png").contains(mimeType)
|
||||
}
|
||||
|
||||
// Google Play doesn't allow us to install received .apk files anymore.
|
||||
// See https://github.com/binwiederhier/ntfy/issues/531
|
||||
fun canOpenAttachment(attachment: Attachment?): Boolean {
|
||||
if (attachment?.type == ANDROID_APP_MIME_TYPE && !BuildConfig.INSTALL_PACKAGES_AVAILABLE) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if battery optimization is enabled, see https://stackoverflow.com/a/49098293/1440785
|
||||
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
val powerManager = context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val appName = context.applicationContext.packageName
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
return powerManager.isIgnoringBatteryOptimizations(appName)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Returns true if dark mode is on, see https://stackoverflow.com/a/60761189/1440785
|
||||
fun Context.systemDarkThemeOn(): Boolean {
|
||||
return resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
fun isDarkThemeOn(context: Context): Boolean {
|
||||
val darkMode = Repository.getInstance(context).getDarkMode()
|
||||
if (darkMode == AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
return true
|
||||
}
|
||||
if (darkMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && context.systemDarkThemeOn()) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// https://cketti.de/2020/05/23/content-uris-and-okhttp/
|
||||
class ContentUriRequestBody(
|
||||
private val resolver: ContentResolver,
|
||||
private val uri: Uri,
|
||||
private val size: Long
|
||||
) : RequestBody() {
|
||||
override fun contentLength(): Long {
|
||||
return size
|
||||
}
|
||||
override fun contentType(): MediaType? {
|
||||
val contentType = resolver.getType(uri)
|
||||
return contentType?.toMediaTypeOrNull()
|
||||
}
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
val inputStream = resolver.openInputStream(uri) ?: throw IOException("Couldn't open content URI for reading")
|
||||
inputStream.source().use { source ->
|
||||
sink.writeAll(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hack: Make end icon for drop down smaller, see https://stackoverflow.com/a/57098715/1440785
|
||||
fun View.makeEndIconSmaller(resources: Resources) {
|
||||
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30f, resources.displayMetrics)
|
||||
val endIconImageView = findViewById<ImageView>(R.id.text_input_end_icon)
|
||||
endIconImageView.minimumHeight = dimension.toInt()
|
||||
endIconImageView.minimumWidth = dimension.toInt()
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
// Shows the ripple effect on the view, if it is ripple-able, see https://stackoverflow.com/a/56314062/1440785
|
||||
fun View.showRipple() {
|
||||
if (background is RippleDrawable) {
|
||||
background.state = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Hides the ripple effect on the view, if it is ripple-able, see https://stackoverflow.com/a/56314062/1440785
|
||||
fun View.hideRipple() {
|
||||
if (background is RippleDrawable) {
|
||||
background.state = intArrayOf()
|
||||
}
|
||||
}
|
||||
|
||||
// Toggles the ripple effect on the view, if it is ripple-able
|
||||
fun View.ripple(scope: CoroutineScope) {
|
||||
showRipple()
|
||||
scope.launch(Dispatchers.Main) {
|
||||
delay(200)
|
||||
hideRipple()
|
||||
}
|
||||
}
|
||||
|
||||
fun Uri.readBitmapFromUri(context: Context): Bitmap {
|
||||
val resolver = context.applicationContext.contentResolver
|
||||
val bitmapStream = resolver.openInputStream(this)
|
||||
val bitmap = BitmapFactory.decodeStream(bitmapStream)
|
||||
if (bitmap.byteCount > 100 * 1024 * 1024) {
|
||||
// If the Bitmap is too large to be rendered (100 MB), it will throw a RuntimeException downstream.
|
||||
// This workaround throws a catchable exception instead. See issue #474. From https://stackoverflow.com/a/53334563/1440785
|
||||
throw Exception("Bitmap too large to draw on Canvas (${bitmap.byteCount} bytes)")
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun String.readBitmapFromUri(context: Context): Bitmap {
|
||||
return Uri.parse(this).readBitmapFromUri(context)
|
||||
}
|
||||
|
||||
fun String.readBitmapFromUriOrNull(context: Context): Bitmap? {
|
||||
return try {
|
||||
this.readBitmapFromUri(context)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// TextWatcher that only implements the afterTextChanged method
|
||||
class AfterChangedTextWatcher(val afterTextChangedFn: (s: Editable?) -> Unit) : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
afterTextChangedFn(s)
|
||||
}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
// Nothing
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
// Nothing
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureSafeNewFile(dir: File, name: String): File {
|
||||
val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_");
|
||||
val file = File(dir, safeName)
|
||||
if (!file.exists()) {
|
||||
return file
|
||||
}
|
||||
(1..1000).forEach { i ->
|
||||
val newFile = File(dir, if (file.extension == "") {
|
||||
"${file.nameWithoutExtension} ($i)"
|
||||
} else {
|
||||
"${file.nameWithoutExtension} ($i).${file.extension}"
|
||||
})
|
||||
if (!newFile.exists()) {
|
||||
return newFile
|
||||
}
|
||||
}
|
||||
throw Exception("Cannot find safe file")
|
||||
}
|
||||
|
||||
fun copyToClipboard(context: Context, notification: Notification) {
|
||||
val message = decodeMessage(notification)
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("notification message", message)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast
|
||||
.makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun String.sha256(): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val digest = md.digest(this.toByteArray())
|
||||
return digest.fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun Button.dangerButton(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setTextAppearance(R.style.DangerText)
|
||||
} else {
|
||||
setTextColor(ContextCompat.getColor(context, Colors.dangerText(context)))
|
||||
}
|
||||
}
|
||||
|
||||
fun Long.nullIfZero(): Long? {
|
||||
return if (this == 0L) return null else this
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
package io.heckel.ntfy.work
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.db.ATTACHMENT_PROGRESS_DELETED
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.msg.DownloadIconWorker
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.maybeFileStat
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Deletes notifications marked for deletion and attachments for deleted notifications.
|
||||
*/
|
||||
class DeleteWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||
// IMPORTANT:
|
||||
// Every time the worker is changed, the periodic work has to be REPLACEd.
|
||||
// This is facilitated in the MainActivity using the VERSION below.
|
||||
|
||||
init {
|
||||
Log.init(ctx) // Init in all entrypoints
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Run "expired icons" and "expired attachments" before notifications,
|
||||
// so we will also catch manually deleted notifications
|
||||
try {
|
||||
deleteExpiredIcons()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete expired icons", e)
|
||||
}
|
||||
try {
|
||||
deleteExpiredAttachments()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete expired attachments", e)
|
||||
}
|
||||
try {
|
||||
deleteExpiredNotifications()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete expired notifications", e)
|
||||
}
|
||||
return@withContext Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteExpiredAttachments() {
|
||||
Log.d(TAG, "Deleting attachments for deleted notifications")
|
||||
val resolver = applicationContext.contentResolver
|
||||
val repository = Repository.getInstance(applicationContext)
|
||||
val notifications = repository.getDeletedNotificationsWithAttachments()
|
||||
notifications.forEach { notification ->
|
||||
try {
|
||||
val attachment = notification.attachment ?: return
|
||||
val contentUri = Uri.parse(attachment.contentUri ?: return)
|
||||
Log.d(TAG, "Deleting attachment for notification ${notification.id}: ${attachment.contentUri} (${attachment.name})")
|
||||
val deleted = resolver.delete(contentUri, null, null) > 0
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Unable to delete attachment for notification ${notification.id}")
|
||||
}
|
||||
val newAttachment = attachment.copy(
|
||||
contentUri = null,
|
||||
progress = ATTACHMENT_PROGRESS_DELETED
|
||||
)
|
||||
val newNotification = notification.copy(attachment = newAttachment)
|
||||
repository.updateNotification(newNotification)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete attachment for notification: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteExpiredIcons() {
|
||||
Log.d(TAG, "Deleting icons for deleted notifications")
|
||||
val repository = Repository.getInstance(applicationContext)
|
||||
val activeIconUris = repository.getActiveIconUris()
|
||||
val activeIconFilenames = activeIconUris
|
||||
.mapNotNull { maybeFileStat(applicationContext, it)?.filename }
|
||||
.toSet()
|
||||
val iconDir = File(applicationContext.cacheDir, DownloadIconWorker.ICON_CACHE_DIR)
|
||||
val allIconFilenames = iconDir.listFiles()?.map{ file -> file.name }.orEmpty()
|
||||
val filenamesToDelete = allIconFilenames.minus(activeIconFilenames)
|
||||
filenamesToDelete.forEach { filename ->
|
||||
try {
|
||||
val file = File(iconDir, filename)
|
||||
val deleted = file.delete()
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Unable to delete icon: $filename")
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(applicationContext,
|
||||
DownloadIconWorker.FILE_PROVIDER_AUTHORITY, file).toString()
|
||||
repository.clearIconUri(uri)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete icon: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteExpiredNotifications() {
|
||||
Log.d(TAG, "Deleting expired notifications")
|
||||
val repository = Repository.getInstance(applicationContext)
|
||||
val subscriptions = repository.getSubscriptions()
|
||||
subscriptions.forEach { subscription ->
|
||||
val logId = topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||
val deleteAfterSeconds = if (subscription.autoDelete == Repository.AUTO_DELETE_USE_GLOBAL) {
|
||||
repository.getAutoDeleteSeconds()
|
||||
} else {
|
||||
subscription.autoDelete
|
||||
}
|
||||
if (deleteAfterSeconds == Repository.AUTO_DELETE_NEVER) {
|
||||
Log.d(TAG, "[$logId] Not deleting any notifications; global setting set to NEVER")
|
||||
return@forEach
|
||||
}
|
||||
|
||||
// Mark as deleted
|
||||
val markDeletedOlderThanTimestamp = (System.currentTimeMillis()/1000) - deleteAfterSeconds
|
||||
Log.d(TAG, "[$logId] Marking notifications older than $markDeletedOlderThanTimestamp as deleted")
|
||||
repository.markAsDeletedIfOlderThan(subscription.id, markDeletedOlderThanTimestamp)
|
||||
|
||||
// Hard delete
|
||||
val deleteOlderThanTimestamp = (System.currentTimeMillis()/1000) - HARD_DELETE_AFTER_SECONDS
|
||||
Log.d(TAG, "[$logId] Hard deleting notifications older than $markDeletedOlderThanTimestamp")
|
||||
repository.removeNotificationsIfOlderThan(subscription.id, deleteOlderThanTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VERSION = BuildConfig.VERSION_CODE
|
||||
const val TAG = "NtfyDeleteWorker"
|
||||
const val WORK_NAME_PERIODIC_ALL = "NtfyDeleteWorkerPeriodic" // Do not change
|
||||
|
||||
private const val ONE_DAY_SECONDS = 24 * 60 * 60L
|
||||
const val HARD_DELETE_AFTER_SECONDS = 4 * 30 * ONE_DAY_SECONDS // 4 months
|
||||
}
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
package io.heckel.ntfy.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.random.Random
|
||||
|
||||
class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||
// IMPORTANT:
|
||||
// Every time the worker is changed, the periodic work has to be REPLACEd.
|
||||
// This is facilitated in the MainActivity using the VERSION below.
|
||||
|
||||
init {
|
||||
Log.init(ctx) // Init in all entrypoints
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return withContext(Dispatchers.IO) {
|
||||
Log.d(TAG, "Polling for new notifications")
|
||||
val repository = Repository.getInstance(applicationContext)
|
||||
val dispatcher = NotificationDispatcher(applicationContext, repository)
|
||||
val api = ApiService()
|
||||
|
||||
val baseUrl = inputData.getString(INPUT_DATA_BASE_URL)
|
||||
val topic = inputData.getString(INPUT_DATA_TOPIC)
|
||||
val subscriptions = if (baseUrl != null && topic != null) {
|
||||
val subscription = repository.getSubscription(baseUrl, topic) ?: return@withContext Result.success()
|
||||
listOf(subscription)
|
||||
} else {
|
||||
repository.getSubscriptions()
|
||||
}
|
||||
|
||||
subscriptions.forEach{ subscription ->
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl)
|
||||
val notifications = api.poll(
|
||||
subscriptionId = subscription.id,
|
||||
baseUrl = subscription.baseUrl,
|
||||
topic = subscription.topic,
|
||||
user = user,
|
||||
since = subscription.lastNotificationId
|
||||
)
|
||||
val newNotifications = repository
|
||||
.onlyNewNotifications(subscription.id, notifications)
|
||||
.map { it.copy(notificationId = Random.nextInt()) }
|
||||
newNotifications.forEach { notification ->
|
||||
if (repository.addNotification(notification)) {
|
||||
dispatcher.dispatch(subscription, notification)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed checking messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Finished polling for new notifications")
|
||||
return@withContext Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VERSION = BuildConfig.VERSION_CODE
|
||||
const val TAG = "NtfyPollWorker"
|
||||
const val WORK_NAME_PERIODIC_ALL = "NtfyPollWorkerPeriodic" // Do not change
|
||||
const val WORK_NAME_ONCE_SINGE_PREFIX = "NtfyPollWorkerSingle" // e.g. NtfyPollWorkerSingle_https://ntfy.sh_mytopic
|
||||
const val INPUT_DATA_BASE_URL = "baseUrl"
|
||||
const val INPUT_DATA_TOPIC = "topic"
|
||||
}
|
||||
}
|
46
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal file
46
app/src/main/res/drawable-v24/ic_launcher_foreground.xml
Normal file
|
@ -0,0 +1,46 @@
|
|||
<!--
|
||||
Copyright (C) 2020 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
|
@ -1,9 +1,21 @@
|
|||
<!--
|
||||
Copyright (C) 2020 The Android Open Source Project
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,16L5.17,16L4,17.17L4,4h16v12zM11,5h2v6h-2zM11,13h2v2h-2z"
|
||||
android:fillColor="#FF9800"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M15.67,4L14,4L14,2h-4v2L8.33,4C7.6,4 7,4.6 7,5.33v15.33C7,21.4 7.6,22 8.33,22h7.33c0.74,0 1.34,-0.6 1.34,-1.33L17,5.33C17,4.6 16.4,4 15.67,4zM13,18h-2v-2h2v2zM13,14h-2L11,9h2v5z"
|
||||
android:fillColor="#F44336"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z"
|
||||
android:fillColor="#555555"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12.5449,2.1992 L12.3145,2.6035C9.7352,7.1246 7.809,10.5009 6.5176,12.7832c0.0118,-0.0179 -0.0053,0.0121 -0.0254,0.043 -0.024,0.0369 -0.055,0.0842 -0.0879,0.1445 -0.0657,0.1206 -0.1489,0.2813 -0.1875,0.5273 -0.0386,0.2461 0.0075,0.6682 0.2988,0.9551 0.2913,0.2869 0.6507,0.3477 0.9844,0.3477h2.5781l-1,7h2.3867l0.2305,-0.4043c0,0 1.9682,-3.4483 5.918,-10.3379l0.0176,-0.0293L17.6445,11C17.8155,10.6348 17.9095,10.1065 17.5996,9.6816 17.2897,9.2568 16.8462,9.1992 16.5195,9.1992h-2.5859l0.998,-7zM12.959,4.707 L12.0879,10.8008h3.8301c-3.0779,5.37 -4.4616,7.7886 -4.8691,8.502l0.873,-6.1035L8.123,13.1992C9.238,11.2305 10.9448,8.2378 12.959,4.707Z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z"
|
||||
android:fillColor="@android:color/white"/>
|
||||
</vector>
|
|
@ -1,28 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (C) 2019 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0"
|
||||
android:width="24dp"
|
||||
tools:ignore="NewApi">
|
||||
<path
|
||||
android:fillColor="#888888"
|
||||
android:pathData="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/>
|
||||
</vector>
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval" >
|
||||
<solid android:color="#338574" />
|
||||
</shape>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"
|
||||
android:fillColor="@android:color/white"/>
|
||||
</vector>
|
|
@ -1,10 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6v10c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L16,6L4,6zM17,3h-3l-1,-1L7,2L6,3L3,3v2h14L17,3z"/>
|
||||
</vector>
|
|
@ -1,27 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (C) 2019 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0"
|
||||
android:width="24dp"
|
||||
tools:ignore="NewApi">
|
||||
<path
|
||||
android:fillColor="#888888"
|
||||
android:pathData="M7 10l5 5 5-5z"/>
|
||||
</vector>
|
|
@ -1,28 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (C) 2019 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0"
|
||||
android:width="24dp"
|
||||
tools:ignore="NewApi">
|
||||
<path
|
||||
android:fillColor="#888888"
|
||||
android:pathData="M7 14l5-5 5 5z"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"
|
||||
android:fillColor="#F44336"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M17.6,9.48l1.84,-3.18c0.16,-0.31 0.04,-0.69 -0.26,-0.85c-0.29,-0.15 -0.65,-0.06 -0.83,0.22l-1.88,3.24c-2.86,-1.21 -6.08,-1.21 -8.94,0L5.65,5.67c-0.19,-0.29 -0.58,-0.38 -0.87,-0.2C4.5,5.65 4.41,6.01 4.56,6.3L6.4,9.48C3.3,11.25 1.28,14.44 1,18h22C22.72,14.44 20.7,11.25 17.6,9.48zM7,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25S8.25,13.31 8.25,14C8.25,14.69 7.69,15.25 7,15.25zM17,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25s1.25,0.56 1.25,1.25C18.25,14.69 17.69,15.25 17,15.25z"
|
||||
android:fillColor="#555555"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,3l0.01,10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4.01,4S14,19.21 14,17L14,7h4L18,3h-6zM10.01,19c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"
|
||||
android:fillColor="#B300FF"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M8,16h8v2L8,18zM8,12h8v2L8,14zM14,2L6,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM18,20L6,20L6,4h7v5h5v11z"
|
||||
android:fillColor="#00ADFF"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M19,5v14L5,19L5,5h14m0,-2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14.14,11.86l-3,3.87L9,13.14 6,17h12l-3.86,-5.14z"
|
||||
android:fillColor="#E30000"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z"
|
||||
android:fillColor="#FF9800"/>
|
||||
</vector>
|
186
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
186
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
@ -0,0 +1,186 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) 2020 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
|
@ -1,31 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="50dp"
|
||||
android:height="50dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="50">
|
||||
<path
|
||||
android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z"
|
||||
android:strokeWidth="0.754022"
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"
|
||||
android:fillColor="#555555"/>
|
||||
</vector>
|
|
@ -1,31 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="22dp"
|
||||
android:height="22dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="50">
|
||||
<path
|
||||
android:pathData="m7.8399,6.35c-3.58,0 -6.6469,2.817 -6.6469,6.3983v0.003l0.0351,27.8668 -0.8991,6.6347 12.2261,-3.248L42.9487,44.0049c3.58,0 6.6469,-2.8208 6.6469,-6.4022v-24.8545c0,-3.5803 -3.0652,-6.3967 -6.6438,-6.3983h-0.0031zM7.8399,10.8662h35.1088,0.0031c1.2579,0.0013 2.1277,0.9164 2.1277,1.8821v24.8544c0,0.9666 -0.8714,1.8821 -2.1307,1.8821L11.8924,39.4849l-6.2114,1.8768 0.0633,-0.366 -0.0343,-28.2473c0,-0.9665 0.8706,-1.8821 2.13,-1.8821z"
|
||||
android:strokeWidth="0.754022"
|
||||
android:fillColor="?android:attr/colorControlNormal"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m11.5278,32.0849l0,-3.346l7.0363,-3.721q0.3397,-0.1732 0.6551,-0.2596 0.3397,-0.1153 0.6066,-0.1732 0.2912,-0.0288 0.5823,-0.0576l0,-0.2308q-0.2912,-0.0288 -0.5823,-0.1153 -0.2669,-0.0576 -0.6066,-0.1443 -0.3154,-0.1153 -0.6551,-0.2884l-7.0363,-3.721l0,-3.3749l10.8699,5.9132l0,3.6056z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="?android:attr/colorControlNormal"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m10.9661,15.6112l0,4.8516l7.3742,3.9002c0.0157,0.0077 0.0305,0.0128 0.0461,0.0204 -0.0157,0.0077 -0.0305,0.0128 -0.0461,0.0204l-7.3742,3.9002l0,4.8267l0.7961,-0.4333 11.1995,-6.0969l0,-4.463zM12.0931,17.6933 L21.8346,22.9981l0,2.7446l-9.7414,5.2999l0,-1.8679l6.6912,-3.5416 0.0084,-0.0051c0.1961,-0.0992 0.3826,-0.1724 0.5531,-0.2191l0.0127,0l0.0167,-0.0051c0.2034,-0.0691 0.3777,-0.1209 0.5279,-0.1545l1.0684,-0.1046l0,-1.4644l-0.5154,-0.0497c-0.1632,-0.0153 -0.3288,-0.0505 -0.4944,-0.0997l-0.0167,-0.0051 -0.0167,-0.0051c-0.1632,-0.0352 -0.3552,-0.0811 -0.5656,-0.1344 -0.1802,-0.0668 -0.3706,-0.1479 -0.5698,-0.2492l-0.0084,-0.0051 -6.6912,-3.5416z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="?android:attr/colorControlNormal"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m26.7503,30.9206l11.6118,0l0,3.1388L26.7503,34.0594Z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="?android:attr/colorControlNormal"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m26.1875,30.2775l0,0.6427 0,3.7845l12.7371,0l0,-4.4272zM27.3113,31.563l10.4896,0l0,1.8515l-10.4896,0z"
|
||||
android:strokeWidth="0.525121"
|
||||
android:fillColor="?android:attr/colorControlNormal"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue