Skip to content

Commit bf70faa

Browse files
authored
feat: search persists on first back press (#146)
* feat: search persists on first back press * test: fix roboeletric * test: almost fixed * test: reduce chance of flakiness with notification clearing and state clearing * test: back press helper * fix: bug bot Back press callback stays disabled after fallthrough The OnBackPressedCallback sets isEnabled = false in the else branch to allow fallthrough, but never re-enables itself. On Android 10+, pressing back on the root activity moves the task to background rather than finishing it. If the user then returns to the app and opens search, the callback remains disabled and the search-persist-on-back-press feature won't work until the activity is recreated.
1 parent 5a5f15d commit bf70faa

4 files changed

Lines changed: 297 additions & 15 deletions

File tree

android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/UITestFixture.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.github.quarck.calnotify.testutils
22

33
import android.Manifest
4+
import android.app.NotificationManager
45
import android.content.Context
56
import android.content.Intent
67
import android.content.pm.PackageManager
@@ -27,6 +28,7 @@ import com.github.quarck.calnotify.ui.SettingsActivityX
2728
import com.github.quarck.calnotify.ui.SnoozeAllActivity
2829
import com.github.quarck.calnotify.ui.ViewEventActivityNoRecents
2930
import com.github.quarck.calnotify.utils.globalAsyncTaskCallback
31+
import androidx.test.espresso.Espresso.pressBackUnconditionally
3032
import io.mockk.every
3133
import io.mockk.just
3234
import io.mockk.Runs
@@ -53,6 +55,9 @@ class UITestFixture {
5355
// Track if dialogs have been pre-suppressed (no need to dismiss them)
5456
private var dialogsSuppressed = false
5557

58+
// Track navigation depth for automatic cleanup
59+
private var navigationDepth = 0
60+
5661
/**
5762
* Sets up the fixture. Call in @Before.
5863
*
@@ -81,6 +86,7 @@ class UITestFixture {
8186
// Reset dialog flag so each test can handle dialogs if they appear
8287
startupDialogsDismissed = false
8388
dialogsSuppressed = false
89+
navigationDepth = 0
8490

8591
clearAllEvents()
8692

@@ -323,6 +329,7 @@ class UITestFixture {
323329

324330
calendarReloadPrevented = false
325331
dialogsSuppressed = false
332+
navigationDepth = 0
326333

327334
// Reset battery optimization dialog setting
328335
try {
@@ -478,6 +485,48 @@ class UITestFixture {
478485
DevLog.info(LOG_TAG, "Cleared all events")
479486
}
480487

488+
/**
489+
* Cancels all notifications posted by this app.
490+
* This prevents notifications from interfering with UI tests.
491+
*/
492+
fun cancelAllNotifications() {
493+
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
494+
notificationManager.cancelAll()
495+
DevLog.info(LOG_TAG, "Cancelled all notifications")
496+
}
497+
498+
/**
499+
* Tracks navigation actions (e.g., opening search, expanding a view).
500+
* Call this when entering UI states that require back presses to exit.
501+
* Use [clearNavigationStack] at test end to automatically clean up.
502+
*
503+
* @param count Number of navigation levels entered (e.g., 2 for search with keyboard)
504+
*/
505+
fun pushNavigation(count: Int = 1) {
506+
navigationDepth += count
507+
DevLog.info(LOG_TAG, "Navigation depth: $navigationDepth (+$count)")
508+
}
509+
510+
/**
511+
* Decrements navigation depth (e.g., after a manual back press in the test).
512+
*/
513+
fun popNavigation(count: Int = 1) {
514+
navigationDepth = maxOf(0, navigationDepth - count)
515+
DevLog.info(LOG_TAG, "Navigation depth: $navigationDepth (-$count)")
516+
}
517+
518+
/**
519+
* Presses back for each tracked navigation action.
520+
* Call at the end of a test to restore initial state.
521+
*/
522+
fun clearNavigationStack() {
523+
DevLog.info(LOG_TAG, "Clearing navigation stack (depth=$navigationDepth)")
524+
repeat(navigationDepth) {
525+
pressBackUnconditionally()
526+
}
527+
navigationDepth = 0
528+
}
529+
481530
/**
482531
* Gets all active events from storage.
483532
*/

android/app/src/androidTest/java/com/github/quarck/calnotify/ui/MainActivityTest.kt

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package com.github.quarck.calnotify.ui
22

33
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
4+
import androidx.test.espresso.Espresso.pressBack
45
import androidx.test.espresso.matcher.ViewMatchers.withId
56
import androidx.test.espresso.matcher.ViewMatchers.withText
67
import androidx.test.ext.junit.runners.AndroidJUnit4
78
import androidx.test.platform.app.InstrumentationRegistry
89
import com.atiurin.ultron.extensions.click
10+
import com.atiurin.ultron.extensions.doesNotExist
911
import com.atiurin.ultron.extensions.isClickable
1012
import com.atiurin.ultron.extensions.isDisplayed
1113
import com.atiurin.ultron.extensions.isNotDisplayed
14+
import com.atiurin.ultron.extensions.replaceText
1215
import com.github.quarck.calnotify.R
1316
import com.github.quarck.calnotify.app.ApplicationController
1417
import com.github.quarck.calnotify.dismissedeventsstorage.EventDismissType
@@ -291,5 +294,102 @@ class MainActivityTest : BaseUltronTest() {
291294
scenario.close()
292295
}
293296

297+
// === Search Back Button Tests ===
298+
299+
@Test
300+
fun search_filters_events() {
301+
fixture.cancelAllNotifications()
302+
fixture.createEvent(title = "Alpha Meeting")
303+
fixture.createEvent(title = "Beta Meeting")
304+
305+
val scenario = fixture.launchMainActivity()
306+
307+
// Both events visible
308+
withText("Alpha Meeting").isDisplayed()
309+
withText("Beta Meeting").isDisplayed()
310+
311+
// Open search (keyboard + search view = 2 navigation levels)
312+
withId(R.id.action_search).click()
313+
fixture.pushNavigation(2)
314+
withId(androidx.appcompat.R.id.search_src_text).isDisplayed()
315+
withId(androidx.appcompat.R.id.search_src_text).replaceText("Alpha")
316+
317+
// Only Alpha should be visible (Beta is filtered out completely)
318+
withText("Alpha Meeting").isDisplayed()
319+
withText("Beta Meeting").doesNotExist()
320+
321+
fixture.clearNavigationStack()
322+
scenario.close()
323+
}
324+
325+
@Test
326+
fun first_back_press_hides_keyboard_keeps_filter() {
327+
fixture.cancelAllNotifications()
328+
fixture.createEvent(title = "Alpha Meeting")
329+
fixture.createEvent(title = "Beta Meeting")
330+
331+
val scenario = fixture.launchMainActivity()
332+
333+
withText("Alpha Meeting").isDisplayed()
334+
335+
// Open search (keyboard + search view = 2 navigation levels)
336+
withId(R.id.action_search).click()
337+
fixture.pushNavigation(2)
338+
withId(androidx.appcompat.R.id.search_src_text).isDisplayed()
339+
withId(androidx.appcompat.R.id.search_src_text).replaceText("Alpha")
340+
341+
// Only Alpha should be visible (Beta is filtered out completely)
342+
withText("Alpha Meeting").isDisplayed()
343+
withText("Beta Meeting").doesNotExist()
344+
345+
// Press back - should keep filter active (hides keyboard)
346+
pressBack()
347+
fixture.popNavigation()
348+
349+
// Filter should still be active - only Alpha visible
350+
withText("Alpha Meeting").isDisplayed()
351+
withText("Beta Meeting").doesNotExist()
352+
353+
fixture.clearNavigationStack()
354+
scenario.close()
355+
}
356+
357+
@Test
358+
fun second_back_press_clears_filter() {
359+
fixture.cancelAllNotifications()
360+
fixture.createEvent(title = "Alpha Meeting")
361+
fixture.createEvent(title = "Beta Meeting")
362+
363+
val scenario = fixture.launchMainActivity()
364+
365+
withText("Alpha Meeting").isDisplayed()
366+
367+
// Open search (keyboard + search view = 2 navigation levels)
368+
withId(R.id.action_search).click()
369+
fixture.pushNavigation(2)
370+
withId(androidx.appcompat.R.id.search_src_text).isDisplayed()
371+
withId(androidx.appcompat.R.id.search_src_text).replaceText("Alpha")
372+
373+
// Only Alpha visible (Beta is filtered out completely)
374+
withText("Alpha Meeting").isDisplayed()
375+
withText("Beta Meeting").doesNotExist()
376+
377+
// First back - hides keyboard, keeps filter
378+
pressBack()
379+
fixture.popNavigation()
380+
withText("Beta Meeting").doesNotExist()
381+
382+
// Second back - clears filter
383+
pressBack()
384+
fixture.popNavigation()
385+
386+
// Both events should be visible again
387+
withText("Alpha Meeting").isDisplayed()
388+
withText("Beta Meeting").isDisplayed()
389+
390+
// Navigation stack already cleared by the test itself
391+
scenario.close()
392+
}
393+
294394
// Inherits setConfig() from BaseUltronTest
295395
}

android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivity.kt

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import android.view.View
4444
import android.widget.ArrayAdapter
4545
import android.widget.RelativeLayout
4646
import android.widget.TextView
47+
import androidx.activity.OnBackPressedCallback
4748
import androidx.appcompat.widget.SearchView
4849
import com.github.quarck.calnotify.*
4950
import com.github.quarck.calnotify.app.ApplicationController
@@ -116,6 +117,10 @@ class MainActivity : AppCompatActivity(), EventListCallback {
116117

117118
val clock: CNPlusClockInterface = CNPlusSystemClock()
118119

120+
// Visible for testing
121+
internal var searchView: SearchView? = null
122+
private var searchMenuItem: MenuItem? = null
123+
119124
override fun onCreate(savedInstanceState: Bundle?) {
120125
super.onCreate(savedInstanceState)
121126

@@ -167,6 +172,30 @@ class MainActivity : AppCompatActivity(), EventListCallback {
167172
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
168173
)
169174
}
175+
176+
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
177+
override fun handleOnBackPressed() {
178+
when {
179+
// SearchView has focus (keyboard visible) - just hide keyboard
180+
searchView?.hasFocus() == true -> {
181+
searchView?.clearFocus()
182+
}
183+
// Has active search filter - clear it
184+
!adapter.searchString.isNullOrEmpty() -> {
185+
searchView?.setQuery("", false)
186+
adapter.setSearchText(null)
187+
adapter.setEventsToDisplay()
188+
searchMenuItem?.collapseActionView()
189+
}
190+
// Default - let system handle (finish or move to background)
191+
else -> {
192+
isEnabled = false
193+
onBackPressedDispatcher.onBackPressed()
194+
isEnabled = true // Re-enable for when user returns from background
195+
}
196+
}
197+
}
198+
})
170199
}
171200

