diff --git a/.gitignore b/.gitignore
index c79d063..3a6760f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ android/.externalNativeBuild
android/.cxx
android/*.keystore
android/*.jks
+android/.kotlin
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..f8ad45a
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,30 @@
+# SUP - Signal Unified Push
+
+Copyright (c) 2026 LoneCloud
+
+This product includes software developed by Philipp C. Heckel (ntfy)
+Licensed under the Apache License 2.0
+
+The Android application (android/) contains modified code from:
+ ntfy-android (https://github.com/binwiederhier/ntfy-android)
+ Copyright (c) 2021-2024 Philipp C. Heckel
+ Licensed under Apache License 2.0
+
+Major modifications:
+
+- Replaced HTTP polling with Signal-based message delivery for privacy
+- Removed web-based features and simplified UI
+- Intended for self-hosted, low-volume personal use
+
+The original ntfy-android license is included below:
+
+================================================================================
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+ [Full Apache 2.0 license text would go here]
+
+================================================================================
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 35f68a5..a4b1172 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
+ id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}
android {
@@ -49,23 +50,86 @@ android {
buildFeatures {
viewBinding = true
+ buildConfig = true
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
+
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ }
+
+ defaultConfig {
+ // Build config fields
+ buildConfigField("boolean", "FIREBASE_AVAILABLE", "false")
+ buildConfigField("boolean", "RATE_APP_AVAILABLE", "false")
+ buildConfigField("boolean", "PAYMENT_LINKS_AVAILABLE", "false")
+ buildConfigField("String", "FLAVOR", "\"sup\"")
+ }
}
dependencies {
- implementation("androidx.core:core-ktx:1.15.0")
+ // AndroidX Core
implementation("androidx.appcompat:appcompat:1.7.1")
- implementation("com.google.android.material:material:1.12.0") // Skip alpha
+ implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
-
+ implementation("androidx.activity:activity-ktx:1.12.2")
+ implementation("androidx.fragment:fragment-ktx:1.8.9")
+ implementation("androidx.work:work-runtime-ktx:2.11.0")
+ implementation("androidx.preference:preference-ktx:1.2.1")
+
+ // JSON (Gson)
+ implementation("com.google.code.gson:gson:2.13.2")
+
+ // Room (SQLite)
+ val roomVersion = "2.6.1"
+ implementation("androidx.room:room-runtime:$roomVersion")
+ ksp("androidx.room:room-compiler:$roomVersion")
+ implementation("androidx.room:room-ktx:$roomVersion")
+
+ // OkHttp
+ implementation("com.squareup.okhttp3:okhttp:5.3.2")
+
+ // RecyclerView
+ implementation("androidx.recyclerview:recyclerview:1.4.0")
+
+ // Swipe to refresh
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0")
+
+ // Material Design
+ implementation("com.google.android.material:material:1.13.0")
+
+ // LiveData
+ implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.10.0")
+ implementation("androidx.legacy:legacy-support-v4:1.0.0")
+
+ // Image viewer
+ implementation("com.github.stfalcon-studio:StfalconImageViewer:1.0.1")
+
+ // Glide (GIF support)
+ val glideVersion = "5.0.5"
+ implementation("com.github.bumptech.glide:glide:$glideVersion")
+ ksp("com.github.bumptech.glide:ksp:$glideVersion")
+
+ // Better click handling for links
+ implementation("me.saket:better-link-movement-method:2.2.0")
+
+ // Markdown
+ implementation("io.noties.markwon:core:4.6.2")
+ implementation("io.noties.markwon:image-picasso:4.6.2")
+ implementation("io.noties.markwon:image:4.6.2")
+ implementation("io.noties.markwon:linkify:4.6.2")
+ implementation("io.noties.markwon:ext-tables:4.6.2")
+ implementation("io.noties.markwon:ext-strikethrough:4.6.2")
+
+ // Markdown dependencies (R8 requirements)
+ implementation("pl.droidsonroids.gif:android-gif-drawable:1.2.29")
+ implementation("com.caverock:androidsvg:1.4")
+
+ // UnifiedPush
implementation("com.github.UnifiedPush:android-connector:3.0.10")
-
- implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.16")
-
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
}
+
diff --git a/android/app/schemas/com.lonecloud.sup.db.Database/17.json b/android/app/schemas/com.lonecloud.sup.db.Database/17.json
new file mode 100644
index 0000000..39be46d
--- /dev/null
+++ b/android/app/schemas/com.lonecloud.sup.db.Database/17.json
@@ -0,0 +1,216 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 18,
+ "identityHash": "35364cf175ffcf5aa672eabaabe12397",
+ "entities": [
+ {
+ "tableName": "Subscription",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "baseUrl",
+ "columnName": "baseUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "topic",
+ "columnName": "topic",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mutedUntil",
+ "columnName": "mutedUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minPriority",
+ "columnName": "minPriority",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "autoDelete",
+ "columnName": "autoDelete",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "insistent",
+ "columnName": "insistent",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "upAppId",
+ "columnName": "upAppId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "upConnectorToken",
+ "columnName": "upConnectorToken",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "displayName",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": ["id"]
+ },
+ "indices": [
+ {
+ "name": "index_Subscription_baseUrl_topic",
+ "unique": true,
+ "columnNames": ["baseUrl", "topic"],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)"
+ },
+ {
+ "name": "index_Subscription_upConnectorToken",
+ "unique": true,
+ "columnNames": ["upConnectorToken"],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "Notification",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`, `subscriptionId`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notificationId",
+ "columnName": "notificationId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "priority",
+ "columnName": "priority",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "3"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deleted",
+ "columnName": "deleted",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": ["id", "subscriptionId"]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "Log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "level",
+ "columnName": "level",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "exception",
+ "columnName": "exception",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": ["id"]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '35364cf175ffcf5aa672eabaabe12397')"
+ ]
+ }
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 08fcb8e..dad32e5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,47 +1,114 @@
-
-
+
+
+
+
+
+
+
+
+
-
-
+ android:name=".app.Application"
+ android:allowBackup="true"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:roundIcon="@mipmap/ic_launcher"
+ android:supportsRtl="true"
+ android:theme="@style/AppTheme"
+ android:networkSecurityConfig="@xml/network_security_config"
+ android:usesCleartextTraffic="true">
+
-
-
+
+
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt b/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt
index bb4df5f..a469b8c 100644
--- a/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt
+++ b/android/app/src/main/java/com/lonecloud/sup/DistributorService.kt
@@ -63,7 +63,6 @@ class DistributorService : Service() {
val jsonResponse = JSONObject(responseBody)
val endpoint = jsonResponse.getString("endpoint")
- // Store mapping
prefs.edit()
.putString("endpoint_$appId", endpoint)
.putString("token_$appId", token)
@@ -85,7 +84,6 @@ class DistributorService : Service() {
val token = intent.getStringExtra("token") ?: return
Log.d("SUP", "Unregistering: token=$token")
- // Find and remove mapping
val allPrefs = prefs.all
for ((key, value) in allPrefs) {
if (key.startsWith("token_") && value == token) {
@@ -129,7 +127,6 @@ class DistributorService : Service() {
}
private fun getAppPackageFromToken(token: String): String {
- // Token format is typically package:randomId
return token.split(":").firstOrNull() ?: ""
}
}
diff --git a/android/app/src/main/java/com/lonecloud/sup/MainActivity.kt b/android/app/src/main/java/com/lonecloud/sup/MainActivity.kt
deleted file mode 100644
index eb80bb4..0000000
--- a/android/app/src/main/java/com/lonecloud/sup/MainActivity.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.lonecloud.sup
-
-import android.content.Intent
-import android.os.Bundle
-import android.provider.Settings
-import android.widget.Button
-import android.widget.EditText
-import android.widget.Toast
-import androidx.appcompat.app.AppCompatActivity
-
-class MainActivity : AppCompatActivity() {
-
- private lateinit var serverUrlInput: EditText
- private lateinit var apiKeyInput: EditText
- private lateinit var saveButton: Button
- private lateinit var enableListenerButton: Button
-
- private val prefs by lazy {
- getSharedPreferences("sup_prefs", MODE_PRIVATE)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
-
- serverUrlInput = findViewById(R.id.server_url)
- apiKeyInput = findViewById(R.id.api_key)
- saveButton = findViewById(R.id.save_button)
- enableListenerButton = findViewById(R.id.enable_listener_button)
-
- // Load saved settings
- serverUrlInput.setText(prefs.getString("server_url", ""))
- apiKeyInput.setText(prefs.getString("api_key", ""))
-
- saveButton.setOnClickListener {
- val serverUrl = serverUrlInput.text.toString().trim()
- val apiKey = apiKeyInput.text.toString().trim()
-
- prefs.edit()
- .putString("server_url", serverUrl)
- .putString("api_key", apiKey)
- .apply()
-
- Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
- }
-
- enableListenerButton.setOnClickListener {
- val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
- startActivity(intent)
- }
- }
-}
diff --git a/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt b/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt
index 91f1b66..19f2f1a 100644
--- a/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt
+++ b/android/app/src/main/java/com/lonecloud/sup/SignalNotificationListener.kt
@@ -1,18 +1,47 @@
package com.lonecloud.sup
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
import android.content.Intent
+import android.net.Uri
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.lonecloud.sup.db.Database
+import com.lonecloud.sup.db.Notification
+import com.lonecloud.sup.ui.MainActivity
+import kotlinx.coroutines.*
+import kotlin.random.Random
class SignalNotificationListener : NotificationListenerService() {
+ private val TAG = "SUP_Listener"
private val prefs by lazy {
getSharedPreferences("sup_prefs", MODE_PRIVATE)
}
+ private val db by lazy { Database.getInstance(this) }
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ companion object {
+ private const val CHANNEL_ID = "sup_notifications"
+ private const val CHANNEL_NAME = "SUP Notifications"
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ serviceScope.cancel()
+ }
override fun onNotificationPosted(sbn: StatusBarNotification?) {
- if (sbn?.packageName != "org.thoughtcrime.securesms") return // Signal package
+ if (sbn?.packageName != "org.thoughtcrime.securesms") return
val notification = sbn.notification
val extras = notification.extras
@@ -20,31 +49,73 @@ class SignalNotificationListener : NotificationListenerService() {
val title = extras.getString("android.title") ?: ""
val text = extras.getCharSequence("android.text")?.toString() ?: ""
- Log.d("SUP", "Signal notification: title=$title, text=$text")
+ Log.d(TAG, "Signal notification: title=$title, text=$text")
- // Parse SUP message format: **Title**\nbody\nJSON
- if (title.startsWith("SUP - ")) {
- val appName = title.removePrefix("SUP - ")
- parseAndDeliver(appName, text)
+ when {
+ title.startsWith("SUP - ") && !title.contains("(UP)") -> {
+ // Direct notification channel
+ val topic = title.removePrefix("SUP - ")
+ parseAndDisplayNotification(topic, text)
+ }
+ title.startsWith("SUP - ") && title.contains("(UP)") -> {
+ // UnifiedPush notification
+ val appName = title.removePrefix("SUP - ").substringBefore(" (UP)")
+ parseAndDeliverUnifiedPush(appName, text)
+ }
}
}
- private fun parseAndDeliver(appName: String, message: String) {
+ private fun parseAndDisplayNotification(topic: String, message: String) {
+ serviceScope.launch {
+ try {
+ val subscription = db.subscriptionDao().get(
+ prefs.getString("server_url", "") ?: "",
+ topic
+ ) ?: return@launch
+
+ if (subscription.mutedUntil > System.currentTimeMillis() / 1000) {
+ Log.d(TAG, "Subscription $topic is muted")
+ return@launch
+ }
+
+ val lines = message.lines()
+ val (title, body, priority, clickUrl) = parseNotificationMessage(lines)
+
+ val notif = Notification(
+ id = "${System.currentTimeMillis()}-${Random.nextInt()}",
+ subscriptionId = subscription.id,
+ timestamp = System.currentTimeMillis() / 1000,
+ title = title ?: topic,
+ message = body,
+ notificationId = Random.nextInt(Int.MAX_VALUE),
+ priority = priority,
+ tags = "",
+ deleted = false
+ )
+
+ db.notificationDao().add(notif)
+ displayNotification(subscription.displayName ?: topic, notif)
+
+ Log.d(TAG, "Displayed notification for topic: $topic")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to display notification", e)
+ }
+ }
+ }
+
+ private fun parseAndDeliverUnifiedPush(appName: String, message: String) {
try {
- // Find the endpoint for this app
val endpoint = prefs.getString("endpoint_$appName", null)
val token = prefs.getString("token_$appName", null)
if (endpoint == null || token == null) {
- Log.w("SUP", "No mapping found for app: $appName")
+ Log.w(TAG, "No mapping found for app: $appName")
return
}
- // Extract message body (skip the formatted parts)
val lines = message.lines()
- val body = lines.getOrNull(1) ?: message
+ val body = lines.drop(1).joinToString("\n").trim()
- // Send to app
val intent = Intent("org.unifiedpush.android.connector.MESSAGE").apply {
putExtra("token", token)
putExtra("message", body)
@@ -52,13 +123,108 @@ class SignalNotificationListener : NotificationListenerService() {
}
sendBroadcast(intent)
- Log.d("SUP", "Delivered notification to $appName")
+ Log.d(TAG, "Delivered UnifiedPush notification to $appName")
} catch (e: Exception) {
- Log.e("SUP", "Failed to parse/deliver notification", e)
+ Log.e(TAG, "Failed to parse/deliver UnifiedPush notification", e)
}
}
+ private fun parseNotificationMessage(lines: List): NotificationData {
+ var title: String? = null
+ var body = ""
+ var priority = 3 // default
+ var clickUrl: String? = null
+
+ for (line in lines) {
+ when {
+ line.startsWith("π¨") || line.startsWith("β οΈ") || line.startsWith("π") ||
+ line.startsWith("π") || line.startsWith("π") -> {
+ // Parse priority from emoji
+ priority = when {
+ line.startsWith("π¨") -> 5 // urgent
+ line.startsWith("β οΈ") -> 4 // high
+ line.startsWith("π") -> 3 // default
+ line.startsWith("π") -> 2 // low
+ line.startsWith("π") -> 1 // min
+ else -> 3
+ }
+ // Extract title (remove emoji and **markdown**)
+ title = line.substring(2).trim()
+ .removePrefix("**").removeSuffix("**").trim()
+ }
+ line.startsWith("π") -> {
+ clickUrl = line.removePrefix("π").trim()
+ }
+ line.startsWith("_Tags:") -> {
+ // Ignore tags line for now
+ }
+ line.isNotBlank() && title != null -> {
+ // Body content
+ if (body.isNotEmpty()) body += "\n"
+ body += line
+ }
+ }
+ }
+
+ return NotificationData(title, body.ifBlank { lines.joinToString("\n") }, priority, clickUrl)
+ }
+
+ private fun displayNotification(topicName: String, notification: Notification) {
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ val intent = Intent(this, MainActivity::class.java)
+
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ notification.notificationId,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ val builder = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(notification.title)
+ .setContentText(notification.message)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(notification.message))
+ .setPriority(mapPriorityToAndroid(notification.priority))
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+
+ notificationManager.notify(notification.notificationId, builder.build())
+ }
+
+ private fun mapPriorityToAndroid(priority: Int): Int {
+ return when (priority) {
+ 1 -> NotificationCompat.PRIORITY_MIN
+ 2 -> NotificationCompat.PRIORITY_LOW
+ 3 -> NotificationCompat.PRIORITY_DEFAULT
+ 4 -> NotificationCompat.PRIORITY_HIGH
+ 5 -> NotificationCompat.PRIORITY_MAX
+ else -> NotificationCompat.PRIORITY_DEFAULT
+ }
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ description = "Notifications from SUP topics"
+ }
+
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+
private fun getAppPackageFromToken(token: String): String {
return token.split(":").firstOrNull() ?: ""
}
+
+ private data class NotificationData(
+ val title: String?,
+ val body: String,
+ val priority: Int,
+ val clickUrl: String?
+ )
}
diff --git a/android/app/src/main/java/com/lonecloud/sup/app/Application.kt b/android/app/src/main/java/com/lonecloud/sup/app/Application.kt
new file mode 100644
index 0000000..08230d4
--- /dev/null
+++ b/android/app/src/main/java/com/lonecloud/sup/app/Application.kt
@@ -0,0 +1,23 @@
+package com.lonecloud.sup.app
+
+import android.app.Application
+import com.google.android.material.color.DynamicColors
+import com.lonecloud.sup.db.Repository
+import com.lonecloud.sup.util.Log
+
+class Application : Application() {
+ val repository by lazy {
+ val repository = Repository.getInstance(applicationContext)
+ if (repository.getRecordLogs()) {
+ Log.setRecord(true)
+ }
+ repository
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ if (repository.getDynamicColorsEnabled()) {
+ DynamicColors.applyToActivitiesIfAvailable(this)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/lonecloud/sup/db/Database.kt b/android/app/src/main/java/com/lonecloud/sup/db/Database.kt
new file mode 100644
index 0000000..dbeaaf7
--- /dev/null
+++ b/android/app/src/main/java/com/lonecloud/sup/db/Database.kt
@@ -0,0 +1,440 @@
+package com.lonecloud.sup.db
+
+import android.content.Context
+import androidx.room.ColumnInfo
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.Index
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.Update
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+import com.lonecloud.sup.service.NotAuthorizedException
+import com.lonecloud.sup.service.hasCause
+import kotlinx.coroutines.flow.Flow
+import java.net.ConnectException
+
+@Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true), Index(value = ["upConnectorToken"], unique = true)])
+data class Subscription(
+ @PrimaryKey val id: Long,
+ @ColumnInfo(name = "baseUrl") val baseUrl: String,
+ @ColumnInfo(name = "topic") val topic: String,
+ @ColumnInfo(name = "mutedUntil") val mutedUntil: Long,
+ @ColumnInfo(name = "minPriority") val minPriority: Int,
+ @ColumnInfo(name = "autoDelete") val autoDelete: Long, // Seconds
+ @ColumnInfo(name = "insistent") val insistent: Int, // Ring constantly for max priority notifications (-1 = use global, 0 = off, 1 = on)
+ @ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name
+ @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
+ @ColumnInfo(name = "displayName") val displayName: String?,
+ @Ignore val totalCount: Int = 0, // Total notifications
+ @Ignore val newCount: Int = 0, // New notifications
+ @Ignore val lastActive: Long = 0, // Unix timestamp
+ @Ignore val connectionDetails: ConnectionDetails = ConnectionDetails()
+) {
+ constructor(
+ id: Long,
+ baseUrl: String,
+ topic: String,
+ mutedUntil: Long,
+ minPriority: Int,
+ autoDelete: Long,
+ insistent: Int,
+ upAppId: String?,
+ upConnectorToken: String?,
+ displayName: String?
+ ) :
+ this(
+ id,
+ baseUrl,
+ topic,
+ mutedUntil,
+ minPriority,
+ autoDelete,
+ insistent,
+ upAppId,
+ upConnectorToken,
+ displayName,
+ totalCount = 0,
+ newCount = 0,
+ lastActive = 0,
+ connectionDetails = ConnectionDetails()
+ )
+}
+
+enum class ConnectionState {
+ NOT_APPLICABLE, CONNECTING, CONNECTED
+}
+
+data class ConnectionDetails(
+ val state: ConnectionState = ConnectionState.NOT_APPLICABLE,
+ val error: Throwable? = null,
+ val nextRetryTime: Long = 0L
+) {
+ fun getStackTraceString(): String {
+ return error?.stackTraceToString() ?: ""
+ }
+
+ fun hasError(): Boolean {
+ return error != null
+ }
+
+ fun isConnectionRefused(): Boolean {
+ return error?.hasCause(ConnectException::class.java) ?: false
+ }
+
+ fun isNotAuthorized(): Boolean {
+ return error?.hasCause(NotAuthorizedException::class.java) ?: false
+ }
+}
+
+data class SubscriptionWithMetadata(
+ val id: Long,
+ val baseUrl: String,
+ val topic: String,
+ val mutedUntil: Long,
+ val autoDelete: Long,
+ val minPriority: Int,
+ val insistent: Int,
+ val upAppId: String?,
+ val upConnectorToken: String?,
+ val displayName: String?,
+ val totalCount: Int,
+ val newCount: Int,
+ val lastActive: Long
+)
+
+@Entity(primaryKeys = ["id", "subscriptionId"])
+data class Notification(
+ @ColumnInfo(name = "id") val id: String,
+ @ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
+ @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
+ @ColumnInfo(name = "title") val title: String,
+ @ColumnInfo(name = "message") val message: String,
+ @ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
+ @ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
+ @ColumnInfo(name = "tags") val tags: String,
+ @ColumnInfo(name = "deleted") val deleted: Boolean,
+)
+
+@Entity(tableName = "Log")
+data class LogEntry(
+ @PrimaryKey(autoGenerate = true) val id: Long,
+ @ColumnInfo(name = "timestamp") val timestamp: Long,
+ @ColumnInfo(name = "tag") val tag: String,
+ @ColumnInfo(name = "level") val level: Int,
+ @ColumnInfo(name = "message") val message: String,
+ @ColumnInfo(name = "exception") val exception: String?
+) {
+ @Ignore constructor(timestamp: Long, tag: String, level: Int, message: String, exception: String?) :
+ this(0, timestamp, tag, level, message, exception)
+}
+
+@androidx.room.Database(
+ version = 17,
+ entities = [
+ Subscription::class,
+ Notification::class,
+ LogEntry::class
+ ]
+)
+abstract class Database : RoomDatabase() {
+ abstract fun subscriptionDao(): SubscriptionDao
+ abstract fun notificationDao(): NotificationDao
+ abstract fun logDao(): LogDao
+
+ companion object {
+ @Volatile
+ private var instance: Database? = null
+
+ fun getInstance(context: Context): Database {
+ return instance ?: synchronized(this) {
+ val instance = Room
+ .databaseBuilder(context.applicationContext, Database::class.java, "AppDatabase")
+ .addMigrations(MIGRATION_1_2)
+ .addMigrations(MIGRATION_2_3)
+ .addMigrations(MIGRATION_3_4)
+ .addMigrations(MIGRATION_4_5)
+ .addMigrations(MIGRATION_5_6)
+ .addMigrations(MIGRATION_6_7)
+ .addMigrations(MIGRATION_7_8)
+ .addMigrations(MIGRATION_8_9)
+ .addMigrations(MIGRATION_9_10)
+ .addMigrations(MIGRATION_10_11)
+ .addMigrations(MIGRATION_11_12)
+ .addMigrations(MIGRATION_12_13)
+ .addMigrations(MIGRATION_13_14)
+ .addMigrations(MIGRATION_14_15)
+ .addMigrations(MIGRATION_15_16)
+ .addMigrations(MIGRATION_16_17)
+ .fallbackToDestructiveMigration(true)
+ .build()
+ this.instance = instance
+ instance
+ }
+ }
+
+ private val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE Subscription_New (id INTEGER NOT NULL, baseUrl TEXT NOT NULL, topic TEXT NOT NULL, instant INTEGER NOT NULL DEFAULT('0'), PRIMARY KEY(id))")
+ db.execSQL("INSERT INTO Subscription_New SELECT id, baseUrl, topic, 0 FROM Subscription")
+ db.execSQL("DROP TABLE Subscription")
+ db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription")
+ db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)")
+
+ db.execSQL("ALTER TABLE Notification ADD COLUMN notificationId INTEGER NOT NULL DEFAULT('0')")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')")
+ }
+ }
+
+ private val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN mutedUntil INTEGER NOT NULL DEFAULT('0')")
+ }
+ }
+
+ private val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE Notification_New (id TEXT NOT NULL, subscriptionId INTEGER NOT NULL, timestamp INTEGER NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, notificationId INTEGER NOT NULL, priority INTEGER NOT NULL DEFAULT(3), tags TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(id, subscriptionId))")
+ db.execSQL("INSERT INTO Notification_New SELECT id, subscriptionId, timestamp, '', message, notificationId, 3, '', deleted FROM Notification")
+ db.execSQL("DROP TABLE Notification")
+ db.execSQL("ALTER TABLE Notification_New RENAME TO Notification")
+ }
+ }
+
+ private val MIGRATION_4_5 = object : Migration(4, 5) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN upAppId TEXT")
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN upConnectorToken TEXT")
+ db.execSQL("CREATE UNIQUE INDEX index_Subscription_upConnectorToken ON Subscription (upConnectorToken)")
+ }
+ }
+
+ private val MIGRATION_5_6 = object : Migration(5, 6) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Notification ADD COLUMN click TEXT NOT NULL DEFAULT('')")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_name TEXT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_type TEXT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_size INT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_expires INT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_url TEXT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_contentUri TEXT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN attachment_progress INT")
+ }
+ }
+
+ private val MIGRATION_6_7 = object : Migration(6, 7) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE Log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp INT NOT NULL, tag TEXT NOT NULL, level INT NOT NULL, message TEXT NOT NULL, exception TEXT)")
+ }
+ }
+
+ private val MIGRATION_7_8 = object : Migration(7, 8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE User (baseUrl TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))")
+ }
+ }
+
+ private val MIGRATION_8_9 = object : Migration(8, 9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Notification ADD COLUMN encoding TEXT NOT NULL DEFAULT('')")
+ }
+ }
+
+ private val MIGRATION_9_10 = object : Migration(9, 10) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Notification ADD COLUMN actions TEXT")
+ }
+ }
+
+ private val MIGRATION_10_11 = object : Migration(10, 11) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN minPriority INT NOT NULL DEFAULT (0)")
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN autoDelete INT NOT NULL DEFAULT (-1)")
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN icon TEXT")
+ }
+ }
+
+ private val MIGRATION_11_12 = object : Migration(11, 12) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT")
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN icon_url TEXT")
+ db.execSQL("ALTER TABLE Notification ADD COLUMN icon_contentUri TEXT")
+ }
+ }
+
+ private val MIGRATION_12_13 = object : Migration(12, 13) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN insistent INTEGER NOT NULL DEFAULT (-1)")
+ db.execSQL("ALTER TABLE Subscription ADD COLUMN dedicatedChannels INTEGER NOT NULL DEFAULT (0)")
+ }
+ }
+
+ private val MIGRATION_13_14 = object : Migration(13, 14) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE Notification ADD COLUMN contentType TEXT NOT NULL DEFAULT ('')")
+ }
+ }
+
+ private val MIGRATION_14_15 = object : Migration(14, 15) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE CustomHeader (baseUrl TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(baseUrl, name))")
+ }
+ }
+
+ private val MIGRATION_15_16 = object : Migration(15, 16) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("CREATE TABLE TrustedCertificate (baseUrl TEXT NOT NULL, pem TEXT NOT NULL, PRIMARY KEY(baseUrl))")
+ db.execSQL("CREATE TABLE ClientCertificate (baseUrl TEXT NOT NULL, p12Base64 TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))")
+ }
+ }
+
+ private val MIGRATION_16_17 = object : Migration(16, 17) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("UPDATE Notification SET icon_contentUri = NULL WHERE icon_url IS NULL AND icon_contentUri IS NOT NULL")
+ }
+ }
+
+
+ }
+}
+
+@Dao
+interface SubscriptionDao {
+ @Query("""
+ SELECT
+ s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
+ COUNT(n.id) totalCount,
+ COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
+ IFNULL(MAX(n.timestamp),0) AS lastActive
+ FROM Subscription AS s
+ LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
+ GROUP BY s.id
+ ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
+ """)
+ fun listFlow(): Flow>
+
+ @Query("""
+ SELECT
+ s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
+ COUNT(n.id) totalCount,
+ COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
+ IFNULL(MAX(n.timestamp),0) AS lastActive
+ FROM Subscription AS s
+ LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
+ GROUP BY s.id
+ ORDER BY s.upAppId ASC, MAX(n.timestamp) DESC
+ """)
+ suspend fun list(): List
+
+ @Query("""
+ SELECT
+ s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
+ COUNT(n.id) totalCount,
+ COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
+ IFNULL(MAX(n.timestamp),0) AS lastActive
+ FROM Subscription AS s
+ LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
+ WHERE s.baseUrl = :baseUrl AND s.topic = :topic
+ GROUP BY s.id
+ """)
+ fun get(baseUrl: String, topic: String): SubscriptionWithMetadata?
+
+ @Query("""
+ SELECT
+ s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
+ COUNT(n.id) totalCount,
+ COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
+ IFNULL(MAX(n.timestamp),0) AS lastActive
+ FROM Subscription AS s
+ LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
+ WHERE s.id = :subscriptionId
+ GROUP BY s.id
+ """)
+ fun get(subscriptionId: Long): SubscriptionWithMetadata?
+
+ @Query("""
+ SELECT
+ s.id, s.baseUrl, s.topic, s.mutedUntil, s.minPriority, s.autoDelete, s.insistent, s.upAppId, s.upConnectorToken, s.displayName,
+ COUNT(n.id) totalCount,
+ COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
+ IFNULL(MAX(n.timestamp),0) AS lastActive
+ FROM Subscription AS s
+ LEFT JOIN Notification AS n ON s.id=n.subscriptionId AND n.deleted != 1
+ WHERE s.upConnectorToken = :connectorToken
+ GROUP BY s.id
+ """)
+ fun getByConnectorToken(connectorToken: String): SubscriptionWithMetadata?
+
+ @Insert
+ fun add(subscription: Subscription)
+
+ @Update
+ fun update(subscription: Subscription)
+
+ @Query("DELETE FROM subscription WHERE id = :subscriptionId")
+ fun remove(subscriptionId: Long)
+}
+
+@Dao
+interface NotificationDao {
+ @Query("SELECT * FROM notification")
+ suspend fun list(): List
+
+ @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
+ fun listFlow(subscriptionId: Long): Flow>
+
+ @Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId")
+ fun listIds(subscriptionId: Long): List
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun add(notification: Notification)
+
+ @Update(onConflict = OnConflictStrategy.IGNORE)
+ fun update(notification: Notification)
+
+ @Query("SELECT * FROM notification WHERE id = :notificationId")
+ fun get(notificationId: String): Notification?
+
+ @Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId")
+ fun clearAllNotificationIds(subscriptionId: Long)
+
+ @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId")
+ fun markAsDeleted(notificationId: String)
+
+ @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId")
+ fun markAllAsDeleted(subscriptionId: Long)
+
+ @Query("UPDATE notification SET deleted = 1 WHERE subscriptionId = :subscriptionId AND timestamp < :olderThanTimestamp")
+ fun markAsDeletedIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long)
+
+ @Query("UPDATE notification SET deleted = 0 WHERE id = :notificationId")
+ fun undelete(notificationId: String)
+
+ @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId AND timestamp < :olderThanTimestamp")
+ fun removeIfOlderThan(subscriptionId: Long, olderThanTimestamp: Long)
+
+ @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId")
+ fun removeAll(subscriptionId: Long)
+}
+
+@Dao
+interface LogDao {
+ @Insert
+ suspend fun insert(entry: LogEntry)
+
+ @Query("DELETE FROM log WHERE id NOT IN (SELECT id FROM log ORDER BY timestamp DESC, id DESC LIMIT :keepCount)")
+ suspend fun prune(keepCount: Int)
+
+ @Query("SELECT * FROM log ORDER BY timestamp ASC, id ASC")
+ fun getAll(): List
+
+ @Query("DELETE FROM log")
+ fun deleteAll()
+}
diff --git a/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt b/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt
new file mode 100644
index 0000000..a510692
--- /dev/null
+++ b/android/app/src/main/java/com/lonecloud/sup/db/Repository.kt
@@ -0,0 +1,515 @@
+package com.lonecloud.sup.db
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.media.MediaPlayer
+import android.os.Build
+import androidx.annotation.WorkerThread
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.edit
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.map
+import com.lonecloud.sup.util.Log
+import com.lonecloud.sup.util.validUrl
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicLong
+
+class Repository(private val sharedPrefs: SharedPreferences, database: Database) {
+ private val subscriptionDao = database.subscriptionDao()
+ private val notificationDao = database.notificationDao()
+
+ private val connectionDetails = ConcurrentHashMap()
+ private val connectionDetailsLiveData = MutableLiveData