diff --git a/app/schemas/io.heckel.ntfy.db.Database/12.json b/app/schemas/io.heckel.ntfy.db.Database/12.json new file mode 100644 index 00000000..9743a783 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/12.json @@ -0,0 +1,326 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "b439720b55cf5e6bfdec2b56dd46103d", + "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, 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 + } + ], + "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, 'b439720b55cf5e6bfdec2b56dd46103d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index d49e972b..120f41e0 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -97,6 +97,7 @@ class Backuper(val context: Context) { mutedUntil = s.mutedUntil, minPriority = s.minPriority ?: Repository.MIN_PRIORITY_USE_GLOBAL, autoDelete = s.autoDelete ?: Repository.AUTO_DELETE_USE_GLOBAL, + lastNotificationId = s.lastNotificationId, icon = s.icon, upAppId = s.upAppId, upConnectorToken = s.upConnectorToken @@ -220,6 +221,7 @@ class Backuper(val context: Context) { mutedUntil = s.mutedUntil, minPriority = s.minPriority, autoDelete = s.autoDelete, + lastNotificationId = s.lastNotificationId, icon = s.icon, upAppId = s.upAppId, upConnectorToken = s.upConnectorToken @@ -326,6 +328,7 @@ data class Subscription( val mutedUntil: Long, val minPriority: Int?, val autoDelete: Long?, + val lastNotificationId: String?, val icon: String?, val upAppId: String?, val upConnectorToken: String? diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index 7d64166f..eba9cb92 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -18,6 +18,7 @@ data class Subscription( @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, @ColumnInfo(name = "minPriority") val minPriority: Int, @ColumnInfo(name = "autoDelete") val autoDelete: Long, // Seconds + @ColumnInfo(name = "lastNotificationId") val lastNotificationId: String?, // Used for polling, with since= @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 @@ -26,8 +27,8 @@ data class Subscription( @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, icon: String, upAppId: String, upConnectorToken: String) : - this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, icon, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE) + constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, minPriority: Int, autoDelete: Long, lastNotificationId: String, icon: String, upAppId: String, upConnectorToken: String) : + this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, lastNotificationId, icon, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE) } enum class ConnectionState { @@ -42,6 +43,7 @@ data class SubscriptionWithMetadata( val mutedUntil: Long, val autoDelete: Long, val minPriority: Int, + val lastNotificationId: String?, val icon: String?, val upAppId: String?, val upConnectorToken: String?, @@ -144,7 +146,7 @@ data class LogEntry( this(0, timestamp, tag, level, message, exception) } -@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 11) +@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 12) @TypeConverters(Converters::class) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao @@ -170,6 +172,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_8_9) .addMigrations(MIGRATION_9_10) .addMigrations(MIGRATION_10_11) + .addMigrations(MIGRATION_11_12) .fallbackToDestructiveMigration() .build() this.instance = instance @@ -259,6 +262,12 @@ abstract class Database : RoomDatabase() { 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") + } + } } } @@ -266,7 +275,7 @@ abstract class Database : RoomDatabase() { interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -279,7 +288,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -292,7 +301,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -305,7 +314,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -318,7 +327,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -335,6 +344,9 @@ interface SubscriptionDao { @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) } diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 5786699a..d35a25dc 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -116,6 +116,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas if (maybeExistingNotification != null) { return false } + subscriptionDao.updateLastNotificationId(notification.subscriptionId, notification.id) notificationDao.add(notification) return true } @@ -299,13 +300,13 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas .apply() } - fun getJsonStreamRemindTime(): Long { - return sharedPrefs.getLong(SHARED_PREFS_JSON_STREAM_REMIND_TIME, JSON_STREAM_REMIND_TIME_ALWAYS) + fun getWebSocketRemindTime(): Long { + return sharedPrefs.getLong(SHARED_PREFS_WEBSOCKET_REMIND_TIME, WEBSOCKET_REMIND_TIME_ALWAYS) } - fun setJsonStreamRemindTime(timeMillis: Long) { + fun setWebSocketRemindTime(timeMillis: Long) { sharedPrefs.edit() - .putLong(SHARED_PREFS_JSON_STREAM_REMIND_TIME, timeMillis) + .putLong(SHARED_PREFS_WEBSOCKET_REMIND_TIME, timeMillis) .apply() } @@ -379,6 +380,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas mutedUntil = s.mutedUntil, minPriority = s.minPriority, autoDelete = s.autoDelete, + lastNotificationId = s.lastNotificationId, icon = s.icon, upAppId = s.upAppId, upConnectorToken = s.upConnectorToken, @@ -402,6 +404,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas mutedUntil = s.mutedUntil, minPriority = s.minPriority, autoDelete = s.autoDelete, + lastNotificationId = s.lastNotificationId, icon = s.icon, upAppId = s.upAppId, upConnectorToken = s.upConnectorToken, @@ -448,7 +451,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val SHARED_PREFS_BROADCAST_ENABLED = "BroadcastEnabled" const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs" const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime" - const val SHARED_PREFS_JSON_STREAM_REMIND_TIME = "JsonStreamRemindTime" // Deprecation of JSON stream + 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" @@ -483,8 +486,8 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas const val BATTERY_OPTIMIZATIONS_REMIND_TIME_ALWAYS = 1L const val BATTERY_OPTIMIZATIONS_REMIND_TIME_NEVER = Long.MAX_VALUE - const val JSON_STREAM_REMIND_TIME_ALWAYS = 1L - const val JSON_STREAM_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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 06938340..64baa451 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -84,8 +84,8 @@ class ApiService { } } - fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: Long = 0L): List { - val sinceVal = if (since == 0L) "all" else since.toString() + fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: String? = null): List { + val sinceVal = since ?: "all" val url = topicUrlJsonPoll(baseUrl, topic, sinceVal) Log.d(TAG, "Polling topic $url") @@ -108,12 +108,12 @@ class ApiService { fun subscribe( baseUrl: String, topics: String, - since: Long, + since: String?, user: User?, notify: (topic: String, Notification) -> Unit, fail: (Exception) -> Unit ): Call { - val sinceVal = if (since == 0L) "all" else since.toString() + val sinceVal = since ?: "all" val url = topicUrlJson(baseUrl, topics, sinceVal) Log.d(TAG, "Opening subscription connection to $url") val request = requestBuilder(url, user).build() diff --git a/app/src/main/java/io/heckel/ntfy/service/Connection.kt b/app/src/main/java/io/heckel/ntfy/service/Connection.kt index a8dfed1a..71a98be4 100644 --- a/app/src/main/java/io/heckel/ntfy/service/Connection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/Connection.kt @@ -3,7 +3,7 @@ package io.heckel.ntfy.service interface Connection { fun start() fun close() - fun since(): Long + fun since(): String? } data class ConnectionId( diff --git a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index 9ccb93ef..8bca6883 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -14,7 +14,7 @@ class JsonConnection( private val repository: Repository, private val api: ApiService, private val user: User?, - private val sinceTime: Long, + private val sinceId: String?, private val stateChangeListener: (Collection, ConnectionState) -> Unit, private val notificationListener: (Subscription, Notification) -> Unit, private val serviceActive: () -> Boolean @@ -25,7 +25,7 @@ class JsonConnection( private val topicsStr = topicsToSubscriptionIds.keys.joinToString(separator = ",") private val url = topicUrl(baseUrl, topicsStr) - private var since: Long = sinceTime + private var since: String? = sinceId private lateinit var call: Call private lateinit var job: Job @@ -39,7 +39,7 @@ class JsonConnection( Log.d(TAG, "[$url] (Re-)starting connection for subscriptions: $topicsToSubscriptionIds") val startTime = System.currentTimeMillis() val notify = notify@ { topic: String, notification: Notification -> - since = notification.timestamp + since = notification.id val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify val subscription = repository.getSubscription(subscriptionId) ?: return@notify val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id) @@ -81,7 +81,7 @@ class JsonConnection( } } - override fun since(): Long { + override fun since(): String? { return since } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index b453e4de..634dfe32 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -178,14 +178,8 @@ class SubscriberService : Service() { val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds) val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds) val match = activeConnectionIds == desiredConnectionIds - val newSinceByBaseUrl = connections - .map { e -> - // Get last message timestamp to determine new ?since= param; set to $last+1 if it - // is defined to avoid retrieving old messages. See comment below too. - val lastMessage = e.value.since() - val newSince = if (lastMessage > 0) lastMessage+1 else 0 - e.key.baseUrl to newSince - } + val sinceByBaseUrl = connections + .map { e -> e.key.baseUrl to e.value.since() } // Use since=, avoid retrieving old messages (see comment below) .toMap() Log.d(TAG, "Refreshing subscriptions") @@ -205,7 +199,7 @@ class SubscriberService : Service() { // 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 = newSinceByBaseUrl[connectionId.baseUrl] ?: (System.currentTimeMillis() / 1000) + val since = sinceByBaseUrl[connectionId.baseUrl] ?: "none" val serviceActive = { -> isServiceStarted } val user = repository.getUser(connectionId.baseUrl) val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) { diff --git a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index a79ce075..1f168d39 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -5,15 +5,19 @@ import android.os.Build import android.os.Handler import android.os.Looper import io.heckel.ntfy.db.* -import io.heckel.ntfy.util.Log 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.* +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 /** @@ -30,7 +34,7 @@ class WsConnection( private val connectionId: ConnectionId, private val repository: Repository, private val user: User?, - private val sinceTime: Long, + private val sinceId: String?, private val stateChangeListener: (Collection, ConnectionState) -> Unit, private val notificationListener: (Subscription, Notification) -> Unit, private val alarmManager: AlarmManager @@ -49,7 +53,7 @@ class WsConnection( private val globalId = GLOBAL_ID.incrementAndGet() private val listenerId = AtomicLong(0) - private val since = AtomicLong(sinceTime) + private val since = AtomicReference(sinceId) private val baseUrl = connectionId.baseUrl private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds private val subscriptionIds = topicsToSubscriptionIds.values @@ -71,7 +75,8 @@ class WsConnection( } state = State.Connecting val nextListenerId = listenerId.incrementAndGet() - val sinceVal = if (since.get() == 0L) "all" else since.get().toString() + val sinceId = since.get() + val sinceVal = sinceId ?: "all" val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal) val request = requestBuilder(urlWithSince, user).build() Log.d(TAG, "$shortUrl (gid=$globalId): Opening $urlWithSince with listener ID $nextListenerId ...") @@ -92,7 +97,7 @@ class WsConnection( } @Synchronized - override fun since(): Long { + override fun since(): String? { return since.get() } @@ -141,7 +146,7 @@ class WsConnection( val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id) notificationListener(subscription, notificationWithSubscriptionId) - since.set(notification.timestamp) + since.set(notification.id) } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/BaseUrl.kt b/app/src/main/java/io/heckel/ntfy/ui/BaseUrl.kt index d1fd23a9..d54de060 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/BaseUrl.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/BaseUrl.kt @@ -9,13 +9,17 @@ import io.heckel.ntfy.R fun initBaseUrlDropdown(baseUrls: List, textView: AutoCompleteTextView, layout: TextInputLayout) { // Base URL dropdown behavior; Oh my, why is this so complicated?! + val context = layout.context val toggleEndIcon = { if (textView.text.isNotEmpty()) { layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_clear) } else if (baseUrls.isEmpty()) { layout.setEndIconDrawable(0) + layout.endIconContentDescription = "" } else { layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose) } } layout.setEndIconOnClickListener { @@ -23,11 +27,14 @@ fun initBaseUrlDropdown(baseUrls: List, textView: AutoCompleteTextView, 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() } } @@ -49,10 +56,13 @@ fun initBaseUrlDropdown(baseUrls: List, textView: AutoCompleteTextView, textView.setAdapter(adapter) if (baseUrls.count() == 1) { layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_clear) textView.setText(baseUrls.first()) } else if (baseUrls.count() > 1) { layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp) + layout.endIconContentDescription = context.getString(R.string.add_dialog_base_urls_dropdown_choose) } else { layout.setEndIconDrawable(0) + layout.endIconContentDescription = "" } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 7e2621bf..cfa4553b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -105,13 +105,14 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra if (subscription == null) { val instant = baseUrl != appBaseUrl subscription = Subscription( - id = Random.nextLong(), + id = randomSubscriptionId(), baseUrl = baseUrl, topic = topic, instant = instant, mutedUntil = 0, minPriority = Repository.MIN_PRIORITY_USE_GLOBAL, autoDelete = Repository.AUTO_DELETE_USE_GLOBAL, + lastNotificationId = null, icon = null, upAppId = null, upConnectorToken = null, @@ -457,8 +458,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra lifecycleScope.launch(Dispatchers.IO) { try { - val user = repository.getUser(subscriptionBaseUrl) // May be null - val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic, user) + 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) diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 6781f984..5321a85b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -9,11 +9,13 @@ 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 @@ -41,6 +43,7 @@ import io.heckel.ntfy.work.PollWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.security.SecureRandom import java.util.* import java.util.concurrent.TimeUnit import kotlin.random.Random @@ -119,9 +122,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc Log.addScrubTerm(s.topic) } - // Update banner + JSON stream banner + // Update banner + WebSocket banner showHideBatteryBanner(subscriptions) - showHideJsonStreamBanner(subscriptions) + showHideWebSocketBanner(subscriptions) } } @@ -169,21 +172,25 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc } } - // JSON stream banner - val jsonStreamBanner = findViewById(R.id.main_banner_json_stream) // Banner visibility is toggled in onResume() - val jsonStreamDismissButton = findViewById