172201
public override fun onStart() {
@@ -419,23 +448,22 @@ class MainActivity : AppCompatActivity(), EventListCallback {
419448
override fun onCreateOptionsMenu(menu: Menu): Boolean {
420449
menuInflater.inflate(R.menu.main, menu)
421450

422-
val searchMenuItem = menu.findItem(R.id.action_search)
451+
searchMenuItem = menu.findItem(R.id.action_search)
423452

424-
searchMenuItem.isVisible = true
425-
searchMenuItem.isEnabled = true
453+
searchMenuItem?.isVisible = true
454+
searchMenuItem?.isEnabled = true
426455

427-
var searchView = searchMenuItem.actionView as SearchView
456+
searchView = searchMenuItem?.actionView as? SearchView
428457
val manager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
429458

430-
searchView.queryHint = resources.getQuantityString(R.plurals.search_placeholder, adapter.getAllItemCount(), adapter.getAllItemCount())
431-
searchView.setSearchableInfo(manager.getSearchableInfo(componentName))
459+
searchView?.queryHint = resources.getQuantityString(R.plurals.search_placeholder, adapter.getAllItemCount(), adapter.getAllItemCount())
460+
searchView?.setSearchableInfo(manager.getSearchableInfo(componentName))
432461

433-
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
462+
searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
434463
override fun onQueryTextSubmit(query: String?): Boolean {
435464
adapter.setSearchText(query)
436-
searchView.clearFocus()
437-
searchView.setQuery(query, false)
438-
searchMenuItem.collapseActionView()
465+
searchView?.clearFocus() // Hide keyboard but keep SearchView expanded
466+
searchView?.setQuery(query, false)
439467
adapter.setEventsToDisplay()
440468
return true
441469
}
@@ -447,14 +475,14 @@ class MainActivity : AppCompatActivity(), EventListCallback {
447475
}
448476
})
449477

450-
val closebutton: View = searchView.findViewById(androidx.appcompat.R.id.search_close_btn)
478+
val closebutton: View? = searchView?.findViewById(androidx.appcompat.R.id.search_close_btn)
451479

452-
closebutton.setOnClickListener {
453-
searchView.setQuery("",false)
454-
searchView.clearFocus()
480+
closebutton?.setOnClickListener {
481+
searchView?.setQuery("", false)
482+
searchView?.clearFocus()
455483
adapter.setSearchText(null)
456484
adapter.setEventsToDisplay()
457-
true
485+
searchMenuItem?.collapseActionView()
458486
}
459487

460488
val menuItem = menu.findItem(R.id.action_snooze_all)

0 commit comments

Comments
 (0)