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.loader; 18 19 import android.annotation.SuppressLint; 20 import android.graphics.Bitmap; 21 import android.graphics.Point; 22 import android.os.RemoteException; 23 24 import androidx.annotation.RestrictTo; 25 import androidx.pdf.R; 26 import androidx.pdf.models.Dimensions; 27 import androidx.pdf.models.GotoLink; 28 import androidx.pdf.models.LinkRects; 29 import androidx.pdf.models.MatchRects; 30 import androidx.pdf.models.PageSelection; 31 import androidx.pdf.models.SelectionBoundary; 32 import androidx.pdf.service.PdfDocumentRemoteProto; 33 import androidx.pdf.util.TileBoard.TileInfo; 34 35 import org.jspecify.annotations.NonNull; 36 37 import java.util.Collections; 38 import java.util.Iterator; 39 import java.util.List; 40 import java.util.Map; 41 import java.util.Map.Entry; 42 import java.util.Objects; 43 44 /** 45 * Loads data for an individual page of the PDF document. Makes sure that if 46 * the same data is requested more than once, it doesn't lead to duplicate 47 * tasks being scheduled. 48 * <p> 49 * For all bitmap loading tasks, it will cancel any task for a lower or different resolution when 50 * a new request is accepted. 51 */ 52 @RestrictTo(RestrictTo.Scope.LIBRARY) 53 public class PdfPageLoader { 54 public static final String TAG = PdfPageLoader.class.getSimpleName(); 55 56 /** Arbitrary dimensions used for pages that are broken. */ 57 private static final Dimensions DEFAULT_PAGE = new Dimensions(400, 400); 58 59 private final PdfLoader mParent; 60 private final int mPageNum; 61 private final boolean mHideTextAnnotations; 62 /** Currently scheduled tasks - null if no task of this type is scheduled. */ 63 GetDimensionsTask mDimensionsTask; 64 RenderBitmapTask mBitmapTask; 65 GetPageTextTask mTextTask; 66 SearchPageTextTask mSearchPageTextTask; 67 SelectionTask mSelectionTask; 68 GetPageLinksTask mLinksTask; 69 GetPageGotoLinksTask mGotoLinksTask; 70 ReleasePageTask mReleasePageTask; 71 72 /** 73 * All currently scheduled tile tasks. 74 * 75 * <p>Map is preferred to SparseArray because of frequent access by key, and leaner API (e.g. 76 * combined remove-get). 77 */ 78 @SuppressLint({"UseSparseArrays", "BanConcurrentHashMap"}) 79 Map<Integer, RenderTileTask> mTileTasks = 80 new java.util.concurrent.ConcurrentHashMap<Integer, RenderTileTask>(); 81 82 /** The reference pageWidth for all tile related tasks. */ 83 int mTilePageWidth; 84 /** 85 * This flag is set when this page makes pdfClient crash, and we'd better avoid crashing it 86 * again. 87 */ 88 private boolean mIsBroken = false; 89 PdfPageLoader(PdfLoader parent, int pageNum, boolean hideTextAnnotations)90 PdfPageLoader(PdfLoader parent, int pageNum, boolean hideTextAnnotations) { 91 this.mParent = parent; 92 this.mPageNum = pageNum; 93 this.mHideTextAnnotations = hideTextAnnotations; 94 } 95 96 /** Schedule task to get the page's dimensions. */ loadPageDimensions()97 public void loadPageDimensions() { 98 if (mDimensionsTask == null) { 99 mDimensionsTask = new GetDimensionsTask(); 100 if (mIsBroken) { 101 mDimensionsTask.reportError(mParent.getCallbacks()); 102 } else { 103 mParent.mExecutor.schedule(mDimensionsTask); 104 } 105 } 106 } 107 cancelPageDimensions()108 private void cancelPageDimensions() { 109 if (mDimensionsTask != null) { 110 mDimensionsTask.cancel(); 111 mDimensionsTask = null; 112 } 113 } 114 115 /** Schedule task to render a page as a bitmap. */ loadPageBitmap(@onNull Dimensions pageSize)116 public void loadPageBitmap(@NonNull Dimensions pageSize) { 117 if (mBitmapTask != null && mBitmapTask.mDimensions.getWidth() < pageSize.getWidth()) { 118 cancelPageBitmap(); 119 } 120 if (mBitmapTask == null) { 121 mBitmapTask = new RenderBitmapTask(pageSize); 122 if (mIsBroken) { 123 mBitmapTask.reportError(mParent.getCallbacks()); 124 } else { 125 mParent.mExecutor.schedule(mBitmapTask); 126 } 127 } 128 } 129 cancelPageBitmap()130 private void cancelPageBitmap() { 131 if (mBitmapTask != null) { 132 mBitmapTask.cancel(); 133 mBitmapTask = null; 134 } 135 } 136 137 /** 138 * Loads the given tiles. If there are any pending tasks targeting a different page size that 139 * this one, they are all canceled. If any tile request is a duplicate, it will not be 140 * scheduled. 141 */ loadTilesBitmaps(@onNull Dimensions pageSize, @NonNull Iterable<TileInfo> tiles)142 public void loadTilesBitmaps(@NonNull Dimensions pageSize, @NonNull Iterable<TileInfo> tiles) { 143 if (!mTileTasks.isEmpty() && mTilePageWidth != pageSize.getWidth()) { 144 cancelAllTileBitmaps(); 145 } 146 if (!mIsBroken) { 147 for (TileInfo tile : tiles) { 148 RenderTileTask tileTask = new RenderTileTask(pageSize, tile); 149 if (!mTileTasks.containsKey(tile.getIndex())) { 150 mTileTasks.put(tile.getIndex(), tileTask); 151 mParent.mExecutor.schedule(tileTask); 152 } 153 } 154 mTilePageWidth = pageSize.getWidth(); 155 } 156 } 157 cancelTileBitmaps(Iterable<Integer> tileIds)158 void cancelTileBitmaps(Iterable<Integer> tileIds) { 159 for (int tileId : tileIds) { 160 RenderTileTask task = mTileTasks.remove(tileId); 161 if (task != null) { 162 task.cancel(); 163 } 164 } 165 } 166 cancelAllTileBitmaps()167 void cancelAllTileBitmaps() { 168 // Unusual iteration since cancelling a task removes it from the tileTasks, 169 // which can lead to ConcurrentModificationException if we are iterating over it. 170 Iterator<Entry<Integer, RenderTileTask>> it = mTileTasks.entrySet().iterator(); 171 while (it.hasNext()) { 172 RenderTileTask task = it.next().getValue(); 173 it.remove(); 174 task.cancel(); 175 } 176 mTileTasks.clear(); 177 mTilePageWidth = 0; 178 } 179 180 /** Schedule task to get a page's text. */ loadPageText()181 public void loadPageText() { 182 if (!mIsBroken && mTextTask == null) { 183 mTextTask = new GetPageTextTask(); 184 mParent.mExecutor.schedule(mTextTask); 185 } 186 } 187 cancelPageText()188 private void cancelPageText() { 189 if (mTextTask != null) { 190 mTextTask.cancel(); 191 mTextTask = null; 192 } 193 } 194 195 /** Schedule task to search a page's text. */ searchPageText(@onNull String query)196 public void searchPageText(@NonNull String query) { 197 if (!mIsBroken && mSearchPageTextTask != null && !mSearchPageTextTask.mQuery.equals( 198 query)) { 199 cancelSearch(); 200 } 201 if (mSearchPageTextTask == null) { 202 mSearchPageTextTask = new SearchPageTextTask(query); 203 mParent.mExecutor.schedule(mSearchPageTextTask); 204 } 205 } 206 cancelSearch()207 private void cancelSearch() { 208 if (mSearchPageTextTask != null) { 209 mSearchPageTextTask.cancel(); 210 mSearchPageTextTask = null; 211 } 212 } 213 214 /** Schedule task to select some of the page text. */ selectPageText(@onNull SelectionBoundary start, @NonNull SelectionBoundary stop)215 public void selectPageText(@NonNull SelectionBoundary start, @NonNull SelectionBoundary stop) { 216 // These tasks will be requested almost continuously as long as the user 217 // is dragging a handle - only start a new one if we finished the last one. 218 if (mSelectionTask != null) { 219 if (Objects.equals(start, stop)) { 220 cancelSelect(); // New selection at a single point - cancel existing task. 221 } else { 222 return; // Dragging: just wait for the currently running task to finish. 223 } 224 } 225 if (!mIsBroken && mSelectionTask == null) { 226 mSelectionTask = new SelectionTask(start, stop); 227 mParent.mExecutor.schedule(mSelectionTask); 228 } 229 } 230 cancelSelect()231 private void cancelSelect() { 232 if (mSelectionTask != null) { 233 mSelectionTask.cancel(); 234 mSelectionTask = null; 235 } 236 } 237 238 /** Schedule task to get a page's url links. */ loadPageLinks()239 public void loadPageLinks() { 240 if (!mIsBroken && mLinksTask == null) { 241 mLinksTask = new GetPageLinksTask(); 242 mParent.mExecutor.schedule(mLinksTask); 243 } 244 } 245 cancelLinks()246 private void cancelLinks() { 247 if (mLinksTask != null) { 248 mLinksTask.cancel(); 249 mLinksTask = null; 250 } 251 } 252 253 /** Schedule task to get a page's goto links. */ loadPageGotoLinks()254 public void loadPageGotoLinks() { 255 if (!mIsBroken && mGotoLinksTask == null) { 256 mGotoLinksTask = new GetPageGotoLinksTask(); 257 mParent.mExecutor.schedule(mGotoLinksTask); 258 } 259 } 260 261 /** Releases object in memory related to a page when that page is no longer visible. */ releasePage()262 public void releasePage() { 263 if (mReleasePageTask == null) { 264 mReleasePageTask = new ReleasePageTask(); 265 mParent.mExecutor.schedule(mReleasePageTask); 266 } 267 } 268 269 /** 270 * 271 */ cancel()272 public void cancel() { 273 cancelExceptSearchAndFormFilling(); 274 cancelSearch(); 275 } 276 277 /** Cancel all tasks except search and form-filling. */ cancelExceptSearchAndFormFilling()278 public void cancelExceptSearchAndFormFilling() { 279 cancelPageDimensions(); 280 cancelPageBitmap(); 281 cancelAllTileBitmaps(); 282 cancelPageText(); 283 cancelSelect(); 284 cancelLinks(); 285 } 286 setBroken()287 private void setBroken() { 288 // TODO: Track the broken state of the FileInfo object. 289 if (!mIsBroken) { 290 mIsBroken = true; 291 } 292 } 293 294 /** AsyncTask for getting a page's dimensions. */ 295 class GetDimensionsTask extends AbstractPdfTask<Dimensions> { 296 GetDimensionsTask()297 GetDimensionsTask() { 298 super(mParent, Priority.DIMENSIONS); 299 } 300 301 @Override getLogTag()302 protected String getLogTag() { 303 return "GetDimensionsTask"; 304 } 305 306 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)307 protected Dimensions doInBackground(PdfDocumentRemoteProto pdfDocument) 308 throws RemoteException { 309 return pdfDocument.getPdfDocumentRemote().getPageDimensions(mPageNum); 310 } 311 312 @Override doCallback(PdfLoaderCallbacks callbacks, Dimensions result)313 protected void doCallback(PdfLoaderCallbacks callbacks, Dimensions result) { 314 // If invalid dimensions are returned, treat it as page broken and report error 315 if (!arePageDimensionsValid(result)) { 316 reportError(callbacks); 317 } else { 318 callbacks.setPageDimensions(mPageNum, result); 319 } 320 } 321 arePageDimensionsValid(Dimensions dimensions)322 private boolean arePageDimensionsValid(Dimensions dimensions) { 323 return dimensions.getWidth() > 0 && dimensions.getHeight() > 0; 324 } 325 326 @Override reportError(PdfLoaderCallbacks callbacks)327 protected void reportError(PdfLoaderCallbacks callbacks) { 328 setBroken(); 329 callbacks.setPageDimensions(mPageNum, DEFAULT_PAGE); 330 callbacks.pageBroken(mPageNum); 331 } 332 333 @Override cleanup()334 protected void cleanup() { 335 mDimensionsTask = null; 336 } 337 } 338 339 /** AsyncTask for rendering a page as a bitmap. */ 340 class RenderBitmapTask extends AbstractPdfTask<Bitmap> { 341 342 final Dimensions mDimensions; 343 RenderBitmapTask(Dimensions dimensions)344 RenderBitmapTask(Dimensions dimensions) { 345 super(mParent, Priority.BITMAP); 346 this.mDimensions = dimensions; 347 } 348 349 @Override getLogTag()350 protected String getLogTag() { 351 return "RenderBitmapTask"; 352 } 353 354 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)355 protected Bitmap doInBackground(PdfDocumentRemoteProto pdfDocument) throws RemoteException { 356 return pdfDocument.getPdfDocumentRemote().renderPage(mPageNum, mDimensions.getWidth(), 357 mDimensions.getHeight(), 358 mHideTextAnnotations); 359 } 360 361 @Override doCallback(PdfLoaderCallbacks callbacks, Bitmap result)362 protected void doCallback(PdfLoaderCallbacks callbacks, Bitmap result) { 363 if (result != null) { 364 callbacks.setPageBitmap(mPageNum, result); 365 } 366 } 367 368 @Override cleanup()369 protected void cleanup() { 370 mBitmapTask = null; 371 } 372 373 @Override reportError(PdfLoaderCallbacks callbacks)374 protected void reportError(PdfLoaderCallbacks callbacks) { 375 setBroken(); 376 callbacks.pageBroken(mPageNum); 377 } 378 379 @Override toString()380 public @NonNull String toString() { 381 return String.format("RenderBitmapTask(page=%d width=%d height=%d)", 382 mPageNum, mDimensions.getWidth(), mDimensions.getHeight()); 383 } 384 } 385 386 /** AsyncTask for releasing page objects from memory after it is no longer visible. */ 387 class ReleasePageTask extends AbstractPdfTask<Void> { ReleasePageTask()388 ReleasePageTask() { 389 super(mParent, Priority.RELEASE); 390 } 391 392 @Override getLogTag()393 protected String getLogTag() { 394 return "ReleasePageTask"; 395 } 396 397 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)398 protected Void doInBackground(PdfDocumentRemoteProto pdfDocument) throws RemoteException { 399 pdfDocument.getPdfDocumentRemote().releasePage(mPageNum); 400 return null; 401 } 402 403 @Override doCallback(PdfLoaderCallbacks callbacks, Void unused)404 protected void doCallback(PdfLoaderCallbacks callbacks, Void unused) { 405 /* no-op */ 406 } 407 408 @Override cleanup()409 protected void cleanup() { 410 mReleasePageTask = null; 411 } 412 413 @Override toString()414 public @NonNull String toString() { 415 return String.format("ReleasePageTask(page=%d)", mPageNum); 416 } 417 } 418 419 /** AsyncTask for rendering a tile as a bitmap. */ 420 class RenderTileTask extends AbstractPdfTask<Bitmap> { 421 final Dimensions mPageSize; 422 final TileInfo mTileInfo; 423 RenderTileTask(Dimensions pageDimensions, TileInfo tileInfo)424 RenderTileTask(Dimensions pageDimensions, TileInfo tileInfo) { 425 super(mParent, Priority.BITMAP_TILE); 426 this.mPageSize = pageDimensions; 427 this.mTileInfo = tileInfo; 428 } 429 430 @Override getLogTag()431 protected String getLogTag() { 432 return "RenderTileTask"; 433 } 434 435 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)436 protected Bitmap doInBackground(PdfDocumentRemoteProto pdfDocument) throws RemoteException { 437 Point offset = mTileInfo.getOffset(); 438 return pdfDocument.getPdfDocumentRemote().renderTile( 439 mPageNum, 440 mTileInfo.getSize().getWidth(), 441 mTileInfo.getSize().getHeight(), 442 mPageSize.getWidth(), 443 mPageSize.getHeight(), 444 offset.x, 445 offset.y, 446 mHideTextAnnotations); 447 } 448 449 @Override doCallback(PdfLoaderCallbacks callbacks, Bitmap result)450 protected void doCallback(PdfLoaderCallbacks callbacks, Bitmap result) { 451 if (result != null) { 452 callbacks.setTileBitmap(mPageNum, mTileInfo, result); 453 } 454 } 455 456 @Override cleanup()457 protected void cleanup() { 458 mTileTasks.remove(mTileInfo.getIndex()); 459 } 460 461 @Override toString()462 public @NonNull String toString() { 463 return String.format("RenderTileTask(page=%d width=%d height=%d tile=%s)", 464 mPageNum, mPageSize.getWidth(), mPageSize.getHeight(), mTileInfo); 465 } 466 467 @Override equals(Object o)468 public boolean equals(Object o) { 469 if (o != null && o instanceof RenderTileTask) { 470 RenderTileTask that = (RenderTileTask) o; 471 return this.mPageSize.equals(that.mPageSize) && this.mTileInfo.equals( 472 that.mTileInfo); 473 } 474 return false; 475 } 476 477 @Override hashCode()478 public int hashCode() { 479 return mTileInfo.hashCode(); 480 } 481 } 482 483 /** AsyncTask for getting a page's text. */ 484 class GetPageTextTask extends AbstractPdfTask<String> { GetPageTextTask()485 GetPageTextTask() { 486 super(mParent, Priority.TEXT); 487 } 488 489 @Override getLogTag()490 protected String getLogTag() { 491 return "GetPageTextTask"; 492 } 493 494 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)495 protected String doInBackground(PdfDocumentRemoteProto pdfDocument) throws RemoteException { 496 if (TaskDenyList.sDisableAltText) { 497 return pdfDocument.getPdfDocumentRemote().getPageText(mPageNum); 498 } else { 499 StringBuilder sb = new StringBuilder(); 500 sb.append(pdfDocument.getPdfDocumentRemote().getPageText(mPageNum)); 501 sb.append("\r\n"); 502 for (String altText : pdfDocument.getPdfDocumentRemote().getPageAltText(mPageNum)) { 503 // Format the alt text appropriately, so that user knows it is alt text. 504 altText = mParent.mContext.getString(R.string.desc_image_alt_text, altText); 505 sb.append(altText).append("\r\n"); 506 } 507 return sb.toString(); 508 } 509 } 510 511 @Override doCallback(PdfLoaderCallbacks callbacks, String result)512 protected void doCallback(PdfLoaderCallbacks callbacks, String result) { 513 callbacks.setPageText(mPageNum, result); 514 } 515 516 @Override cleanup()517 protected void cleanup() { 518 mTextTask = null; 519 } 520 521 @Override toString()522 public @NonNull String toString() { 523 return String.format("GetPageTextTask(page=%d)", mPageNum); 524 } 525 } 526 527 /** AsyncTask for searching a page's text. */ 528 class SearchPageTextTask extends AbstractPdfTask<MatchRects> { 529 private final String mQuery; 530 SearchPageTextTask(String query)531 SearchPageTextTask(String query) { 532 super(mParent, Priority.SEARCH); 533 this.mQuery = query; 534 } 535 536 @Override getLogTag()537 protected String getLogTag() { 538 return "SearchPageTextTask"; 539 } 540 541 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)542 protected MatchRects doInBackground(PdfDocumentRemoteProto pdfDocument) 543 throws RemoteException { 544 return pdfDocument.getPdfDocumentRemote().searchPageText(mPageNum, mQuery); 545 } 546 547 @Override doCallback(PdfLoaderCallbacks callbacks, MatchRects matches)548 protected void doCallback(PdfLoaderCallbacks callbacks, MatchRects matches) { 549 callbacks.setSearchResults(mQuery, mPageNum, matches); 550 } 551 552 @Override cleanup()553 protected void cleanup() { 554 mSearchPageTextTask = null; 555 } 556 557 @Override toString()558 public @NonNull String toString() { 559 return String.format("SearchPageTextTask(page=%d, query=\"%s\")", mPageNum, mQuery); 560 } 561 } 562 563 /** AsyncTask for selecting some of a page's text. */ 564 class SelectionTask extends AbstractPdfTask<PageSelection> { 565 private final SelectionBoundary mStart; 566 private final SelectionBoundary mStop; 567 SelectionTask(SelectionBoundary start, SelectionBoundary stop)568 SelectionTask(SelectionBoundary start, SelectionBoundary stop) { 569 super(mParent, Priority.SELECT); 570 this.mStart = start; 571 this.mStop = stop; 572 } 573 574 @Override getLogTag()575 protected String getLogTag() { 576 return "SelectionTask"; 577 } 578 579 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)580 protected PageSelection doInBackground(PdfDocumentRemoteProto pdfDocument) 581 throws RemoteException { 582 return pdfDocument.getPdfDocumentRemote().selectPageText(mPageNum, mStart, mStop); 583 } 584 585 @Override doCallback(PdfLoaderCallbacks callbacks, PageSelection selection)586 protected void doCallback(PdfLoaderCallbacks callbacks, PageSelection selection) { 587 callbacks.setSelection(mPageNum, selection); 588 } 589 590 @Override cleanup()591 protected void cleanup() { 592 mSelectionTask = null; 593 } 594 595 @Override toString()596 public @NonNull String toString() { 597 return String.format("SelectionTask(page=%d, start=%s, stop=%s)", mPageNum, mStart, 598 mStop); 599 } 600 } 601 602 /** AsyncTask for getting a page's url links. */ 603 class GetPageLinksTask extends AbstractPdfTask<LinkRects> { GetPageLinksTask()604 GetPageLinksTask() { 605 super(mParent, Priority.LINKS); 606 } 607 608 @Override getLogTag()609 protected String getLogTag() { 610 return "GetPageLinksTask"; 611 } 612 613 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)614 protected LinkRects doInBackground(PdfDocumentRemoteProto pdfDocument) 615 throws RemoteException { 616 if (TaskDenyList.sDisableLinks) { 617 return LinkRects.NO_LINKS; 618 } else { 619 return pdfDocument.getPdfDocumentRemote().getPageLinks(mPageNum); 620 } 621 } 622 623 @Override doCallback(PdfLoaderCallbacks callbacks, LinkRects result)624 protected void doCallback(PdfLoaderCallbacks callbacks, LinkRects result) { 625 callbacks.setPageUrlLinks(mPageNum, result); 626 } 627 628 @Override cleanup()629 protected void cleanup() { 630 mLinksTask = null; 631 } 632 633 @Override toString()634 public @NonNull String toString() { 635 return String.format("GetPageLinksTask(page=%d)", mPageNum); 636 } 637 } 638 639 /** AsyncTask for getting a page's go-to links. */ 640 class GetPageGotoLinksTask extends AbstractPdfTask<List<GotoLink>> { GetPageGotoLinksTask()641 GetPageGotoLinksTask() { 642 // Go-to links are a subset of links so we will follow all link settings. 643 super(mParent, Priority.LINKS); 644 } 645 646 @Override getLogTag()647 protected String getLogTag() { 648 return "GetPageGotoLinksTask"; 649 } 650 651 @Override doInBackground(PdfDocumentRemoteProto pdfDocument)652 protected List<GotoLink> doInBackground(PdfDocumentRemoteProto pdfDocument) 653 throws RemoteException { 654 if (TaskDenyList.sDisableLinks) { 655 return Collections.emptyList(); 656 } else { 657 return pdfDocument.getPdfDocumentRemote().getPageGotoLinks(mPageNum); 658 } 659 } 660 661 @Override doCallback(PdfLoaderCallbacks callbacks, List<GotoLink> links)662 protected void doCallback(PdfLoaderCallbacks callbacks, List<GotoLink> links) { 663 callbacks.setPageGotoLinks(mPageNum, links); 664 } 665 666 @Override cleanup()667 protected void cleanup() { 668 mGotoLinksTask = null; 669 } 670 671 @Override toString()672 public @NonNull String toString() { 673 return String.format("GetPageGotoLinksTask(page=%d)", mPageNum); 674 } 675 } 676 } 677