diff --git a/app/build.gradle b/app/build.gradle index 8b3784d..d1cc6aa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,6 +16,14 @@ android { versionName "1.0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + /* Required for Room schema migrations */ + javaCompileOptions { + annotationProcessorOptions { + arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } + } buildTypes { diff --git a/app/schemas/io.heckel.ntfy.data.Database/2.json b/app/schemas/io.heckel.ntfy.data.Database/2.json new file mode 100644 index 0000000..2112233 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.data.Database/2.json @@ -0,0 +1,100 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "30177aa8688290d24499babf22b15720", + "entities": [ + { + "tableName": "Subscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topic", + "columnName": "topic", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_Subscription_baseUrl_topic", + "unique": true, + "columnNames": [ + "baseUrl", + "topic" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '30177aa8688290d24499babf22b15720')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 24c932f..65ac5dd 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -1,8 +1,11 @@ package io.heckel.ntfy.data import android.content.Context +import androidx.annotation.NonNull import androidx.lifecycle.LiveData import androidx.room.* +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import kotlinx.coroutines.flow.Flow @Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)]) @@ -10,8 +13,18 @@ data class Subscription( @PrimaryKey val id: Long, // Internal ID, only used in Repository and activities @ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "topic") val topic: String, - @ColumnInfo(name = "notifications") val notifications: Int, - @ColumnInfo(name = "lastActive") val lastActive: Long, // Unix timestamp + @Ignore val notifications: Int, + @Ignore val lastActive: Long = 0 // Unix timestamp +) { + constructor(id: Long, baseUrl: String, topic: String) : this(id, baseUrl, topic, 0, 0) +} + +data class SubscriptionWithMetadata( + val id: Long, + val baseUrl: String, + val topic: String, + val notifications: Int, + val lastActive: Long ) @Entity @@ -19,10 +32,11 @@ data class Notification( @PrimaryKey val id: String, @ColumnInfo(name = "subscriptionId") val subscriptionId: Long, @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp - @ColumnInfo(name = "message") val message: String + @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "deleted") val deleted: Boolean ) -@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 1) +@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 2) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao abstract fun notificationDao(): NotificationDao @@ -35,45 +49,81 @@ abstract class Database : RoomDatabase() { return instance ?: synchronized(this) { val instance = Room .databaseBuilder(context.applicationContext, Database::class.java,"AppDatabase") + .addMigrations(MIGRATION_1_2) .fallbackToDestructiveMigration() .build() this.instance = instance instance } } + + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + // Drop "notifications" & "lastActive" columns (SQLite does not support dropping columns, ...) + db.execSQL("CREATE TABLE Subscription_New (id INTEGER NOT NULL, baseUrl TEXT NOT NULL, topic TEXT NOT NULL, PRIMARY KEY(id))") + db.execSQL("INSERT INTO Subscription_New SELECT id, baseUrl, topic FROM Subscription") + db.execSQL("DROP TABLE Subscription") + db.execSQL("ALTER TABLE Subscription_New RENAME TO Subscription") + db.execSQL("CREATE UNIQUE INDEX index_Subscription_baseUrl_topic ON Subscription (baseUrl, topic)") + + // Add "deleted" column + db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')") + } + } } } @Dao interface SubscriptionDao { - @Query("SELECT * FROM subscription ORDER BY lastActive DESC") - fun listFlow(): Flow> + @Query( + "SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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 MAX(n.timestamp) DESC" + ) + fun listFlow(): Flow> - @Query("SELECT * FROM subscription ORDER BY lastActive DESC") - fun list(): List + @Query( + "SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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 MAX(n.timestamp) DESC" + ) + fun list(): List - @Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic") - fun get(baseUrl: String, topic: String): Subscription? + @Query( + "SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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 * FROM subscription WHERE id = :subscriptionId") - fun get(subscriptionId: Long): Subscription? + @Query( + "SELECT s.id, s.baseUrl, s.topic, COUNT(n.id) notifications, 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? @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 WHERE subscriptionId = :subscriptionId ORDER BY timestamp DESC") + @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC") fun list(subscriptionId: Long): Flow> - @Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") + @Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted fun listIds(subscriptionId: Long): List @Insert(onConflict = OnConflictStrategy.IGNORE) @@ -82,7 +132,7 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE id = :notificationId") fun get(notificationId: String): Notification? - @Query("DELETE FROM notification WHERE id = :notificationId") + @Query("UPDATE notification SET deleted = 1 WHERE id = :notificationId") fun remove(notificationId: String) @Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId") diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 7b5d74a..a083f58 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -4,7 +4,8 @@ import android.util.Log import androidx.annotation.WorkerThread import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData -import java.util.* +import androidx.lifecycle.map +import kotlinx.coroutines.flow.map class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) { init { @@ -12,17 +13,20 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif } fun getSubscriptionsLiveData(): LiveData> { - return subscriptionDao.listFlow().asLiveData() + return subscriptionDao + .listFlow() + .asLiveData() + .map { list -> toSubscriptionList(list) } } fun getSubscriptions(): List { - return subscriptionDao.list() + return toSubscriptionList(subscriptionDao.list()) } @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun getSubscription(baseUrl: String, topic: String): Subscription? { - return subscriptionDao.get(baseUrl, topic) + return toSubscription(subscriptionDao.get(baseUrl, topic)) } @Suppress("RedundantSuspendModifier") @@ -31,12 +35,6 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif subscriptionDao.add(subscription) } - @Suppress("RedundantSuspendModifier") - @WorkerThread - suspend fun updateSubscription(subscription: Subscription) { - subscriptionDao.update(subscription) - } - @Suppress("RedundantSuspendModifier") @WorkerThread suspend fun removeSubscription(subscriptionId: Long) { @@ -54,24 +52,16 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif @Suppress("RedundantSuspendModifier") @WorkerThread - suspend fun addNotification(subscriptionId: Long, notification: Notification) { + suspend fun addNotification(notification: Notification) { val maybeExistingNotification = notificationDao.get(notification.id) - if (maybeExistingNotification != null) { - return + if (maybeExistingNotification == null) { + notificationDao.add(notification) } - - val subscription = subscriptionDao.get(subscriptionId) ?: return - val newSubscription = subscription.copy(notifications = subscription.notifications + 1, lastActive = Date().time/1000) - subscriptionDao.update(newSubscription) - notificationDao.add(notification) } @Suppress("RedundantSuspendModifier") @WorkerThread - suspend fun removeNotification(subscriptionId: Long, notificationId: String) { - val subscription = subscriptionDao.get(subscriptionId) ?: return - val newSubscription = subscription.copy(notifications = subscription.notifications - 1, lastActive = Date().time/1000) - subscriptionDao.update(newSubscription) + suspend fun removeNotification(notificationId: String) { notificationDao.remove(notificationId) } @@ -81,6 +71,31 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif notificationDao.removeAll(subscriptionId) } + private fun toSubscriptionList(list: List): List { + return list.map { s -> + Subscription( + id = s.id, + baseUrl = s.baseUrl, + topic = s.topic, + lastActive = s.lastActive, + notifications = s.notifications + ) + } + } + + private fun toSubscription(s: SubscriptionWithMetadata?): Subscription? { + if (s == null) { + return null + } + return Subscription( + id = s.id, + baseUrl = s.baseUrl, + topic = s.topic, + lastActive = s.lastActive, + notifications = s.notifications + ) + } + companion object { private 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 54531c3..1a3b12e 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -53,7 +53,7 @@ class ApiService { private fun fromString(subscriptionId: Long, s: String): Notification { val n = gson.fromJson(s, NotificationData::class.java) // Indirection to prevent accidental field renames, etc. - return Notification(n.id, subscriptionId, n.time, n.message) + return Notification(n.id, subscriptionId, n.time, n.message, false) } private data class NotificationData( diff --git a/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt index 559955d..1403978 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/FirebaseService.kt @@ -39,8 +39,8 @@ class FirebaseService : FirebaseMessagingService() { // Add notification val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch - val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message) - repository.addNotification(subscription.id, notification) + val notification = Notification(id = id, subscriptionId = subscription.id, timestamp = timestamp, message = message, deleted = false) + repository.addNotification(notification) // Send notification Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") 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 f62f3a2..8161490 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -23,6 +23,7 @@ import io.heckel.ntfy.R import io.heckel.ntfy.app.Application import io.heckel.ntfy.data.Notification import io.heckel.ntfy.data.topicShortUrl +import io.heckel.ntfy.data.topicUrl import io.heckel.ntfy.msg.ApiService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -113,6 +114,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { onRefreshClick() true } + R.id.detail_menu_copy_url -> { + onCopyUrlClick() + true + } R.id.detail_menu_unsubscribe -> { onDeleteClick() true @@ -136,6 +141,18 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { } } + private fun onCopyUrlClick() { + val url = topicUrl(subscriptionBaseUrl, subscriptionTopic) + Log.d(TAG, "Copying topic URL $url to clipboard ") + + val clipboard: ClipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("topic address", url) + clipboard.setPrimaryClip(clip) + Toast + .makeText(this, getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG) + .show() + } + private fun onRefreshClick() { Log.d(TAG, "Fetching cached notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") @@ -144,15 +161,15 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic) val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications) val toastMessage = if (newNotifications.isEmpty()) { - getString(R.string.detail_refresh_message_no_results) + getString(R.string.refresh_message_no_results) } else { - getString(R.string.detail_refresh_message_result, newNotifications.size) + getString(R.string.refresh_message_result, newNotifications.size) } - newNotifications.forEach { notification -> repository.addNotification(subscriptionId, notification) } + newNotifications.forEach { notification -> repository.addNotification(notification) } runOnUiThread { Toast.makeText(this@DetailActivity, toastMessage, Toast.LENGTH_LONG).show() } } catch (e: Exception) { Toast - .makeText(this@DetailActivity, getString(R.string.detail_refresh_message_error, e.message), Toast.LENGTH_LONG) + .makeText(this@DetailActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG) .show() } } @@ -243,7 +260,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { builder .setMessage(R.string.detail_action_mode_delete_dialog_message) .setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ -> - adapter.selected.map { notificationId -> viewModel.remove(subscriptionId, notificationId) } + adapter.selected.map { notificationId -> viewModel.remove(notificationId) } finishActionMode() } .setNegativeButton(R.string.detail_action_mode_delete_dialog_cancel) { _, _ -> diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt index ac5e11c..643d900 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -14,8 +14,8 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { return repository.getNotificationsLiveData(subscriptionId) } - fun remove(subscriptionId: Long, notificationId: String) = viewModelScope.launch(Dispatchers.IO) { - repository.removeNotification(subscriptionId, notificationId) + fun remove(notificationId: String) = viewModelScope.launch(Dispatchers.IO) { + repository.removeNotification(notificationId) } } 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 79a73ec..986da94 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -76,8 +76,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { mainList.adapter = adapter viewModel.list().observe(this) { - it?.let { - adapter.submitList(it as MutableList) + it?.let { subscriptions -> + adapter.submitList(subscriptions as MutableList) if (it.isEmpty()) { mainList.visibility = View.GONE noEntries.visibility = View.VISIBLE @@ -168,7 +168,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { lifecycleScope.launch(Dispatchers.IO) { try { val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) - notifications.forEach { notification -> repository.addNotification(subscription.id, notification) } + notifications.forEach { notification -> repository.addNotification(notification) } } catch (e: Exception) { Log.e(TAG, "Unable to fetch notifications: ${e.stackTrace}") } @@ -196,19 +196,27 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback { lifecycleScope.launch(Dispatchers.IO) { try { Log.d(TAG, "Polling for new notifications") + var newNotificationsCount = 0 repository.getSubscriptions().forEach { subscription -> val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) newNotifications.forEach { notification -> - repository.addNotification(subscription.id, notification) + repository.addNotification(notification) notifier?.send(subscription, notification.message) + newNotificationsCount++ } } + val toastMessage = if (newNotificationsCount == 0) { + getString(R.string.refresh_message_no_results) + } else { + getString(R.string.refresh_message_result, newNotificationsCount) + } + runOnUiThread { Toast.makeText(this@MainActivity, toastMessage, Toast.LENGTH_LONG).show() } Log.d(TAG, "Finished polling for new notifications") } catch (e: Exception) { Log.e(TAG, "Polling failed: ${e.message}", e) runOnUiThread { - Toast.makeText(this@MainActivity, getString(R.string.poll_worker_exception, e.message), Toast.LENGTH_LONG).show() + Toast.makeText(this@MainActivity, getString(R.string.refresh_message_error, e.message), Toast.LENGTH_LONG).show() } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt index 7e040e1..863c3a6 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt @@ -56,10 +56,12 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon } else { context.getString(R.string.main_item_status_text_not_one, subscription.notifications) } - val dateText = if (System.currentTimeMillis()/1000 - subscription.lastActive < 24 * 60 * 60) { + val dateText = if (subscription.lastActive == 0L) { + "" + } else if (System.currentTimeMillis()/1000 - subscription.lastActive < 24 * 60 * 60) { SimpleDateFormat("HH:mm").format(Date(subscription.lastActive*1000)) } else { - SimpleDateFormat("MM/dd").format(Date(subscription.lastActive*1000)) + SimpleDateFormat("M/d/yy").format(Date(subscription.lastActive*1000)) } nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic) statusView.text = statusMessage diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt index 40d0505..62408be 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -31,7 +31,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) newNotifications.forEach { notification -> - repository.addNotification(subscription.id, notification) + repository.addNotification(notification) notifier.send(subscription, notification.message) } } diff --git a/app/src/main/res/menu/detail_action_bar_menu.xml b/app/src/main/res/menu/detail_action_bar_menu.xml index 852ef14..9b169ef 100644 --- a/app/src/main/res/menu/detail_action_bar_menu.xml +++ b/app/src/main/res/menu/detail_action_bar_menu.xml @@ -1,5 +1,6 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1b1eb94..19cdc90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,11 @@ Ntfy ntfy + + %1$d notification(s) received + Everything is up-to-date + Could not refresh topic: %1$s + Subscribed topics Force refresh @@ -46,13 +51,11 @@ Cancel This is a test notification from the Ntfy Android app. It was sent at %1$s. Could not send test message: %1$s - %1$d notification(s) added - No new notifications found - Could not refresh topic: %1$s Copied to clipboard Send test notification + Copy topic address Force refresh Unsubscribe