1 /* 2 * Copyright 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.pdf.viewer; 18 19 import android.text.TextUtils; 20 21 import androidx.annotation.RestrictTo; 22 import androidx.pdf.find.MatchCount; 23 import androidx.pdf.models.MatchRects; 24 import androidx.pdf.util.CycleRange; 25 import androidx.pdf.util.CycleRange.Direction; 26 import androidx.pdf.util.ObservableValue; 27 import androidx.pdf.util.Observables; 28 import androidx.pdf.util.Observables.ExposedValue; 29 import androidx.pdf.util.Preconditions; 30 import androidx.pdf.viewer.loader.PdfLoader; 31 32 import org.jspecify.annotations.NonNull; 33 import org.jspecify.annotations.Nullable; 34 35 import java.util.Arrays; 36 import java.util.Objects; 37 38 /** 39 * Stores data relevant to the current search, including the query and the selected match, and the 40 * number of matches on each page. 41 * 42 * <p>SearchModel is responsible for starting SearchPageTextTasks for every page that it needs data 43 * for. It uses the pdfLoader to do this. Whenever the user updates a query or selects "find next" 44 * or "find previous", this class will update data about the number of matches on each page, and 45 * about which match is selected. The viewer can listen to changes in the selected data. 46 */ 47 @RestrictTo(RestrictTo.Scope.LIBRARY) 48 public class SearchModel { 49 50 /** 51 * The current query. Null if the user is not performing a search, or searches for 52 * whitespace. 53 */ 54 private final ExposedValue<String> mQuery = Observables.newExposedValueWithInitialValue(null); 55 56 /** 57 * The currently selected match. Null if there the search query is null or there are no matches 58 * for the current query, or a match for the current query has not yet been found. 59 */ 60 private final ExposedValue<SelectedMatch> mSelectedMatch = 61 Observables.newExposedValueWithInitialValue(null); 62 63 /** 64 * We store the last selected match so that when the next search finishes, we can select the 65 * match 66 * that is as close as possible to the last selected match. 67 */ 68 private SelectedMatch mLastSelectedMatch = null; 69 70 /** The index of the current match out of the total matches found ie, match 3 of 8. */ 71 private final ExposedValue<MatchCount> mMatchCount = 72 Observables.newExposedValueWithInitialValue(null); 73 74 private final PdfLoader mPdfLoader; 75 76 /** 77 * The number of matches of the current query found on each page. Remains {@code null} until the 78 * document is loaded and so the array length is known. 79 */ 80 private int[] mPageToMatchCount; 81 /** The total number of matches of the current query, found on all pages so far. */ 82 private int mTotalMatches = 0; 83 84 /** 85 * An iterator that spreads OUTWARDS from the current location. If null, counting all the 86 * matches 87 * hasn't started. If !hasNext(), counting the matches has finished - otherwise it is in 88 * progress. 89 */ 90 private CycleRange.Iterator mNextPageToCount; 91 92 /** 93 * An iterator that spreads FORWARDS or BACKWARDS from the last selected match. If null, it 94 * means 95 * no find-next or find-previous operation in progress. If !hasNext(), it means the entire 96 * document was searched and no match was found. Otherwise, a find-next or find-previous is in 97 * progress, and if a match is found, then the selected match will be updated. 98 */ 99 private CycleRange.Iterator mNextSelectedPage; 100 SearchModel(@onNull PdfLoader pdfLoader)101 public SearchModel(@NonNull PdfLoader pdfLoader) { 102 this.mPdfLoader = pdfLoader; 103 } 104 105 /** Set the number of pages the document has. */ setNumPages(int numPages)106 public void setNumPages(int numPages) { 107 mPageToMatchCount = new int[numPages]; 108 clearPageToMatchCount(); 109 } 110 111 /** Return the number of pages the document has, or -1 if not yet known. */ getNumPages()112 public int getNumPages() { 113 return (mPageToMatchCount != null) ? mPageToMatchCount.length : -1; 114 } 115 116 /** Return the current search query. */ query()117 public @NonNull ObservableValue<String> query() { 118 return mQuery; 119 } 120 121 /** Return the currently selected match. */ selectedMatch()122 public @NonNull ObservableValue<SelectedMatch> selectedMatch() { 123 return mSelectedMatch; 124 } 125 126 /** Return index of the current match out of the total matches found. */ matchCount()127 public @NonNull ObservableValue<MatchCount> matchCount() { 128 return mMatchCount; 129 } 130 131 /** 132 * Returns the page that the currently selected match is on, or -1 if there is no currently 133 * selected match. 134 */ getSelectedPage()135 public int getSelectedPage() { 136 SelectedMatch value = mSelectedMatch.get(); 137 return (value != null) ? value.getPage() : -1; 138 } 139 140 /** Set query for new search. */ setQuery(@ullable String newQuery, int viewingPage)141 public void setQuery(@Nullable String newQuery, int viewingPage) { 142 newQuery = whiteSpaceToNull(newQuery); 143 if (!Objects.equals(mQuery.get(), newQuery)) { 144 mQuery.set(newQuery); 145 mSelectedMatch.set(null); 146 clearPageToMatchCount(); 147 if (newQuery != null) { 148 startNewSearch(newQuery, viewingPage); 149 } else { 150 mLastSelectedMatch = null; 151 } 152 } 153 } 154 startNewSearch(String newQuery, int viewingPage)155 private void startNewSearch(String newQuery, int viewingPage) { 156 if (getNumPages() < 0) { 157 return; // Cannot search until setNumPages is called. 158 } 159 160 // Start on the page the last selected match was on, if there was one. 161 // If not then start on the page the user is viewing. 162 int startPage = (mLastSelectedMatch != null) ? mLastSelectedMatch.getPage() : viewingPage; 163 164 // Make a plan to select a match, starting here and going forwards until we find the match. 165 mNextSelectedPage = CycleRange.of(startPage, getNumPages(), Direction.FORWARDS).iterator(); 166 // Make a plan to count all matches on every page, starting here and going outwards until 167 // there are no more pages to count. 168 mNextPageToCount = CycleRange.of(startPage, getNumPages(), Direction.OUTWARDS).iterator(); 169 mPdfLoader.searchPageText(startPage, newQuery); 170 } 171 172 /** Clears pageToMatchCount array, nextPageToCount and totalMatches. */ clearPageToMatchCount()173 private void clearPageToMatchCount() { 174 if (mPageToMatchCount != null) { 175 Arrays.fill(mPageToMatchCount, -1); 176 } 177 mNextPageToCount = null; 178 mTotalMatches = 0; 179 } 180 181 /** 182 * Add these search results into the model. Returns true if another search task was started now 183 * that these results have arrived, false if no further searching is necessary. 184 */ updateMatches(@onNull String matchesQuery, int page, @NonNull MatchRects matches)185 public boolean updateMatches(@NonNull String matchesQuery, int page, 186 @NonNull MatchRects matches) { 187 Preconditions.checkState( 188 getNumPages() >= 0, "updateMatches should only be called after setNumPages"); 189 190 String currentQuery = this.mQuery.get(); 191 if (!Objects.equals(matchesQuery, currentQuery)) { 192 return false; // This data is irrelevant as it is for an old query - ignore. 193 } 194 195 // Update pageToMatchCount and totalMatches with data from this page, if it is new data. 196 if (mPageToMatchCount[page] == -1) { 197 mPageToMatchCount[page] = matches.size(); 198 mTotalMatches += matches.size(); 199 } 200 201 // If a search is ongoing and we've found the next match on this page, we update 202 // selectedMatch and stop the search by setting nextSelectedPage iterator to null. 203 if (mNextSelectedPage != null 204 && mNextSelectedPage.hasNext() 205 && mNextSelectedPage.peekNext() == page 206 && !matches.isEmpty()) { 207 208 if (mLastSelectedMatch != null && mLastSelectedMatch.getPage() == page) { 209 // The last search result was on this page too - find the new match closest to 210 // the previous: 211 mSelectedMatch.set(mLastSelectedMatch.nearestMatch(currentQuery, matches)); 212 } else { 213 // Select either the first or last match on the page, depending on which direction 214 // we are searching: 215 int selectedIndex = 216 (mNextSelectedPage.getDirection() == Direction.BACKWARDS) ? matches.size() 217 - 1 : 0; 218 mSelectedMatch.set(new SelectedMatch(currentQuery, page, matches, selectedIndex)); 219 } 220 mLastSelectedMatch = mSelectedMatch.get(); 221 // Clear the nextSelectedPage iterator, indicating we have found selected a match. 222 mNextSelectedPage = null; 223 } 224 225 // Search for the next selected match, or if that isn't needed, continue counting matches. 226 boolean newSearchStarted = 227 searchNextPageThat(Condition.IS_MATCH_COUNT_UNKNOWN_OR_POSITIVE, mNextSelectedPage) 228 || searchNextPageThat(Condition.IS_MATCH_COUNT_UNKNOWN, mNextPageToCount); 229 updateMatchCount(); 230 return newSearchStarted; 231 } 232 updateMatchCount()233 private void updateMatchCount() { 234 SelectedMatch currentMatch = mSelectedMatch.get(); 235 int overallSelectedIndex = -1; 236 if (currentMatch != null) { 237 overallSelectedIndex = currentMatch.getSelected(); 238 for (int p = 0; p < currentMatch.getPage(); p++) { 239 if (mPageToMatchCount[p] > 0) { 240 overallSelectedIndex += mPageToMatchCount[p]; 241 } 242 } 243 } 244 boolean isAllPagesCounted = mNextPageToCount != null && !mNextPageToCount.hasNext(); 245 MatchCount newMatchCount = 246 new MatchCount(overallSelectedIndex, mTotalMatches, isAllPagesCounted); 247 if (!Objects.equals(newMatchCount, mMatchCount.get())) { 248 mMatchCount.set(newMatchCount); 249 } 250 } 251 252 /** 253 * Selects the next match - may succeed immediately, if the next match is on the same page, 254 * or may 255 * request it from the PdfLoader, which will run asynchronously and eventually call {@link 256 * #updateMatches}. 257 */ selectNextMatch(@onNull Direction direction, int viewingPage)258 public void selectNextMatch(@NonNull Direction direction, int viewingPage) { 259 if (getNumPages() < 0) { 260 return; // Cannot search until setNumPages is called. 261 } 262 263 String currentQuery = mQuery.get(); 264 SelectedMatch currentMatch = mSelectedMatch.get(); 265 266 if (currentQuery != null) { 267 if (selectNextMatchOnPage(direction)) { 268 return; 269 } 270 int startPage = viewingPage; 271 if (currentMatch != null) { 272 startPage = currentMatch.getPage() + direction.sign; 273 } 274 mNextSelectedPage = CycleRange.of(startPage, getNumPages(), direction).iterator(); 275 searchNextPageThat(Condition.IS_MATCH_COUNT_UNKNOWN_OR_POSITIVE, mNextSelectedPage); 276 } 277 } 278 selectNextMatchOnPage(Direction direction)279 private boolean selectNextMatchOnPage(Direction direction) { 280 if (mSelectedMatch.get() != null) { 281 SelectedMatch nextMatch = mSelectedMatch.get().selectNextMatchOnPage(direction); 282 if (nextMatch != null) { 283 mSelectedMatch.set(nextMatch); 284 mLastSelectedMatch = nextMatch; 285 updateMatchCount(); 286 return true; 287 } 288 } 289 return false; 290 } 291 292 /** Return the page overlay for the selection. */ getOverlay(@onNull String matchesQuery, int page, @NonNull MatchRects matches)293 public @Nullable PdfHighlightOverlay getOverlay(@NonNull String matchesQuery, int page, 294 @NonNull MatchRects matches) { 295 if (page == getSelectedPage()) { 296 return mSelectedMatch.get().getOverlay(); 297 } 298 if (Objects.equals(matchesQuery, mQuery.get())) { 299 return new PdfHighlightOverlay(matches); 300 } 301 return null; 302 } 303 304 /** 305 * Walks through the given iterator, and launches a search task as soon as a page is found that 306 * meets the given condition. 307 * 308 * @return true if such a page is found and a search task is started. 309 */ searchNextPageThat(Condition condition, CycleRange.@Nullable Iterator iterator)310 private boolean searchNextPageThat(Condition condition, 311 CycleRange.@Nullable Iterator iterator) { 312 if (iterator == null) { 313 return false; 314 } 315 while (iterator.hasNext() && !condition.apply(mPageToMatchCount[iterator.peekNext()])) { 316 iterator.next(); 317 } 318 if (iterator.hasNext()) { 319 mPdfLoader.searchPageText(iterator.peekNext(), mQuery.get()); 320 return true; 321 } 322 return false; 323 } 324 325 /** Different conditions relating to the number of matches known to be on a certain page. */ 326 private enum Condition { 327 IS_MATCH_COUNT_UNKNOWN { 328 @Override apply(int matchCount)329 boolean apply(int matchCount) { 330 return matchCount == -1; 331 } 332 }, 333 IS_MATCH_COUNT_UNKNOWN_OR_POSITIVE { 334 @Override apply(int matchCount)335 boolean apply(int matchCount) { 336 return matchCount != 0; 337 } 338 }; 339 apply(int matchCount)340 abstract boolean apply(int matchCount); 341 } 342 343 /** 344 * Treat whitespace-only strings the same as null, which means, don't search: whitespace can 345 * match 346 * newlines, which don't have a highlightable area. 347 */ whiteSpaceToNull(@onNull String query)348 public static @Nullable String whiteSpaceToNull(@NonNull String query) { 349 return (query != null && TextUtils.isGraphic(query)) ? query : null; 350 } 351 352 /** Update the current selected match */ setSelectedMatch(@onNull SelectedMatch selectedMatch)353 public void setSelectedMatch(@NonNull SelectedMatch selectedMatch) { 354 mSelectedMatch.set(selectedMatch); 355 } 356 } 357