1 /*
2  * Copyright 2023 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.find;
18 
19 import android.app.Activity;
20 import android.content.Context;
21 import android.os.Bundle;
22 import android.os.Parcelable;
23 import android.text.Editable;
24 import android.text.TextUtils;
25 import android.text.TextWatcher;
26 import android.util.AttributeSet;
27 import android.view.KeyEvent;
28 import android.view.LayoutInflater;
29 import android.view.MotionEvent;
30 import android.view.View;
31 import android.view.inputmethod.EditorInfo;
32 import android.widget.ImageView;
33 import android.widget.LinearLayout;
34 import android.widget.TextView;
35 import android.widget.TextView.OnEditorActionListener;
36 
37 import androidx.annotation.RestrictTo;
38 import androidx.core.os.BundleCompat;
39 import androidx.core.view.WindowCompat;
40 import androidx.core.view.WindowInsetsCompat;
41 import androidx.pdf.R;
42 import androidx.pdf.models.MatchRects;
43 import androidx.pdf.util.Accessibility;
44 import androidx.pdf.util.CycleRange;
45 import androidx.pdf.util.ObservableValue;
46 import androidx.pdf.util.ObservableValue.ValueObserver;
47 import androidx.pdf.viewer.ImmersiveModeRequester;
48 import androidx.pdf.viewer.PaginatedView;
49 import androidx.pdf.viewer.SearchModel;
50 import androidx.pdf.viewer.SelectedMatch;
51 import androidx.pdf.viewer.loader.PdfLoader;
52 
53 import com.google.android.material.floatingactionbutton.FloatingActionButton;
54 
55 import org.jspecify.annotations.NonNull;
56 import org.jspecify.annotations.Nullable;
57 
58 import java.util.Objects;
59 
60 /**
61  * A View that has a search query box, find-next and find-previous button, useful for finding
62  * matches in a file. This does not use the real platform SearchView because it cannot be styled to
63  * remove the search icon and underline.
64  */
65 @RestrictTo(RestrictTo.Scope.LIBRARY)
66 public class FindInFileView extends LinearLayout {
67     private static final char MATCH_STATUS_COUNTING = '\u2026';
68     private static final String KEY_SUPER = "super";
69     private static final String KEY_FOCUS = "focus";
70     private static final String KEY_IS_SAVED = "is_saved";
71     private static final String KEY_MATCH_RECTS = "match_rects";
72     private static final String KEY_SELECTED_PAGE = "selected_page";
73     private static final String KEY_SELECTED_INDEX = "selected_index";
74 
75     private TextView mQueryBox;
76     private ImageView mPrevButton;
77     private ImageView mNextButton;
78     private TextView mMatchStatus;
79     private View mCloseButton;
80     private FloatingActionButton mAnnotationButton;
81     private PaginatedView mPaginatedView;
82     private ImmersiveModeRequester mImmersiveModeRequester;
83 
84     private FindInFileListener mFindInFileListener;
85     private Runnable mOnClosedButtonCallback;
86 
87     private SearchModel mSearchModel;
88     private ObservableValue<MatchCount> mMatchCount;
89 
90     private boolean mIsAnnotationIntentResolvable;
91     private boolean mIsRestoring;
92     private boolean mFocus;
93     private int mViewingPage;
94     private int mSelectedMatch;
95     private MatchRects mMatches;
96 
97     private OnVisibilityChangedListener mOnVisibilityChangedListener;
98 
99     private final OnClickListener mOnClickListener = new OnClickListener() {
100         @Override
101         public void onClick(View v) {
102             if (v == mPrevButton || v == mNextButton) {
103                 mQueryBox.clearFocus();
104                 if (mFindInFileListener != null) {
105                     boolean mBackwards = (v == mPrevButton);
106                     mFindInFileListener.onFindNextMatch(mQueryBox.getText().toString(), mBackwards);
107                 }
108             } else if (v == mCloseButton) {
109                 resetFindInFile();
110                 if (mIsAnnotationIntentResolvable) {
111                     mImmersiveModeRequester.requestImmersiveModeChange(false);
112                 }
113             }
114         }
115     };
116 
117     private final FindInFileListener mFindInFileListenerSetter = new FindInFileListener() {
118         @Override
119         public boolean onQueryTextChange(@Nullable String query) {
120             if (mSearchModel != null && mPaginatedView != null) {
121                 mSearchModel.setQuery(query, getViewingPage());
122                 return true;
123             }
124             return false;
125         }
126 
127         @Override
128         public boolean onFindNextMatch(String query, boolean backwards) {
129             if (mSearchModel != null) {
130                 CycleRange.Direction direction;
131                 if (backwards) {
132                     direction = CycleRange.Direction.BACKWARDS;
133                 } else {
134                     direction = CycleRange.Direction.FORWARDS;
135                 }
136                 mSearchModel.selectNextMatch(direction,
137                         mPaginatedView.getPageRangeHandler().getVisiblePage());
138                 return true;
139             }
140             return false;
141         }
142 
143         @Override
144         public @Nullable ObservableValue<MatchCount> matchCount() {
145             return mSearchModel != null ? mSearchModel.matchCount() : null;
146         }
147     };
148 
149     private final ValueObserver<MatchCount> mMatchCountObserver = new ValueObserver<MatchCount>() {
150         @Override
151         public void onChange(MatchCount oldMatchCount, MatchCount newMatchCount) {
152             if (newMatchCount == null) {
153                 mMatchStatus.setFocusableInTouchMode(false);
154                 mMatchStatus.setText("");
155             } else {
156                 String matchStatusText = getContext().getString(R.string.message_match_status,
157                         newMatchCount.mSelectedIndex + 1,
158                         // Zero-based - change to one-based for user.
159                         newMatchCount.mTotalMatches);
160 
161                 String matchStatusDescription =
162                         getContext().getString(R.string.match_status_description,
163                         newMatchCount.mSelectedIndex + 1,
164                         //Zero-based - change to one-based for user.
165                         newMatchCount.mTotalMatches);
166 
167                 if (newMatchCount.mIsAllPagesCounted) {
168                     if (newMatchCount.mSelectedIndex >= 0) {
169                         Accessibility.get().announce(getContext(), FindInFileView.this,
170                                 matchStatusDescription);
171                     }
172                     else {
173                         Accessibility.get().announce(getContext(), FindInFileView.this,
174                                 R.string.message_no_match_status);
175                     }
176                 } else {
177                     matchStatusText += MATCH_STATUS_COUNTING;  // Not yet all counted, use ellipses.
178                 }
179                 mMatchStatus.setText(matchStatusText);
180                 mMatchStatus.setFocusableInTouchMode(true);
181             }
182         }
183     };
184 
185     private final TextWatcher mOnQueryTextListener = new TextWatcher() {
186         @Override
187         public void beforeTextChanged(CharSequence s, int start, int count, int after) {
188         }
189 
190         @Override
191         public void onTextChanged(CharSequence s, int start, int before, int count) {
192         }
193 
194         @Override
195         public void afterTextChanged(Editable s) {
196             if (mFindInFileListener != null) {
197                 mFindInFileListener.onQueryTextChange(s.toString());
198             }
199 
200             // Enable next/prev button
201             if (!TextUtils.isGraphic(s)) {
202                 mMatchStatus.setVisibility(GONE);
203             } else {
204                 mMatchStatus.setVisibility(VISIBLE);
205             }
206         }
207     };
208 
209     private final OnEditorActionListener mOnActionListener = new OnEditorActionListener() {
210         @Override
211         public boolean onEditorAction(TextView textView, int actionId, KeyEvent event) {
212             if (actionId == EditorInfo.IME_ACTION_SEARCH) {
213                 mQueryBox.clearFocus();
214                 if (mFindInFileListener != null) {
215                     return mFindInFileListener.onFindNextMatch(textView.getText().toString(),
216                             false);
217                 }
218             }
219             return false;
220         }
221     };
222 
223     /**
224      *  Listener interface for receiving FindInFile visibility change events.
225      */
226     public interface OnVisibilityChangedListener {
227         /**
228          * Called when the visibility state changes.
229          */
onVisibilityChanged(boolean isVisible)230         void onVisibilityChanged(boolean isVisible);
231     }
232 
setOnVisibilityChangedListener(@ullable OnVisibilityChangedListener listener)233     public void setOnVisibilityChangedListener(@Nullable OnVisibilityChangedListener listener) {
234         this.mOnVisibilityChangedListener = listener;
235     }
236 
FindInFileView(@onNull Context context)237     public FindInFileView(@NonNull Context context) {
238         this(context, null);
239     }
240 
FindInFileView(@onNull Context context, @NonNull AttributeSet attrs)241     public FindInFileView(@NonNull Context context, @NonNull AttributeSet attrs) {
242         super(context, attrs);
243         LayoutInflater.from(context).inflate(R.layout.find_in_file, this, true);
244 
245         // Init UI Elements
246         mQueryBox = (TextView) findViewById(R.id.find_query_box);
247         mPrevButton = findViewById(R.id.find_prev_btn);
248         mNextButton = findViewById(R.id.find_next_btn);
249         mMatchStatus = (TextView) findViewById(R.id.match_status_textview);
250         mCloseButton = findViewById(R.id.close_btn);
251 
252         // Set Listeners
253         mQueryBox.addTextChangedListener(mOnQueryTextListener);
254         mQueryBox.setOnEditorActionListener(mOnActionListener);
255         mPrevButton.setOnClickListener(mOnClickListener);
256         mNextButton.setOnClickListener(mOnClickListener);
257         mCloseButton.setOnClickListener(mOnClickListener);
258 
259         // Set Focus In Touch Mode
260         setFocusInTouchMode();
261     }
262 
263     @Override
onVisibilityChanged(@onNull View changedView, int visibility)264     protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
265         super.onVisibilityChanged(changedView, visibility);
266 
267 
268         if (changedView == this && mOnVisibilityChangedListener != null) {
269             mOnVisibilityChangedListener.onVisibilityChanged(visibility == View.VISIBLE);
270         }
271     }
272 
273     @Override
onSaveInstanceState()274     protected @NonNull Parcelable onSaveInstanceState() {
275         Bundle bundle = new Bundle();
276         bundle.putParcelable(KEY_SUPER, super.onSaveInstanceState());
277         // Save TextView Focus State
278         bundle.putBoolean(KEY_FOCUS, mQueryBox.hasFocus());
279         // Save SearchModel State
280         if (mSearchModel != null && mSearchModel.selectedMatch().get() != null) {
281             bundle.putBoolean(KEY_IS_SAVED, true);
282             bundle.putParcelable(KEY_MATCH_RECTS, Objects.requireNonNull(
283                     mSearchModel.selectedMatch().get()).getPageMatches());
284             bundle.putInt(KEY_SELECTED_PAGE, mSearchModel.getSelectedPage());
285             bundle.putInt(KEY_SELECTED_INDEX,
286                     Objects.requireNonNull(mSearchModel.selectedMatch().get()).getSelected());
287         }
288         return bundle;
289     }
290 
291     @Override
onRestoreInstanceState(Parcelable state)292     protected void onRestoreInstanceState(Parcelable state) {
293         Bundle bundle = (Bundle) state;
294         super.onRestoreInstanceState(
295                 BundleCompat.getParcelable(bundle, KEY_SUPER, Parcelable.class));
296         if (bundle.getBoolean(KEY_IS_SAVED)) {
297             mIsRestoring = true;
298             mSelectedMatch = bundle.getInt(KEY_SELECTED_INDEX);
299             mViewingPage = bundle.getInt(KEY_SELECTED_PAGE);
300             mMatches = BundleCompat.getParcelable(bundle, KEY_MATCH_RECTS, MatchRects.class);
301         }
302     }
303 
304     /**
305      * Handles touch events and prevents further propagation
306      */
307     @Override
onTouchEvent(MotionEvent event)308     public boolean onTouchEvent(MotionEvent event) {
309         return true;
310     }
311 
312     /**
313      * Sets the pdfLoader and create a new {@link SearchModel} instance with the given pdfLoader.
314      */
setPdfLoader(@onNull PdfLoader pdfLoader)315     public void setPdfLoader(@NonNull PdfLoader pdfLoader) {
316         mSearchModel = new SearchModel(pdfLoader);
317     }
318 
setPaginatedView(@onNull PaginatedView paginatedView)319     public void setPaginatedView(@NonNull PaginatedView paginatedView) {
320         mPaginatedView = paginatedView;
321     }
322 
setOnClosedButtonCallback(@onNull Runnable onClosedButtonCallback)323     public void setOnClosedButtonCallback(@NonNull Runnable onClosedButtonCallback) {
324         this.mOnClosedButtonCallback = onClosedButtonCallback;
325     }
326 
getSearchModel()327     public @NonNull SearchModel getSearchModel() {
328         return mSearchModel;
329     }
330 
setAnnotationButton( @onNull FloatingActionButton annotationButton, @NonNull ImmersiveModeRequester immersiveModeRequester)331     public void setAnnotationButton(
332             @NonNull FloatingActionButton annotationButton,
333             @NonNull ImmersiveModeRequester immersiveModeRequester) {
334         mAnnotationButton = annotationButton;
335         mImmersiveModeRequester = immersiveModeRequester;
336     }
337 
setAnnotationIntentResolvable( boolean isAnnotationIntentResolvable)338     public void setAnnotationIntentResolvable(
339             boolean isAnnotationIntentResolvable) {
340         mIsAnnotationIntentResolvable = isAnnotationIntentResolvable;
341     }
342 
343     /**
344      * Sets the visibility of the find-in-file view and configures its behavior.
345      *
346      * @param visibility true to show the find-in-file view, false to hide it.
347      */
setFindInFileView(boolean visibility)348     public void setFindInFileView(boolean visibility) {
349         if (mSearchModel == null) {
350             return; // Ignore call. Models not initialized yet
351         }
352         if (visibility) {
353             this.setVisibility(VISIBLE);
354             if (mAnnotationButton != null && mAnnotationButton.getVisibility() == VISIBLE) {
355                 mImmersiveModeRequester.requestImmersiveModeChange(true);
356             }
357             // We set the FIF listener after the document loads
358             // to prevent incomplete search results.
359             setFindInFileListener();
360             setMatchStatus();
361             // Requests the keyboard based on the focus flag
362             if (mFocus) {
363                 queryBoxRequestFocus();
364             }
365             // Restores search model select state
366             if (mIsRestoring) {
367                 restoreSelectedMatch();
368             }
369         } else {
370             this.setVisibility(GONE);
371         }
372     }
373 
374     /**
375      * Resets the visibility of the FindInFileView and resets the search query
376      */
resetFindInFile()377     public void resetFindInFile() {
378         mOnClosedButtonCallback.run();
379         this.setVisibility(GONE);
380         mQueryBox.clearFocus();
381         mQueryBox.setText("");
382         mFocus = true;
383         mIsRestoring = false;
384     }
385 
restoreSelectedMatch()386     private void restoreSelectedMatch() {
387         // If the first match is selected, no need to restore since it will be reselected by default
388         if (mSelectedMatch > 0) {
389             mSearchModel.setSelectedMatch(
390                     new SelectedMatch(mSearchModel.query().get(), mViewingPage, mMatches,
391                             mSelectedMatch - 1));
392             mSearchModel.selectNextMatch(CycleRange.Direction.FORWARDS, mViewingPage);
393         }
394     }
395 
396     /**
397      * registers the {@link FindInFileListener}
398      */
setFindInFileListener()399     private void setFindInFileListener() {
400         this.mFindInFileListener = mFindInFileListenerSetter;
401     }
402 
403     /**
404      *  Sets match count observer and search for any existing string in QueryBox
405      */
setMatchStatus()406     private void setMatchStatus() {
407         // Set MatchCount Observer for Search
408         setObservableMatchCount(
409                 (mFindInFileListener != null) ? mFindInFileListener.matchCount() : null);
410         // Check for any existing string in QueryBox
411         if (!mQueryBox.getText().toString().isEmpty()) {
412             if (mFindInFileListener != null) {
413                 mFindInFileListener.onQueryTextChange(mQueryBox.getText().toString());
414             }
415             mMatchStatus.setVisibility(VISIBLE);
416         }
417     }
418 
setObservableMatchCount(@ullable ObservableValue<MatchCount> matchCount)419     private void setObservableMatchCount(@Nullable ObservableValue<MatchCount> matchCount) {
420         if (this.mMatchCount != null) {
421             this.mMatchCount.removeObserver(mMatchCountObserver);
422         }
423         this.mMatchCount = matchCount;
424         if (this.mMatchCount != null) {
425             this.mMatchCount.addObserver(mMatchCountObserver);
426         }
427     }
428 
429     /**
430      * Requests focus and shows the keyboard when find in file view is inflated.
431      */
queryBoxRequestFocus()432     private void queryBoxRequestFocus() {
433         mQueryBox.requestFocus();
434         WindowCompat.getInsetsController(((Activity) getContext()).getWindow(), this)
435                 .show(WindowInsetsCompat.Type.ime());
436     }
437 
438     /**
439      * Enables touch mode focus for the view and sets the focus flag.
440      */
setFocusInTouchMode()441     private void setFocusInTouchMode() {
442         this.setFocusableInTouchMode(true);
443         mFocus = true;
444     }
445 
getViewingPage()446     private int getViewingPage() {
447         if (mIsRestoring) {
448             return mViewingPage;
449         }
450         return mPaginatedView.getPageRangeHandler().getVisiblePage();
451     }
452 
453     /**
454      * Hides the Keyboard and clears focus from QueryBox when SingleTap event is detected
455      */
handleSingleTapEvent()456     public void handleSingleTapEvent() {
457         mQueryBox.clearFocus();
458     }
459 }
460