From c2396dd1a3dc8f29c70e7a930d6b784637454732 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 24 Jan 2026 16:19:08 -0500 Subject: [PATCH 1/7] WIP Search --- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 99 ++++++++++++++++++- .../java/io/heckel/ntfy/ui/DetailViewModel.kt | 25 +++++ .../res/drawable/ic_search_white_24dp.xml | 9 ++ .../main/res/menu/menu_detail_action_bar.xml | 14 +-- app/src/main/res/values/strings.xml | 3 + 5 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 app/src/main/res/drawable/ic_search_white_24dp.xml 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 b5c93cf9..0b55d3a9 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -9,12 +9,15 @@ import android.text.Html import android.view.Menu import android.view.MenuItem import android.view.View +import android.widget.EditText +import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -92,6 +95,10 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet private lateinit var messageBarPublishButton: FloatingActionButton private lateinit var messageBarExpandButton: ImageButton + // Search state + private var searchView: SearchView? = null + private var isSearchActive: Boolean = false + // Action mode stuff private var actionMode: ActionMode? = null private val actionModeCallback = object : ActionMode.Callback { @@ -307,20 +314,39 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet insets } - viewModel.list(subscriptionId).observe(this) { - it?.let { + // Observe filtered notifications (filtered by search query) + val noSearchResultsText: TextView = findViewById(R.id.detail_no_notifications_text) + val howToIntro: View = findViewById(R.id.detail_how_to_intro) + val howToExampleView: View = findViewById(R.id.detail_how_to_example) + val howToLinkView: View = findViewById(R.id.detail_how_to_link) + viewModel.listFiltered(subscriptionId).observe(this) { + it?.let { notifications -> // Show list view - adapter.submitList(it as MutableList) - if (it.isEmpty()) { + adapter.submitList(notifications.toMutableList()) + if (notifications.isEmpty()) { mainListContainer.visibility = View.GONE noEntriesText.visibility = View.VISIBLE + // Show different text based on whether we're searching or not + if (isSearchActive && viewModel.searchQuery.value?.isNotEmpty() == true) { + noSearchResultsText.text = getString(R.string.detail_no_search_results) + // Hide "how to" instructions when showing search results + howToIntro.visibility = View.GONE + howToExampleView.visibility = View.GONE + howToLinkView.visibility = View.GONE + } else { + noSearchResultsText.text = getString(R.string.detail_no_notifications_text) + // Show "how to" instructions for empty subscription + howToIntro.visibility = View.VISIBLE + howToExampleView.visibility = View.VISIBLE + howToLinkView.visibility = if (BuildConfig.PAYMENT_LINKS_AVAILABLE) View.VISIBLE else View.GONE + } } else { mainListContainer.visibility = View.VISIBLE noEntriesText.visibility = View.GONE } // Cancel notifications that still have popups - maybeCancelNotificationPopups(it) + maybeCancelNotificationPopups(notifications) } } @@ -553,6 +579,57 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet menu[i].icon?.setTint(toolbarTextColor) } + // Setup SearchView + val searchItem = menu.findItem(R.id.detail_menu_search) + searchView = searchItem?.actionView as? SearchView + searchView?.let { sv -> + sv.queryHint = getString(R.string.detail_search_hint) + + // Tint SearchView icons and text + val searchIcon = sv.findViewById(androidx.appcompat.R.id.search_button) + searchIcon?.setColorFilter(toolbarTextColor) + val closeIcon = sv.findViewById(androidx.appcompat.R.id.search_close_btn) + closeIcon?.setColorFilter(toolbarTextColor) + val searchEditText = sv.findViewById(androidx.appcompat.R.id.search_src_text) + searchEditText?.setTextColor(toolbarTextColor) + searchEditText?.setHintTextColor(toolbarTextColor.and(0x80FFFFFF.toInt())) // 50% alpha + + // Handle query text changes + sv.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.setSearchQuery(newText ?: "") + return true + } + }) + + // Handle expand/collapse + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + isSearchActive = true + // Hide other menu items when search is expanded + setMenuItemsVisibility(false) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + isSearchActive = false + // Clear search query when collapsed + viewModel.setSearchQuery("") + // Restore menu items visibility + setMenuItemsVisibility(true) + showHideInstantMenuItems(subscriptionInstant) + showHideMutedUntilMenuItems(subscriptionMutedUntil) + showHideCopyMenuItems(subscriptionBaseUrl) + showHideConnectionErrorMenuItem(repository.getConnectionDetails()) + return true + } + }) + } + // Show and hide buttons showHideInstantMenuItems(subscriptionInstant) showHideMutedUntilMenuItems(subscriptionMutedUntil) @@ -566,6 +643,18 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet return true } + private fun setMenuItemsVisibility(visible: Boolean) { + if (!this::menu.isInitialized) return + + // Hide/show all menu items except search + for (i in 0 until menu.size) { + val item = menu[i] + if (item.itemId != R.id.detail_menu_search) { + item.isVisible = visible + } + } + } + private fun startNotificationMutedChecker() { // FIXME This is awful and has to go. 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 8606a64a..d8aa793b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -1,19 +1,44 @@ package io.heckel.ntfy.ui import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.db.combineWith import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class DetailViewModel(private val repository: Repository) : ViewModel() { + private val _searchQuery = MutableLiveData("") + val searchQuery: LiveData = _searchQuery + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + fun list(subscriptionId: Long): LiveData> { return repository.getNotificationsLiveData(subscriptionId) } + fun listFiltered(subscriptionId: Long): LiveData> { + return repository.getNotificationsLiveData(subscriptionId) + .combineWith(_searchQuery) { notifications, query -> + if (query.isNullOrBlank()) { + notifications.orEmpty() + } else { + val q = query.lowercase() + notifications.orEmpty().filter { n -> + n.title.lowercase().contains(q) || + n.message.lowercase().contains(q) || + n.tags.lowercase().contains(q) + } + } + } + } + fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) { repository.markAsDeleted(notificationId) } diff --git a/app/src/main/res/drawable/ic_search_white_24dp.xml b/app/src/main/res/drawable/ic_search_white_24dp.xml new file mode 100644 index 00000000..29a15776 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/menu/menu_detail_action_bar.xml b/app/src/main/res/menu/menu_detail_action_bar.xml index ff99af16..7f18561e 100644 --- a/app/src/main/res/menu/menu_detail_action_bar.xml +++ b/app/src/main/res/menu/menu_detail_action_bar.xml @@ -1,6 +1,12 @@ + + android:title="@string/detail_menu_enable_instant" /> + android:title="@string/detail_menu_disable_instant" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0670b6f1..1ccf3b4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,6 +203,9 @@ Clear all notifications Subscription settings Unsubscribe + Search notifications + Search in notifications + No notifications match your search Delete From 7cfef864a2dea247ae772ca9931b4c3d9e949169 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 24 Jan 2026 20:27:43 -0500 Subject: [PATCH 2/7] Make better --- .../main/java/io/heckel/ntfy/db/Database.kt | 13 ++++ .../main/java/io/heckel/ntfy/db/Repository.kt | 4 ++ .../java/io/heckel/ntfy/ui/DetailActivity.kt | 65 ++++--------------- .../java/io/heckel/ntfy/ui/DetailViewModel.kt | 23 +++---- .../main/res/menu/menu_detail_action_bar.xml | 6 -- 5 files changed, 38 insertions(+), 73 deletions(-) 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 57fa984f..952b6c0e 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -574,6 +574,19 @@ interface NotificationDao { @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC") fun listFlow(subscriptionId: Long): Flow> + @Query(""" + SELECT * FROM notification + WHERE subscriptionId = :subscriptionId + AND deleted != 1 + AND ( + title LIKE '%' || :query || '%' COLLATE NOCASE + OR message LIKE '%' || :query || '%' COLLATE NOCASE + OR tags LIKE '%' || :query || '%' COLLATE NOCASE + ) + ORDER BY timestamp DESC + """) + fun listFlowFiltered(subscriptionId: Long, query: String): Flow> + @Query("SELECT * FROM notification WHERE deleted = 1 AND attachment_contentUri <> ''") fun listDeletedWithAttachments(): List 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 1780944d..51504bca 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -120,6 +120,10 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) return notificationDao.listFlow(subscriptionId).asLiveData() } + fun getNotificationsFilteredLiveData(subscriptionId: Long, query: String): LiveData> { + return notificationDao.listFlowFiltered(subscriptionId, query).asLiveData() + } + fun getNotification(notificationId: String): Notification? { return notificationDao.get(notificationId) } 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 0b55d3a9..2947c5de 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -98,6 +98,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet // Search state private var searchView: SearchView? = null private var isSearchActive: Boolean = false + private lateinit var toolbar: com.google.android.material.appbar.MaterialToolbar + private var toolbarTextColor: Int = 0 // Action mode stuff private var actionMode: ActionMode? = null @@ -147,13 +149,17 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet val dynamicColors = repository.getDynamicColorsEnabled() val darkMode = isDarkThemeOn(this) val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode) - val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode) + toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode) toolbarLayout.setBackgroundColor(statusBarColor) - val toolbar = toolbarLayout.findViewById(R.id.toolbar) + toolbar = toolbarLayout.findViewById(R.id.toolbar) toolbar.setTitleTextColor(toolbarTextColor) toolbar.setNavigationIconTint(toolbarTextColor) toolbar.overflowIcon?.setTint(toolbarTextColor) + // Set collapse icon (back arrow when search is expanded) with proper tint + val collapseIcon = ContextCompat.getDrawable(this, R.drawable.ic_arrow_back_white_24dp)?.mutate() + collapseIcon?.setTint(toolbarTextColor) + toolbar.collapseIcon = collapseIcon setSupportActionBar(toolbar) // Set system status bar appearance @@ -537,7 +543,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet subscriptionMutedUntil = subscription.mutedUntil subscriptionDisplayName = displayName(appBaseUrl, subscription) - showHideInstantMenuItems(subscriptionInstant) showHideMutedUntilMenuItems(subscriptionMutedUntil) showHideCopyMenuItems(subscription.baseUrl) showHideConnectionErrorMenuItem(repository.getConnectionDetails()) @@ -574,7 +579,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet this.menu = menu // Tint menu icons based on theme - val toolbarTextColor = Colors.toolbarTextColor(this, repository.getDynamicColorsEnabled(), isDarkThemeOn(this)) for (i in 0 until menu.size) { menu[i].icon?.setTint(toolbarTextColor) } @@ -584,6 +588,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet searchView = searchItem?.actionView as? SearchView searchView?.let { sv -> sv.queryHint = getString(R.string.detail_search_hint) + sv.maxWidth = Integer.MAX_VALUE // Make SearchView expand fully // Tint SearchView icons and text val searchIcon = sv.findViewById(androidx.appcompat.R.id.search_button) @@ -612,6 +617,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet isSearchActive = true // Hide other menu items when search is expanded setMenuItemsVisibility(false) + // Ensure collapse icon is tinted (back arrow when search is expanded) + toolbar.collapseIcon?.setTint(toolbarTextColor) return true } @@ -621,7 +628,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet viewModel.setSearchQuery("") // Restore menu items visibility setMenuItemsVisibility(true) - showHideInstantMenuItems(subscriptionInstant) showHideMutedUntilMenuItems(subscriptionMutedUntil) showHideCopyMenuItems(subscriptionBaseUrl) showHideConnectionErrorMenuItem(repository.getConnectionDetails()) @@ -631,7 +637,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet } // Show and hide buttons - showHideInstantMenuItems(subscriptionInstant) showHideMutedUntilMenuItems(subscriptionMutedUntil) showHideCopyMenuItems(subscriptionBaseUrl) showHideConnectionErrorMenuItem(repository.getConnectionDetails()) @@ -692,14 +697,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet onMutedUntilClick(enable = true) true } - R.id.detail_menu_enable_instant -> { - onInstantEnableClick(enable = true) - true - } - R.id.detail_menu_disable_instant -> { - onInstantEnableClick(enable = false) - true - } R.id.detail_menu_connection_error -> { onConnectionErrorClick() true @@ -833,46 +830,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet } } - private fun onInstantEnableClick(enable: Boolean) { - Log.d(TAG, "Toggling instant delivery setting for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") - - lifecycleScope.launch(Dispatchers.IO) { - val subscription = repository.getSubscription(subscriptionId) - val newSubscription = subscription?.copy(instant = enable) - newSubscription?.let { repository.updateSubscription(newSubscription) } - showHideInstantMenuItems(enable) - runOnUiThread { - if (enable) { - Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_enabled), Toast.LENGTH_SHORT) - .show() - } else { - Toast.makeText(this@DetailActivity, getString(R.string.detail_instant_delivery_disabled), Toast.LENGTH_SHORT) - .show() - } - } - } - } - - private fun showHideInstantMenuItems(enable: Boolean) { - if (!this::menu.isInitialized) { - return - } - subscriptionInstant = enable - runOnUiThread { - val appBaseUrl = getString(R.string.app_base_url) - val enableInstantItem = menu.findItem(R.id.detail_menu_enable_instant) - val disableInstantItem = menu.findItem(R.id.detail_menu_disable_instant) - val allowToggleInstant = BuildConfig.FIREBASE_AVAILABLE && subscriptionBaseUrl == appBaseUrl - if (allowToggleInstant) { - enableInstantItem?.isVisible = !subscriptionInstant - disableInstantItem?.isVisible = subscriptionInstant - } else { - enableInstantItem?.isVisible = false - disableInstantItem?.isVisible = false - } - } - } - private fun showHideMutedUntilMenuItems(mutedUntilTimestamp: Long) { if (!this::menu.isInitialized) { return 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 d8aa793b..f9293841 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -4,10 +4,10 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Repository -import io.heckel.ntfy.db.combineWith import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -15,6 +15,8 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { private val _searchQuery = MutableLiveData("") val searchQuery: LiveData = _searchQuery + private var currentSubscriptionId: Long = 0 + fun setSearchQuery(query: String) { _searchQuery.value = query } @@ -24,19 +26,14 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { } fun listFiltered(subscriptionId: Long): LiveData> { - return repository.getNotificationsLiveData(subscriptionId) - .combineWith(_searchQuery) { notifications, query -> - if (query.isNullOrBlank()) { - notifications.orEmpty() - } else { - val q = query.lowercase() - notifications.orEmpty().filter { n -> - n.title.lowercase().contains(q) || - n.message.lowercase().contains(q) || - n.tags.lowercase().contains(q) - } - } + currentSubscriptionId = subscriptionId + return _searchQuery.switchMap { query -> + if (query.isNullOrBlank()) { + repository.getNotificationsLiveData(subscriptionId) + } else { + repository.getNotificationsFilteredLiveData(subscriptionId, query) } + } } fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/res/menu/menu_detail_action_bar.xml b/app/src/main/res/menu/menu_detail_action_bar.xml index 7f18561e..746c20fa 100644 --- a/app/src/main/res/menu/menu_detail_action_bar.xml +++ b/app/src/main/res/menu/menu_detail_action_bar.xml @@ -28,12 +28,6 @@ android:icon="@drawable/ic_notifications_off_white_outline_24dp" android:title="@string/detail_menu_notifications_disabled_forever" app:showAsAction="ifRoom" /> - - From dad1cbb1fe6ca9998c7a23d8d8c76d6a40e98725 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 26 Jan 2026 21:14:29 -0500 Subject: [PATCH 3/7] Hide search icon --- app/src/main/res/values/themes.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 644daf63..4ff45f5f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -49,6 +49,7 @@ @style/MaterialSwitch true + @style/AppSearchViewStyle @style/ActionMode @style/ActionModeCloseButtonStyle @@ -57,6 +58,11 @@ + From 20d7e8ca9299aaa4ef32cc46ba77dede36d407ee Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 26 Jan 2026 21:25:51 -0500 Subject: [PATCH 4/7] Remove unused code --- app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt | 3 --- app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt | 3 --- 2 files changed, 6 deletions(-) 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 2947c5de..ea539100 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -81,7 +81,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet private var subscriptionBaseUrl: String = "" // Set in onCreate() private var subscriptionTopic: String = "" // Set in onCreate() private var subscriptionDisplayName: String = "" // Set in onCreate() & updated by options menu! - private var subscriptionInstant: Boolean = false // Set in onCreate() & updated by options menu! private var subscriptionMutedUntil: Long = 0L // Set in onCreate() & updated by options menu! // UI elements @@ -283,7 +282,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return subscriptionDisplayName = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME) ?: return - subscriptionInstant = intent.getBooleanExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, false) subscriptionMutedUntil = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, 0L) // Set title @@ -539,7 +537,6 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet // Update buttons (this is for when we return from the preferences screen) lifecycleScope.launch(Dispatchers.IO) { val subscription = repository.getSubscription(subscriptionId) ?: return@launch - subscriptionInstant = subscription.instant subscriptionMutedUntil = subscription.mutedUntil subscriptionDisplayName = displayName(appBaseUrl, subscription) 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 f9293841..8c9e1d1e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -15,8 +15,6 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { private val _searchQuery = MutableLiveData("") val searchQuery: LiveData = _searchQuery - private var currentSubscriptionId: Long = 0 - fun setSearchQuery(query: String) { _searchQuery.value = query } @@ -26,7 +24,6 @@ class DetailViewModel(private val repository: Repository) : ViewModel() { } fun listFiltered(subscriptionId: Long): LiveData> { - currentSubscriptionId = subscriptionId return _searchQuery.switchMap { query -> if (query.isNullOrBlank()) { repository.getNotificationsLiveData(subscriptionId) From 0599fefb4ba34cb96991665187ab14a5bbd3a793 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 26 Jan 2026 22:07:55 -0500 Subject: [PATCH 5/7] Refine --- .../main/java/io/heckel/ntfy/db/Database.kt | 6 +- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 167 +++++++++--------- .../java/io/heckel/ntfy/ui/DetailViewModel.kt | 9 +- app/src/main/res/values/strings.xml | 4 +- 4 files changed, 93 insertions(+), 93 deletions(-) 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 952b6c0e..30b65885 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -575,9 +575,9 @@ interface NotificationDao { fun listFlow(subscriptionId: Long): Flow> @Query(""" - SELECT * FROM notification - WHERE subscriptionId = :subscriptionId - AND deleted != 1 + SELECT * FROM notification + WHERE subscriptionId = :subscriptionId + AND deleted != 1 AND ( title LIKE '%' || :query || '%' COLLATE NOCASE OR message LIKE '%' || :query || '%' COLLATE NOCASE 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 ea539100..16be93ea 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -64,6 +64,7 @@ import androidx.core.net.toUri import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.textfield.TextInputEditText import android.widget.ImageButton +import com.google.android.material.color.MaterialColors class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSettingsListener, PublishFragment.PublishListener { private val viewModel by viewModels { @@ -148,16 +149,17 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet val dynamicColors = repository.getDynamicColorsEnabled() val darkMode = isDarkThemeOn(this) val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode) - toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode) toolbarLayout.setBackgroundColor(statusBarColor) - + + // Set collapse icon (back arrow when search is expanded) with proper tint + toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode) + val collapseIcon = ContextCompat.getDrawable(this, R.drawable.ic_arrow_back_white_24dp)?.mutate() + collapseIcon?.setTint(toolbarTextColor) + toolbar = toolbarLayout.findViewById(R.id.toolbar) toolbar.setTitleTextColor(toolbarTextColor) toolbar.setNavigationIconTint(toolbarTextColor) toolbar.overflowIcon?.setTint(toolbarTextColor) - // Set collapse icon (back arrow when search is expanded) with proper tint - val collapseIcon = ContextCompat.getDrawable(this, R.drawable.ic_arrow_back_white_24dp)?.mutate() - collapseIcon?.setTint(toolbarTextColor) toolbar.collapseIcon = collapseIcon setSupportActionBar(toolbar) @@ -169,7 +171,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet val detailContentLayout = findViewById(R.id.detail_content_layout) if (repository.getDynamicColorsEnabled()) { detailContentLayout.setBackgroundColor( - com.google.android.material.color.MaterialColors.getColor( + MaterialColors.getColor( this, android.R.attr.colorBackground, ContextCompat.getColor(this, R.color.detail_activity_background) @@ -291,9 +293,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet // Set "how to instructions" val howToExample: TextView = findViewById(R.id.detail_how_to_example) - howToExample.linksClickable = true - val howToText = getString(R.string.detail_how_to_example, topicUrl) + howToExample.linksClickable = true howToExample.text = Html.fromHtml(howToText, Html.FROM_HTML_MODE_LEGACY) // Swipe to refresh @@ -321,8 +322,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet // Observe filtered notifications (filtered by search query) val noSearchResultsText: TextView = findViewById(R.id.detail_no_notifications_text) val howToIntro: View = findViewById(R.id.detail_how_to_intro) - val howToExampleView: View = findViewById(R.id.detail_how_to_example) - val howToLinkView: View = findViewById(R.id.detail_how_to_link) + val howToLink: View = findViewById(R.id.detail_how_to_link) viewModel.listFiltered(subscriptionId).observe(this) { it?.let { notifications -> // Show list view @@ -331,18 +331,16 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet mainListContainer.visibility = View.GONE noEntriesText.visibility = View.VISIBLE // Show different text based on whether we're searching or not - if (isSearchActive && viewModel.searchQuery.value?.isNotEmpty() == true) { + if (isSearchActive && viewModel.hasSearchQuery()) { noSearchResultsText.text = getString(R.string.detail_no_search_results) - // Hide "how to" instructions when showing search results howToIntro.visibility = View.GONE - howToExampleView.visibility = View.GONE - howToLinkView.visibility = View.GONE + howToExample.visibility = View.GONE + howToLink.visibility = View.GONE } else { noSearchResultsText.text = getString(R.string.detail_no_notifications_text) - // Show "how to" instructions for empty subscription howToIntro.visibility = View.VISIBLE - howToExampleView.visibility = View.VISIBLE - howToLinkView.visibility = if (BuildConfig.PAYMENT_LINKS_AVAILABLE) View.VISIBLE else View.GONE + howToExample.visibility = View.VISIBLE + howToLink.visibility = if (BuildConfig.PAYMENT_LINKS_AVAILABLE) View.VISIBLE else View.GONE } } else { mainListContainer.visibility = View.VISIBLE @@ -393,8 +391,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet } // Observe connection details and update menu item visibility - repository.getConnectionDetailsLiveData().observe(this) { details -> - showHideConnectionErrorMenuItem(details) + repository.getConnectionDetailsLiveData().observe(this) { + showHideConnectionErrorMenuItem() } // Mark this subscription as "open" so we don't receive notifications for it @@ -540,9 +538,9 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet subscriptionMutedUntil = subscription.mutedUntil subscriptionDisplayName = displayName(appBaseUrl, subscription) - showHideMutedUntilMenuItems(subscriptionMutedUntil) - showHideCopyMenuItems(subscription.baseUrl) - showHideConnectionErrorMenuItem(repository.getConnectionDetails()) + showHideMutedUntilMenuItems() + showHideCopyMenuItems() + showHideConnectionErrorMenuItem() updateTitle(subscriptionDisplayName) } } @@ -575,24 +573,29 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet menuInflater.inflate(R.menu.menu_detail_action_bar, menu) this.menu = menu - // Tint menu icons based on theme - for (i in 0 until menu.size) { - menu[i].icon?.setTint(toolbarTextColor) - } + setupSearchView() + showHideMenuItems() - // Setup SearchView + // Regularly check if "notification muted" time has passed + // NOTE: This is done here, because then we know that we've initialized the menu items. + startNotificationMutedChecker() + + return true + } + + private fun setupSearchView() { val searchItem = menu.findItem(R.id.detail_menu_search) searchView = searchItem?.actionView as? SearchView searchView?.let { sv -> - sv.queryHint = getString(R.string.detail_search_hint) + sv.queryHint = getString(R.string.detail_menu_search_hint) sv.maxWidth = Integer.MAX_VALUE // Make SearchView expand fully // Tint SearchView icons and text val searchIcon = sv.findViewById(androidx.appcompat.R.id.search_button) - searchIcon?.setColorFilter(toolbarTextColor) val closeIcon = sv.findViewById(androidx.appcompat.R.id.search_close_btn) - closeIcon?.setColorFilter(toolbarTextColor) val searchEditText = sv.findViewById(androidx.appcompat.R.id.search_src_text) + searchIcon?.setColorFilter(toolbarTextColor) + closeIcon?.setColorFilter(toolbarTextColor) searchEditText?.setTextColor(toolbarTextColor) searchEditText?.setHintTextColor(toolbarTextColor.and(0x80FFFFFF.toInt())) // 50% alpha @@ -612,48 +615,45 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem): Boolean { isSearchActive = true - // Hide other menu items when search is expanded - setMenuItemsVisibility(false) - // Ensure collapse icon is tinted (back arrow when search is expanded) - toolbar.collapseIcon?.setTint(toolbarTextColor) + showHideMenuItems() return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - isSearchActive = false - // Clear search query when collapsed viewModel.setSearchQuery("") - // Restore menu items visibility - setMenuItemsVisibility(true) - showHideMutedUntilMenuItems(subscriptionMutedUntil) - showHideCopyMenuItems(subscriptionBaseUrl) - showHideConnectionErrorMenuItem(repository.getConnectionDetails()) + isSearchActive = false + showHideMenuItems() return true } }) } - - // Show and hide buttons - showHideMutedUntilMenuItems(subscriptionMutedUntil) - showHideCopyMenuItems(subscriptionBaseUrl) - showHideConnectionErrorMenuItem(repository.getConnectionDetails()) - - // Regularly check if "notification muted" time has passed - // NOTE: This is done here, because then we know that we've initialized the menu items. - startNotificationMutedChecker() - - return true } - private fun setMenuItemsVisibility(visible: Boolean) { + private fun showHideMenuItems() { if (!this::menu.isInitialized) return - // Hide/show all menu items except search + // Tint menu icons based on theme for (i in 0 until menu.size) { - val item = menu[i] - if (item.itemId != R.id.detail_menu_search) { - item.isVisible = visible - } + menu[i].icon?.setTint(toolbarTextColor) + } + + // Ensure collapse icon is tinted (back arrow when search is expanded) + toolbar.collapseIcon?.setTint(toolbarTextColor) + + // Show/hide menu items based on state + showHideMutedUntilMenuItems() + showHideCopyMenuItems() + showHideConnectionErrorMenuItem() + showHideOtherMenuItems() + } + + private fun showHideOtherMenuItems() { + if (!this::menu.isInitialized) return + runOnUiThread { + menu.findItem(R.id.detail_menu_settings)?.isVisible = !isSearchActive + menu.findItem(R.id.detail_menu_clear)?.isVisible = !isSearchActive + menu.findItem(R.id.detail_menu_test)?.isVisible = !isSearchActive + menu.findItem(R.id.detail_menu_unsubscribe)?.isVisible = !isSearchActive } } @@ -669,7 +669,8 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet if (mutedUntilExpired) { val newSubscription = subscription.copy(mutedUntil = 0L) repository.updateSubscription(newSubscription) - showHideMutedUntilMenuItems(0L) + subscriptionMutedUntil = 0L + showHideMutedUntilMenuItems() } delay(60_000) } @@ -776,7 +777,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp) newSubscription?.let { repository.updateSubscription(newSubscription) } subscriptionMutedUntil = mutedUntilTimestamp - showHideMutedUntilMenuItems(mutedUntilTimestamp) + showHideMutedUntilMenuItems() runOnUiThread { when (mutedUntilTimestamp) { 0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show() @@ -827,46 +828,44 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet } } - private fun showHideMutedUntilMenuItems(mutedUntilTimestamp: Long) { - if (!this::menu.isInitialized) { - return - } - subscriptionMutedUntil = mutedUntilTimestamp + private fun showHideMutedUntilMenuItems() { + if (!this::menu.isInitialized) return runOnUiThread { val notificationsEnabledItem = menu.findItem(R.id.detail_menu_notifications_enabled) val notificationsDisabledUntilItem = menu.findItem(R.id.detail_menu_notifications_disabled_until) val notificationsDisabledForeverItem = menu.findItem(R.id.detail_menu_notifications_disabled_forever) - notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L - notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L - notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L - if (subscriptionMutedUntil > 1L) { - val formattedDate = formatDateShort(subscriptionMutedUntil) - notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate) + if (isSearchActive) { + notificationsEnabledItem?.isVisible = false + notificationsDisabledUntilItem?.isVisible = false + notificationsDisabledForeverItem?.isVisible = false + } else { + notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L + notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L + notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L + if (subscriptionMutedUntil > 1L) { + val formattedDate = formatDateShort(subscriptionMutedUntil) + notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate) + } } } } - - private fun showHideCopyMenuItems(subscriptionBaseUrl: String) { - if (!this::menu.isInitialized) { - return - } + private fun showHideCopyMenuItems() { + if (!this::menu.isInitialized) return runOnUiThread { // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463 val copyUrlItem = menu.findItem(R.id.detail_menu_copy_url) - copyUrlItem?.isVisible = appBaseUrl != subscriptionBaseUrl || BuildConfig.PAYMENT_LINKS_AVAILABLE + copyUrlItem?.isVisible = !isSearchActive && (appBaseUrl != subscriptionBaseUrl || BuildConfig.PAYMENT_LINKS_AVAILABLE) } } - private fun showHideConnectionErrorMenuItem(details: Map) { - if (!this::menu.isInitialized) { - return - } + private fun showHideConnectionErrorMenuItem() { + if (!this::menu.isInitialized) return runOnUiThread { - val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error) // Only show if there's an error for this subscription's base URL - val hasError = details[subscriptionBaseUrl]?.hasError() == true - connectionErrorItem?.isVisible = hasError + val connectionErrorItem = menu.findItem(R.id.detail_menu_connection_error) + val hasError = repository.getConnectionDetails()[subscriptionBaseUrl]?.hasError() == true + connectionErrorItem?.isVisible = !isSearchActive && hasError } } 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 8c9e1d1e..4960f578 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -12,19 +12,20 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class DetailViewModel(private val repository: Repository) : ViewModel() { - private val _searchQuery = MutableLiveData("") - val searchQuery: LiveData = _searchQuery + private val searchQuery = MutableLiveData("") fun setSearchQuery(query: String) { - _searchQuery.value = query + searchQuery.value = query } + fun hasSearchQuery(): Boolean = !searchQuery.value.isNullOrBlank() + fun list(subscriptionId: Long): LiveData> { return repository.getNotificationsLiveData(subscriptionId) } fun listFiltered(subscriptionId: Long): LiveData> { - return _searchQuery.switchMap { query -> + return searchQuery.switchMap { query -> if (query.isNullOrBlank()) { repository.getNotificationsLiveData(subscriptionId) } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ccf3b4d..1ad4267a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -141,6 +141,7 @@ $ curl -d \"Hi\" %1$s ]]> Detailed instructions available on ntfy.sh, and in the docs. + Your search returned no results Delete all of the notifications in this topic? Delete permanently @@ -204,8 +205,7 @@ Subscription settings Unsubscribe Search notifications - Search in notifications - No notifications match your search + Search in notifications Delete From 19e9f05b61b3bcd97208f3754e747794a5726474 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 27 Jan 2026 12:00:28 -0500 Subject: [PATCH 6/7] Move stuff to UI thread --- .../java/io/heckel/ntfy/ui/DetailActivity.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 16be93ea..b2554a87 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -538,9 +538,7 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet subscriptionMutedUntil = subscription.mutedUntil subscriptionDisplayName = displayName(appBaseUrl, subscription) - showHideMutedUntilMenuItems() - showHideCopyMenuItems() - showHideConnectionErrorMenuItem() + showHideMenuItems() updateTitle(subscriptionDisplayName) } } @@ -631,15 +629,17 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet private fun showHideMenuItems() { if (!this::menu.isInitialized) return - - // Tint menu icons based on theme - for (i in 0 until menu.size) { - menu[i].icon?.setTint(toolbarTextColor) + + runOnUiThread { + // Tint menu icons based on theme + for (i in 0 until menu.size) { + menu[i].icon?.setTint(toolbarTextColor) + } + + // Ensure collapse icon is tinted (back arrow when search is expanded) + toolbar.collapseIcon?.setTint(toolbarTextColor) } - // Ensure collapse icon is tinted (back arrow when search is expanded) - toolbar.collapseIcon?.setTint(toolbarTextColor) - // Show/hide menu items based on state showHideMutedUntilMenuItems() showHideCopyMenuItems() From ea9519c843a27151d6507dc924c9710056c8d979 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 27 Jan 2026 12:06:11 -0500 Subject: [PATCH 7/7] Release notes --- fastlane/metadata/android/en-US/changelog/NEXT.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelog/NEXT.txt diff --git a/fastlane/metadata/android/en-US/changelog/NEXT.txt b/fastlane/metadata/android/en-US/changelog/NEXT.txt new file mode 100644 index 00000000..d61d474d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelog/NEXT.txt @@ -0,0 +1,2 @@ +Features: +* Search within a topic (#141, ntfy-android#153, thanks to @Copephobia and @StoyanYonkov for reporting, and to @Fearup for sponsoring)