From a0438c6afcfafd24d5077b4ae6ae8b6bd38954e9 Mon Sep 17 00:00:00 2001 From: MiMoHo <37556964+MiMoHo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:25:08 +0200 Subject: [PATCH] perf(search): adaptive debounce and background matching for in-note search The in-note search started almost immediately (50/200 ms static delay) after each typed character and ran counting, occurrence lookup and span highlighting synchronously on the main thread. In long notes this freezes the UI after the very first typed letter. - The debounce delay now adapts to the measured duration of the previous search + highlight pass: clamp(2 x lastDuration, 50 ms, 750 ms), plus 150 ms for queries of up to 3 characters. Short notes keep the current snappy behavior. - Occurrence counting runs on a background executor. A generation counter cancels superseded searches, so only the newest query result is applied (as suggested in #1729). - jumpToOccurrence no longer allocates full lower-case and substring copies of the note content per query; the recursive, case-sensitive indexOfNth got replaced by a single case-insensitive matcher pass. Fixes #1729 Helps #2489 Signed-off-by: MiMoHo <37556964+MiMoHo@users.noreply.github.com> --- .../edit/SearchableBaseNoteFragment.java | 97 ++++++++++++++----- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java index 503fbc9d2..01a355b41 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java @@ -8,6 +8,8 @@ import android.os.Bundle; import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; import android.text.Layout; import android.text.TextUtils; import android.util.Log; @@ -30,6 +32,9 @@ import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.nextcloud.android.sso.model.SingleSignOnAccount; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; import it.niedermann.owncloud.notes.R; @@ -47,9 +52,14 @@ public abstract class SearchableBaseNoteFragment extends BaseNoteFragment { private int occurrenceCount = 0; private SearchView searchView; private String searchQuery = null; - private static final int delay = 50; // If the search string does not change after $delay ms, then the search task starts. - private static final int shortStringDelay = 200; // A longer delay for short search strings. + private static final int minDelay = 50; // Minimum delay in ms after the search string stopped changing before the search task starts. + private static final int maxDelay = 750; // Upper bound in ms for the adaptive search delay. + private static final int shortStringExtraDelay = 150; // Additional delay for short search strings because they tend to produce many matches and therefore expensive highlighting. private static final int shortStringSize = 3; // The maximum length of a short search string. + private long lastSearchDuration = 0; // Measured duration of the last search + highlight pass. Used to adapt the debounce delay to the size of the note and the amount of matches (#1729). + private final AtomicInteger searchGeneration = new AtomicInteger(); + private final ExecutorService searchExecutor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); private boolean directEditRemotelyAvailable = false; // avoid using this directly, instead use: isDirectEditEnabled() @ColorInt @@ -160,6 +170,7 @@ public void onGlobalLayout() { if (currentVisibility != oldVisibility) { if (currentVisibility != View.VISIBLE) { + searchGeneration.incrementAndGet(); // Invalidate pending search results. colorWithText("", null, color); searchQuery = ""; hideSearchFabs(); @@ -216,15 +227,32 @@ public boolean onQueryTextChange(@NonNull String newText) { private void queryMatch(@NonNull String newText) { searchQuery = newText; - occurrenceCount = countOccurrences(getContent(), searchQuery); - if (occurrenceCount > 1) { - showSearchFabs(); - } else { - hideSearchFabs(); - } currentOccurrence = 1; - jumpToOccurrence(); - colorWithText(searchQuery, currentOccurrence, color); + final int generation = searchGeneration.incrementAndGet(); + final String content = getContent(); + searchExecutor.submit(() -> { + final long searchStart = SystemClock.elapsedRealtime(); + final int count = countOccurrences(content, newText); + if (generation != searchGeneration.get()) { + return; // A newer query arrived in the meantime → discard this result. + } + mainHandler.post(() -> { + if (generation != searchGeneration.get() || !isAdded()) { + return; + } + occurrenceCount = count; + if (occurrenceCount > 1) { + showSearchFabs(); + } else { + hideSearchFabs(); + } + jumpToOccurrence(); + colorWithText(searchQuery, currentOccurrence, color); + // Include the highlighting in the measurement because applying the spans + // is usually the most expensive part in long notes. + lastSearchDuration = SystemClock.elapsedRealtime() - searchStart; + }); + }); } private void queryWithHandler(@NonNull String newText) { @@ -232,9 +260,23 @@ private void queryWithHandler(@NonNull String newText) { delayQueryTask.cancel(); handler.removeCallbacksAndMessages(null); } + searchGeneration.incrementAndGet(); // Invalidate a potentially running search as early as possible. delayQueryTask = new DelayQueryRunnable(newText); - // If there are few chars in the search pattern, we should start the search later. - handler.postDelayed(delayQueryTask, newText.length() > shortStringSize ? delay : shortStringDelay); + handler.postDelayed(delayQueryTask, getAdaptiveDelay(newText)); + } + + /** + * Scales the debounce delay with the measured duration of the previous search pass. + * Searching in short notes keeps the current, snappy behavior, while long notes get a + * delay which is long enough to let the user finish typing before an expensive search + * and highlight pass gets triggered (#1729). + */ + private long getAdaptiveDelay(@NonNull String newText) { + long adaptiveDelay = Math.min(maxDelay, Math.max(minDelay, lastSearchDuration * 2)); + if (newText.length() <= shortStringSize) { + adaptiveDelay = Math.min(maxDelay, adaptiveDelay + shortStringExtraDelay); + } + return adaptiveDelay; } class DelayQueryRunnable implements Runnable { @@ -270,6 +312,13 @@ public void onSaveInstanceState(@NonNull Bundle outState) { } } + @Override + public void onDestroy() { + searchGeneration.incrementAndGet(); // Invalidate pending search results. + searchExecutor.shutdownNow(); + super.onDestroy(); + } + protected abstract void colorWithText(@NonNull String newText, @Nullable Integer current, @ColorInt int color); protected abstract Layout getLayout(); @@ -317,8 +366,7 @@ private void jumpToOccurrence() { currentOccurrence = occurrenceCount; jumpToOccurrence(); } else if (searchQuery != null && !searchQuery.isEmpty()) { - final String currentContent = getContent().toLowerCase(); - final int indexOfNewText = indexOfNth(currentContent, searchQuery.toLowerCase(), 0, currentOccurrence); + final int indexOfNewText = indexOfNth(getContent(), searchQuery, currentOccurrence); if (indexOfNewText <= 0) { // Search term is not n times in text // Go back to first search result @@ -328,8 +376,7 @@ private void jumpToOccurrence() { } return; } - final String textUntilFirstOccurrence = currentContent.substring(0, indexOfNewText); - final int numberLine = layout.getLineForOffset(textUntilFirstOccurrence.length()); + final int numberLine = layout.getLineForOffset(indexOfNewText); if (numberLine >= 0) { final var scrollView = getScrollView(); @@ -340,15 +387,19 @@ private void jumpToOccurrence() { } } - private static int indexOfNth(String input, String value, int startIndex, int nth) { + private static int indexOfNth(String input, String value, int nth) { if (nth < 1) throw new IllegalArgumentException("Param 'nth' must be greater than 0!"); - if (nth == 1) - return input.indexOf(value, startIndex); - final int idx = input.indexOf(value, startIndex); - if (idx == -1) - return -1; - return indexOfNth(input, value, idx + 1, nth - 1); + // Single, case insensitive pass without allocating lower case copies of the whole content. + final var matcher = Pattern.compile(value, Pattern.CASE_INSENSITIVE | Pattern.LITERAL) + .matcher(input); + int i = 0; + while (matcher.find()) { + if (++i == nth) { + return matcher.start(); + } + } + return -1; } private static int countOccurrences(String haystack, String needle) {