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