diff --git a/app/schemas/io.heckel.ntfy.db.Database/7.json b/app/schemas/io.heckel.ntfy.db.Database/7.json index 0e09a5a..096ca25 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/7.json +++ b/app/schemas/io.heckel.ntfy.db.Database/7.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 7, - "identityHash": "eda2cb9740c4542f24462779eb6ff81d", + "identityHash": "ecb1b85b2ae822dc62b2843620368477", "entities": [ { "tableName": "Subscription", @@ -65,7 +65,6 @@ "baseUrl", "topic" ], - "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" }, { @@ -74,7 +73,6 @@ "columnNames": [ "upConnectorToken" ], - "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)" } ], @@ -198,38 +196,6 @@ "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)", @@ -284,7 +250,7 @@ "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, 'eda2cb9740c4542f24462779eb6ff81d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ecb1b85b2ae822dc62b2843620368477')" ] } } \ No newline at end of file diff --git a/app/schemas/io.heckel.ntfy.db.Database/8.json b/app/schemas/io.heckel.ntfy.db.Database/8.json new file mode 100644 index 0000000..2f25cff --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/8.json @@ -0,0 +1,290 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "eda2cb9740c4542f24462779eb6ff81d", + "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, `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": "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, 'eda2cb9740c4542f24462779eb6ff81d')" + ] + } +} \ No newline at end of file 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 6625d92..232cd72 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -100,7 +100,7 @@ data class LogEntry( this(0, timestamp, tag, level, message, exception) } -@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 7) +@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 8) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao abstract fun notificationDao(): NotificationDao @@ -121,6 +121,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_4_5) .addMigrations(MIGRATION_5_6) .addMigrations(MIGRATION_6_7) + .addMigrations(MIGRATION_7_8) .fallbackToDestructiveMigration() .build() this.instance = instance @@ -184,6 +185,12 @@ abstract class Database : RoomDatabase() { 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))") + } + } } } @@ -243,7 +250,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil,s.upAppId, s.upConnectorToken, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, 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 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 220d43f..cb1b549 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -219,16 +219,6 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas return sharedPrefs.getInt(SHARED_PREFS_DARK_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) } - fun getWakelockEnabled(): Boolean { - return sharedPrefs.getBoolean(SHARED_PREFS_WAKELOCK_ENABLED, false) // Disabled by default! - } - - fun setWakelockEnabled(enabled: Boolean) { - sharedPrefs.edit() - .putBoolean(SHARED_PREFS_WAKELOCK_ENABLED, enabled) - .apply() - } - fun setConnectionProtocol(connectionProtocol: String) { if (connectionProtocol == CONNECTION_PROTOCOL_JSONHTTP) { sharedPrefs.edit() diff --git a/app/src/main/java/io/heckel/ntfy/log/Log.kt b/app/src/main/java/io/heckel/ntfy/log/Log.kt index 25937d8..c510a19 100644 --- a/app/src/main/java/io/heckel/ntfy/log/Log.kt +++ b/app/src/main/java/io/heckel/ntfy/log/Log.kt @@ -6,8 +6,6 @@ import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.db.Database import io.heckel.ntfy.db.LogDao import io.heckel.ntfy.db.LogEntry -import io.heckel.ntfy.db.Repository -import io.heckel.ntfy.util.isIgnoringBatteryOptimizations import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -34,15 +32,19 @@ class Log(private val logsDao: LogDao) { } } - fun getFormatted(): String { - return prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll()))) + fun getFormatted(scrub: Boolean): String { + return if (scrub) { + prependDeviceInfo(formatEntries(scrubEntries(logsDao.getAll())), scrubLine = true) + } else { + prependDeviceInfo(formatEntries(logsDao.getAll()), scrubLine = false) + } } - private fun prependDeviceInfo(s: String): String { + private fun prependDeviceInfo(s: 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 2,000 lines. - Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑. - + 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}) @@ -116,7 +118,7 @@ class Log(private val logsDao: LogDao) { companion object { private const val TAG = "NtfyLog" private const val PRUNE_EVERY = 100 - private const val ENTRIES_MAX = 2000 + 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" @@ -153,8 +155,12 @@ class Log(private val logsDao: LogDao) { return getInstance()?.record?.get() ?: false } - fun getFormatted(): String { - return getInstance()?.getFormatted() ?: "(no logs)" + fun getFormatted(scrub: Boolean): String { + return getInstance()?.getFormatted(scrub) ?: "(no logs)" + } + + fun getScrubTerms(): Map { + return getInstance()?.scrubTerms!!.toMap() } fun deleteAll() { 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 1eaaf39..a4219ca 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -47,6 +47,9 @@ class ApiService { builder.addHeader("X-Delay", delay) } client.newCall(builder.build()).execute().use { response -> + if (response.code == 401 || response.code == 403) { + throw UnauthorizedException(user) + } if (!response.isSuccessful) { throw Exception("Unexpected response ${response.code} when publishing to $url") } @@ -132,6 +135,8 @@ class ApiService { } } + class UnauthorizedException(val user: User?) : 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" diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt index 8b5a979..976d354 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -68,16 +68,21 @@ class BroadcastService(private val ctx: Context) { GlobalScope.launch(Dispatchers.IO) { val repository = Repository.getInstance(ctx) val user = repository.getUser(baseUrl) // May be null - api.publish( - baseUrl = baseUrl, - topic = topic, - user = user, - message = message, - title = title, - priority = priority, - tags = splitTags(tags), - delay = delay - ) + 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) + } } } 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 14e4095..d12d493 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -21,9 +21,11 @@ import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.ui.MainActivity 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 /** @@ -63,16 +65,16 @@ class SubscriberService : Service() { 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) { - val action = intent.action - Log.d(TAG, "using an intent with action $action") - when (action) { + Log.d(TAG, "using an intent with action ${intent.action}") + when (intent.action) { Action.START.name -> startService() Action.STOP.name -> stopService() - else -> Log.e(TAG, "This should never happen. No action in the received intent") + 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.") @@ -116,9 +118,6 @@ class SubscriberService : Service() { wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG) } - if (repository.getWakelockEnabled()) { - wakeLock?.acquire() - } refreshConnections() } @@ -148,95 +147,115 @@ class SubscriberService : Service() { saveServiceState(this, ServiceState.STOPPED) } - private fun refreshConnections() = + private fun refreshConnections() { GlobalScope.launch(Dispatchers.IO) { - // 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 - .groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) } - .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) } - .toSet() - val newConnectionIds = desiredConnectionIds subtract activeConnectionIds - val obsoleteConnectionIds = activeConnectionIds subtract desiredConnectionIds - val match = activeConnectionIds == desiredConnectionIds - - 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.") + if (!refreshMutex.tryLock()) { + Log.d(TAG, "Refreshing subscriptions already in progress. Skipping.") return@launch } - - // Open new connections - newConnectionIds.forEach { connectionId -> - // FIXME since !!! - - // Do NOT request old messages for new connections; we'll call poll() in MainActivity. - // This is important, so we don't download attachments from old messages, which is not desired. - - val since = System.currentTimeMillis()/1000 - 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, this, 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) { - synchronized(this) { - 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) - 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) - else -> getString(R.string.channel_subscriber_notification_noinstant_text_more, instantSubscriptions.size) - } - } - serviceNotification = createNotification(title, text) - notificationManager?.notify(NOTIFICATION_SERVICE_ID, serviceNotification) - } + 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 + .groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) } + .map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) } + .toSet() + val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds) + val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds) + 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 + } + .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 = newSinceByBaseUrl[connectionId.baseUrl] ?: (System.currentTimeMillis() / 1000) + 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) + 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) + 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, state: ConnectionState) { repository.updateState(subscriptionIds, state) } private fun onNotificationReceived(subscription: Subscription, notification: io.heckel.ntfy.db.Notification) { - // If permanent wakelock is not enabled, still take the wakelock while notifications are being dispatched - if (!repository.getWakelockEnabled()) { - // Wakelocks are reference counted by default so that should work neatly here - wakeLock?.acquire(10*60*1000L /*10 minutes*/) - } + // 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") @@ -245,12 +264,9 @@ class SubscriberService : Service() { Log.d(TAG, "[$url] Dispatching notification $notification") dispatcher.dispatch(subscription, notification) } - - if (!repository.getWakelockEnabled()) { - wakeLock?.let { - if (it.isHeld) { - it.release() - } + wakeLock?.let { + if (it.isHeld) { + it.release() } } } @@ -337,6 +353,7 @@ class SubscriberService : Service() { private const val WAKE_LOCK_TAG = "SubscriberService:lock" private const val NOTIFICATION_CHANNEL_ID = "ntfy-subscriber" 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" diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt index 4113df6..abdd6d1 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt @@ -21,7 +21,7 @@ class SubscriberServiceManager(private val context: Context) { Log.d(TAG, "Enqueuing work to refresh subscriber service") val workManager = WorkManager.getInstance(context) val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build() - workManager.enqueue(startServiceRequest) + workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races! } fun restart() { @@ -59,6 +59,7 @@ class SubscriberServiceManager(private val context: Context) { companion object { const val TAG = "NtfySubscriberMgr" + const val WORK_NAME_ONCE = "ServiceStartWorkerOnce" fun refresh(context: Context) { val manager = SubscriberServiceManager(context) 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 821d574..e0370a3 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -43,43 +43,49 @@ class WsConnection( .build() private var errorCount = 0 private var webSocket: WebSocket? = null - private val listenerId = AtomicLong(0) private var state: State? = null private var closed = false - private var since: Long = sinceTime + private val globalId = GLOBAL_ID.incrementAndGet() + private val listenerId = AtomicLong(0) + + private val since = AtomicLong(sinceTime) private val baseUrl = connectionId.baseUrl private val topicsToSubscriptionIds = connectionId.topicsToSubscriptionIds private val subscriptionIds = topicsToSubscriptionIds.values private val topicsStr = topicsToSubscriptionIds.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: Not (re-)starting, because connection is marked closed/connecting/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 nextId = listenerId.incrementAndGet() - val sinceVal = if (since == 0L) "all" else since.toString() + val nextListenerId = listenerId.incrementAndGet() + val sinceVal = if (since.get() == 0L) "all" else since.get().toString() val urlWithSince = topicUrlWs(baseUrl, topicsStr, sinceVal) val request = requestBuilder(urlWithSince, user).build() - Log.d(TAG, "$shortUrl: Opening $urlWithSince with listener ID $nextId ...") - webSocket = client.newWebSocket(request, Listener(nextId)) + 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: Not closing existing connection, because there is no active web socket") + Log.d(TAG,"$shortUrl (gid=$globalId): Not closing existing connection, because there is no active web socket") return } - Log.d(TAG, "$shortUrl: Closing existing connection") + Log.d(TAG, "$shortUrl (gid=$globalId): Closing connection") state = State.Disconnected webSocket!!.close(WS_CLOSE_NORMAL, "") webSocket = null @@ -87,23 +93,23 @@ class WsConnection( @Synchronized override fun since(): Long { - return since + return since.get() } @Synchronized fun scheduleReconnect(seconds: Int) { if (closed || state == State.Connecting || state == State.Connected) { - Log.d(TAG,"$shortUrl: Not rescheduling connection, because connection is marked closed/connecting/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: Scheduling a restart in $seconds seconds (via alarm manager)") + 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: Scheduling a restart in $seconds seconds (via handler)") + 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())) } @@ -112,7 +118,7 @@ class WsConnection( private inner class Listener(private val id: Long) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { synchronize("onOpen") { - Log.d(TAG, "$shortUrl (listener $id): Opened connection") + Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Opened connection") state = State.Connected if (errorCount > 0) { errorCount = 0 @@ -123,10 +129,10 @@ class WsConnection( override fun onMessage(webSocket: WebSocket, text: String) { synchronize("onMessage") { - Log.d(TAG, "$shortUrl (listener $id): Received message: $text") + 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 (listener $id): Irrelevant or unknown message. Discarding.") + Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Irrelevant or unknown message. Discarding.") return@synchronize } val topic = notificationWithTopic.topic @@ -135,13 +141,13 @@ class WsConnection( val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id) notificationListener(subscription, notificationWithSubscriptionId) - since = notification.timestamp + since.set(notification.timestamp) } } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { synchronize("onClosed") { - Log.w(TAG, "$shortUrl (listener $id): Closed connection") + Log.w(TAG, "$shortUrl (gid=$globalId, lid=$id): Closed connection") state = State.Disconnected } } @@ -149,12 +155,12 @@ class WsConnection( override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { synchronize("onFailure") { if (response == null) { - Log.e(TAG, "$shortUrl (listener $id): Connection failed (response is null): ${t.message}", t) + Log.e(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection failed (response is null): ${t.message}", t) } else { - Log.e(TAG, "$shortUrl (listener $id): Connection failed (response code ${response.code}, message: ${response.message}): ${t.message}", t) + 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 (listener $id): Connection marked as closed. Not retrying.") + Log.d(TAG, "$shortUrl (gid=$globalId, lid=$id): Connection marked as closed. Not retrying.") return@synchronize } stateChangeListener(subscriptionIds, ConnectionState.CONNECTING) @@ -170,7 +176,7 @@ class WsConnection( if (listenerId.get() == id) { fn() } else { - Log.w(TAG, "$shortUrl (listener $id): Skipping synchronized block '$tag', because listener ID does not match ${listenerId.get()}") + Log.w(TAG, "$shortUrl (gid=$globalId, lid=$id): Skipping synchronized block '$tag', because listener ID does not match ${listenerId.get()}") } } } @@ -185,5 +191,6 @@ class WsConnection( 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) } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 419c298..4a3c72a 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -46,14 +46,16 @@ class AddFragment : DialogFragment() { private lateinit var subscribeInstantDeliveryCheckbox: CheckBox private lateinit var subscribeInstantDeliveryDescription: View private lateinit var subscribeProgress: ProgressBar - private lateinit var subscribeErrorImage: View + private lateinit var subscribeErrorText: TextView + private lateinit var subscribeErrorTextImage: View // Login page private lateinit var users: List private lateinit var loginUsernameText: TextInputEditText private lateinit var loginPasswordText: TextInputEditText private lateinit var loginProgress: ProgressBar - private lateinit var loginErrorImage: View + private lateinit var loginErrorText: TextView + private lateinit var loginErrorTextImage: View private lateinit var baseUrls: List // List of base URLs already used, excluding app_base_url @@ -84,22 +86,26 @@ class AddFragment : DialogFragment() { loginView.visibility = View.GONE // Fields for "subscribe page" - subscribeTopicText = view.findViewById(R.id.add_dialog_topic_text) - subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_base_url_layout) - subscribeBaseUrlText = view.findViewById(R.id.add_dialog_base_url_text) - subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box) - subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_instant_delivery_checkbox) - subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_instant_delivery_description) - subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) - subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_use_another_server_description) - subscribeProgress = view.findViewById(R.id.add_dialog_progress) - subscribeErrorImage = view.findViewById(R.id.add_dialog_error_image) + subscribeTopicText = view.findViewById(R.id.add_dialog_subscribe_topic_text) + subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_subscribe_base_url_layout) + subscribeBaseUrlText = view.findViewById(R.id.add_dialog_subscribe_base_url_text) + 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) + 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) - loginErrorImage = view.findViewById(R.id.add_dialog_login_error_image) + loginErrorText = view.findViewById(R.id.add_dialog_login_error_text) + loginErrorTextImage = view.findViewById(R.id.add_dialog_login_error_text_image) // Set "Use another server" description based on flavor subscribeUseAnotherServerDescription.text = if (BuildConfig.FIREBASE_AVAILABLE) { @@ -268,7 +274,8 @@ class AddFragment : DialogFragment() { private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) { subscribeProgress.visibility = View.VISIBLE - subscribeErrorImage.visibility = View.GONE + subscribeErrorText.visibility = View.GONE + subscribeErrorTextImage.visibility = View.GONE enableSubscribeView(false) lifecycleScope.launch(Dispatchers.IO) { try { @@ -280,7 +287,7 @@ class AddFragment : DialogFragment() { } else { if (user != null) { Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, but user already exists") - showToastAndReenableSubscribeView(getString(R.string.add_dialog_login_error_not_authorized)) + showErrorAndReenableSubscribeView(getString(R.string.add_dialog_login_error_not_authorized)) } 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" @@ -291,26 +298,26 @@ class AddFragment : DialogFragment() { } } catch (e: Exception) { Log.w(TAG, "Connection to topic failed: ${e.message}", e) - showToastAndReenableSubscribeView(e.message) + showErrorAndReenableSubscribeView(e.message) } } } - private fun showToastAndReenableSubscribeView(message: String?) { + private fun showErrorAndReenableSubscribeView(message: String?) { val activity = activity ?: return // We may have pressed "Cancel" activity.runOnUiThread { subscribeProgress.visibility = View.GONE - subscribeErrorImage.visibility = View.VISIBLE + subscribeErrorText.visibility = View.VISIBLE + subscribeErrorText.text = message + subscribeErrorTextImage.visibility = View.VISIBLE enableSubscribeView(true) - Toast - .makeText(context, message, Toast.LENGTH_LONG) - .show() } } private fun loginAndMaybeDismiss(baseUrl: String, topic: String) { loginProgress.visibility = View.VISIBLE - loginErrorImage.visibility = View.GONE + loginErrorText.visibility = View.GONE + loginErrorTextImage.visibility = View.GONE enableLoginView(false) val user = User( baseUrl = baseUrl, @@ -327,24 +334,23 @@ class AddFragment : DialogFragment() { dismissDialog() } else { Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}") - showToastAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized)) + showErrorAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized)) } } catch (e: Exception) { Log.w(TAG, "Connection to topic failed during login: ${e.message}", e) - showToastAndReenableLoginView(e.message) + showErrorAndReenableLoginView(e.message) } } } - private fun showToastAndReenableLoginView(message: String?) { + private fun showErrorAndReenableLoginView(message: String?) { val activity = activity ?: return // We may have pressed "Cancel" activity.runOnUiThread { loginProgress.visibility = View.GONE - loginErrorImage.visibility = View.VISIBLE + loginErrorText.visibility = View.VISIBLE + loginErrorText.text = message + loginErrorTextImage.visibility = View.VISIBLE enableLoginView(true) - Toast - .makeText(context, message, Toast.LENGTH_LONG) - .show() } } @@ -448,7 +454,8 @@ class AddFragment : DialogFragment() { private fun resetSubscribeView() { subscribeProgress.visibility = View.GONE - subscribeErrorImage.visibility = View.GONE + subscribeErrorText.visibility = View.GONE + subscribeErrorTextImage.visibility = View.GONE enableSubscribeView(true) } @@ -464,7 +471,8 @@ class AddFragment : DialogFragment() { private fun resetLoginView() { loginProgress.visibility = View.GONE - loginErrorImage.visibility = View.GONE + loginErrorText.visibility = View.GONE + loginErrorTextImage.visibility = View.GONE loginUsernameText.visibility = View.VISIBLE loginUsernameText.text?.clear() loginPasswordText.visibility = View.VISIBLE 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 113b490..912b2a8 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -281,8 +281,17 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra 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, getString(R.string.detail_test_message_error, e.message), Toast.LENGTH_LONG) + .makeText(this@DetailActivity, message, Toast.LENGTH_LONG) .show() } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt index 3fe8b00..887b270 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.launch /** * Subscription settings + * + * THIS IS CURRENTLY UNUSED. */ class DetailSettingsActivity : AppCompatActivity() { private lateinit var repository: Repository diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index a010ad9..7d92061 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -1,9 +1,11 @@ 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.DialogInterface import android.content.pm.PackageManager import android.os.Build import android.os.Bundle @@ -98,14 +100,9 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere pref: Preference ): Boolean { // Instantiate the new Fragment - val args = pref.extras - val fragment = supportFragmentManager.fragmentFactory.instantiate( - classLoader, - pref.fragment!! - ).apply { - arguments = args - setTargetFragment(caller, 0) - } + 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() @@ -118,7 +115,6 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere if (fragment is UserSettingsFragment) { userSettingsFragment = fragment } - return true } @@ -331,8 +327,10 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v -> when (v) { - EXPORT_LOGS_COPY -> copyLogsToClipboard() - EXPORT_LOGS_UPLOAD -> uploadLogsToNopaste() + 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 } @@ -368,6 +366,15 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere getString(R.string.settings_advanced_record_logs_summary_disabled) } } + recordLogsEnabled?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v -> + lifecycleScope.launch(Dispatchers.IO) { + repository.getSubscriptions().forEach { s -> + Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain) + Log.addScrubTerm(s.topic) + } + } + false + } // Connection protocol val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return @@ -390,27 +397,6 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere } } - // Permanent wakelock enabled - val wakelockEnabledPrefId = context?.getString(R.string.settings_advanced_wakelock_key) ?: return - val wakelockEnabled: SwitchPreference? = findPreference(wakelockEnabledPrefId) - wakelockEnabled?.isChecked = repository.getWakelockEnabled() - wakelockEnabled?.preferenceDataStore = object : PreferenceDataStore() { - override fun putBoolean(key: String?, value: Boolean) { - repository.setWakelockEnabled(value) - restartService() - } - override fun getBoolean(key: String?, defValue: Boolean): Boolean { - return repository.getWakelockEnabled() - } - } - wakelockEnabled?.summaryProvider = Preference.SummaryProvider { pref -> - if (pref.isChecked) { - getString(R.string.settings_advanced_wakelock_summary_enabled) - } else { - getString(R.string.settings_advanced_wakelock_summary_disabled) - } - } - // Version val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return val versionPref: Preference? = findPreference(versionPrefId) @@ -441,25 +427,29 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere serviceManager.restart() // Service will auto-restart } - private fun copyLogsToClipboard() { + private fun copyLogsToClipboard(scrub: Boolean) { lifecycleScope.launch(Dispatchers.IO) { - val log = Log.getFormatted() + val log = Log.getFormatted(scrub = scrub) val context = context ?: return@launch requireActivity().runOnUiThread { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("ntfy logs", log) clipboard.setPrimaryClip(clip) - Toast - .makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG) - .show() + 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() { + private fun uploadLogsToNopaste(scrub: Boolean) { lifecycleScope.launch(Dispatchers.IO) { Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...") - val log = Log.getFormatted() + val log = Log.getFormatted(scrub = scrub) if (log.length > EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD) { requireActivity().runOnUiThread { Toast @@ -492,9 +482,13 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("logs URL", resp.url) clipboard.setPrimaryClip(clip) - Toast - .makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG) - .show() + 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) { @@ -509,6 +503,22 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere } } + 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() @@ -657,8 +667,10 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere 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 EXPORT_LOGS_COPY = "copy" - private const val EXPORT_LOGS_UPLOAD = "upload" + 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 } diff --git a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt index d5e16c0..1e17806 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt @@ -118,6 +118,16 @@ class UserFragment : DialogFragment() { 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() } diff --git a/app/src/main/res/drawable/ic_error_black_24dp.xml b/app/src/main/res/drawable/ic_error_red_24dp.xml similarity index 100% rename from app/src/main/res/drawable/ic_error_black_24dp.xml rename to app/src/main/res/drawable/ic_error_red_24dp.xml diff --git a/app/src/main/res/layout/fragment_add_dialog.xml b/app/src/main/res/layout/fragment_add_dialog.xml index 7d16f21..6e8fbf7 100644 --- a/app/src/main/res/layout/fragment_add_dialog.xml +++ b/app/src/main/res/layout/fragment_add_dialog.xml @@ -12,9 +12,9 @@ android:layout_height="match_parent" android:orientation="horizontal" android:id="@+id/add_dialog_subscribe_view" - android:visibility="gone"> + android:visibility="visible"> + /> + app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_title_text"/> + app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_description"/> + app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_description"> + app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_base_url_layout" android:layout_marginTop="-3dp"> @@ -122,25 +121,31 @@ + app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_instant_delivery_box"/> + android:layout_width="20dp" + android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp" + android:id="@+id/add_dialog_subscribe_error_text_image" + android:visibility="gone" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/add_dialog_subscribe_error_text" android:layout_marginTop="1dp"/> + + /> + + - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 594b6fb..c504937 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,7 +91,7 @@ Instant delivery in doze mode Ensures that messages are immediately delivered, even if the device is inactive or in doze mode. - This requires a foreground service and consumes a little more power. + This requires a foreground service. Cancel Subscribe @@ -127,7 +127,9 @@ This is a test notification from the Ntfy Android app. It has a priority of %1$d. If you send another one, it may look different. - Could not send test message: %1$s + Cannot send message: %1$s + Cannot send message: Anonymous publishing not allowed + Cannot send message: User %1$s not authorized Copied to clipboard Instant delivery enabled Instant delivery disabled @@ -178,13 +180,7 @@ Subscription settings - Login - For topics that require a login, you may pick the login user here. You can add/edit users in the main settings. - SubscriptionAuthUserKey - Login user - Anonymous login - No user selected to log in to the topic - User %1$s selected as a login user + Pause notifications @@ -278,17 +274,22 @@ Apps cannot receive notifications as broadcasts RecordLogs Record logs - Logs are currently being recorded to your device. Up to 2,000 log lines are stored. + Logs are currently being recorded to your device. Up to 1,000 log entries are stored. Enable log recording, so you can share the logs later. This is useful for diagnosing issues. ExportLogs Copy/upload logs - Copy logs to the clipboard, or upload to nopaste.net (owned by ntfy author). Hostnames and topics are scrubbed, notifications are not. - Copy to clipboard - Upload to nopaste.net + Copy logs to the clipboard, or upload to nopaste.net (owned by ntfy author). Hostnames and topics can be censored, notifications will never be. + Copy to clipboard + Copy to clipboard (censored) + Upload & copy link + Upload & copy link (censored) Logs copied to clipboard Uploading log … - URL copied to clipboard + Logs uploaded & URL copied Error uploading logs: %1$s + The following topics/hostnames were replaced with fruit names, so you can share the log without worry:\n\n%1$s + No topics/hostnames were redacted, maybe you don\'t have any subscriptions? + Got it ClearLogs Clear logs Delete previously recorded logs, and start over @@ -300,10 +301,6 @@ Use WebSockets to connect to the server. This option is experimental. Let us know if it consumes less battery or is unstable. JSON stream over HTTP WebSockets (experimental) - WakelockEnabled - Permanent wakelock - Prevents app from sleeping to ensure timely notification delivery. This consumes a lot of battery, but some devices require this. - Allows app to enter sleep mode. This may negatively impact notification delivery. It depends on the device. About Version Version diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 6c4742c..0d6cd9a 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -62,12 +62,16 @@ ws - @string/settings_advanced_export_logs_entry_copy - @string/settings_advanced_export_logs_entry_upload + @string/settings_advanced_export_logs_entry_copy_original + @string/settings_advanced_export_logs_entry_copy_scrubbed + @string/settings_advanced_export_logs_entry_upload_original + @string/settings_advanced_export_logs_entry_upload_scrubbed - copy - upload + copy_original + copy_scrubbed + upload_original + upload_scrubbed @string/settings_appearance_dark_mode_entry_system diff --git a/app/src/main/res/xml/detail_preferences.xml b/app/src/main/res/xml/detail_preferences.xml index 120c845..380b4fd 100644 --- a/app/src/main/res/xml/detail_preferences.xml +++ b/app/src/main/res/xml/detail_preferences.xml @@ -1,12 +1,3 @@ - - - diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index f67dfd7..0fdb182 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -74,10 +74,6 @@ app:key="@string/settings_advanced_clear_logs_key" app:title="@string/settings_advanced_clear_logs_title" app:summary="@string/settings_advanced_clear_logs_summary"/> - \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelog/20.txt b/fastlane/metadata/android/en-US/changelog/20.txt index e349284..7e3db19 100644 --- a/fastlane/metadata/android/en-US/changelog/20.txt +++ b/fastlane/metadata/android/en-US/changelog/20.txt @@ -1,3 +1,14 @@ +Features: +* Support auth / access control (#19, thanks to @cmeis, @gedw99, @karmanyaahm, + @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher) +* Export/upload log now allows censored/uncensored logs (no ticket) +* Removed wake lock (except for notification dispatching, no ticket) + Bug fixes: * Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob) * Fix for Android 12 crashes (#124, thanks @eskilop) +* Fix WebSocket retry logic bug with multiple servers (no ticket) +* Fix race in refresh logic leading to duplicate connections (no ticket) + +Notes: +* Foundational work for per-subscription settings