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