Repository: xb2016/EhViewer-NekoInverter Branch: master Commit: 8b8607e6b4b8 Files: 615 Total size: 2.8 MB Directory structure: gitextract_k4e444b1/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ └── config.yml │ └── workflows/ │ ├── ci.yml │ └── releases.yml ├── .gitignore ├── LICENSE ├── NOTICE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── keystore/ │ │ └── androidkey.jks │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── com.hippo.network.CookiesDatabase/ │ │ ├── 1.json │ │ └── 2.json │ └── src/ │ ├── debug/ │ │ └── res/ │ │ └── values/ │ │ └── strings.xml │ └── main/ │ ├── AndroidManifest.xml │ ├── cpp/ │ │ ├── 0001-Insert-link-libs.patch │ │ ├── 0002-Fix-zip_time-performance.patch │ │ ├── 0003-Use-UTF-8-as-default-charset-on-bionic.patch │ │ ├── CMakeLists.txt │ │ ├── archive.c │ │ ├── ehviewer.h │ │ ├── gifutils.c │ │ ├── hash.c │ │ ├── image.c │ │ ├── natsort/ │ │ │ ├── strnatcmp.c │ │ │ └── strnatcmp.h │ │ └── nettle/ │ │ ├── .gitignore │ │ ├── CMakeLists.txt │ │ ├── config.h │ │ ├── keymap.h │ │ ├── rotors.h │ │ └── version.h │ ├── java/ │ │ ├── com/ │ │ │ └── hippo/ │ │ │ ├── app/ │ │ │ │ ├── CheckBoxDialogBuilder.kt │ │ │ │ ├── EditTextCheckBoxDialogBuilder.kt │ │ │ │ ├── EditTextDialogBuilder.kt │ │ │ │ └── ListCheckBoxDialogBuilder.kt │ │ │ ├── database/ │ │ │ │ ├── MSQLiteBuilder.kt │ │ │ │ └── MSQLiteOpenHelper.kt │ │ │ ├── drawable/ │ │ │ │ ├── AddDeleteDrawable.kt │ │ │ │ ├── BatteryDrawable.kt │ │ │ │ ├── DrawerArrowDrawable.kt │ │ │ │ ├── PreciselyClipDrawable.kt │ │ │ │ ├── TriangleDrawable.kt │ │ │ │ ├── UnikeryDrawable.kt │ │ │ │ └── WrapDrawable.kt │ │ │ ├── easyrecyclerview/ │ │ │ │ ├── EasyRecyclerView.kt │ │ │ │ ├── FastScroller.kt │ │ │ │ ├── HandlerDrawable.kt │ │ │ │ ├── LayoutManagerUtils.kt │ │ │ │ ├── LinearDividerItemDecoration.kt │ │ │ │ ├── MarginItemDecoration.kt │ │ │ │ ├── SimpleHolder.kt │ │ │ │ └── SimpleSmoothScroller.kt │ │ │ ├── ehviewer/ │ │ │ │ ├── AppConfig.java │ │ │ │ ├── Crash.kt │ │ │ │ ├── EhApplication.kt │ │ │ │ ├── EhDB.kt │ │ │ │ ├── EhProxySelector.kt │ │ │ │ ├── FavouriteStatusRouter.java │ │ │ │ ├── GetText.kt │ │ │ │ ├── Settings.kt │ │ │ │ ├── UrlOpener.kt │ │ │ │ ├── WindowInsetsAnimationHelper.java │ │ │ │ ├── client/ │ │ │ │ │ ├── EhCacheKeyFactory.kt │ │ │ │ │ ├── EhClient.kt │ │ │ │ │ ├── EhCookieStore.kt │ │ │ │ │ ├── EhEngine.kt │ │ │ │ │ ├── EhFilter.kt │ │ │ │ │ ├── EhRequest.kt │ │ │ │ │ ├── EhRequestBuilder.kt │ │ │ │ │ ├── EhTagDatabase.kt │ │ │ │ │ ├── EhUrl.kt │ │ │ │ │ ├── EhUrlOpener.kt │ │ │ │ │ ├── EhUtils.kt │ │ │ │ │ ├── data/ │ │ │ │ │ │ ├── AbstractGalleryInfo.kt │ │ │ │ │ │ ├── BaseGalleryInfo.kt │ │ │ │ │ │ ├── FavListUrlBuilder.kt │ │ │ │ │ │ ├── GalleryComment.kt │ │ │ │ │ │ ├── GalleryCommentList.kt │ │ │ │ │ │ ├── GalleryDetail.kt │ │ │ │ │ │ ├── GalleryInfo.kt │ │ │ │ │ │ ├── GalleryPreview.kt │ │ │ │ │ │ ├── GalleryTagGroup.kt │ │ │ │ │ │ ├── LargePreviewSet.kt │ │ │ │ │ │ ├── ListUrlBuilder.kt │ │ │ │ │ │ ├── NormalPreviewSet.kt │ │ │ │ │ │ └── PreviewSet.kt │ │ │ │ │ ├── exception/ │ │ │ │ │ │ ├── CloudflareBypassException.kt │ │ │ │ │ │ ├── EhException.kt │ │ │ │ │ │ ├── InsufficientFundsException.kt │ │ │ │ │ │ ├── NoHAtHClientException.kt │ │ │ │ │ │ ├── NotLoggedInException.kt │ │ │ │ │ │ ├── OffensiveException.kt │ │ │ │ │ │ ├── ParseException.kt │ │ │ │ │ │ ├── PiningException.kt │ │ │ │ │ │ └── QuotaExceededException.kt │ │ │ │ │ └── parser/ │ │ │ │ │ ├── ArchiveParser.kt │ │ │ │ │ ├── EventPaneParser.kt │ │ │ │ │ ├── FavoritesParser.kt │ │ │ │ │ ├── ForumsParser.kt │ │ │ │ │ ├── GalleryApiParser.kt │ │ │ │ │ ├── GalleryDetailParser.kt │ │ │ │ │ ├── GalleryDetailUrlParser.kt │ │ │ │ │ ├── GalleryListParser.kt │ │ │ │ │ ├── GalleryListUrlParser.kt │ │ │ │ │ ├── GalleryMultiPageViewerParser.kt │ │ │ │ │ ├── GalleryNotAvailableParser.kt │ │ │ │ │ ├── GalleryPageApiParser.kt │ │ │ │ │ ├── GalleryPageParser.kt │ │ │ │ │ ├── GalleryPageUrlParser.kt │ │ │ │ │ ├── GalleryTokenApiParser.kt │ │ │ │ │ ├── HomeParser.kt │ │ │ │ │ ├── ParserUtils.kt │ │ │ │ │ ├── ProfileParser.kt │ │ │ │ │ ├── RateGalleryParser.kt │ │ │ │ │ ├── SignInParser.kt │ │ │ │ │ ├── TorrentParser.kt │ │ │ │ │ ├── UserConfigParser.kt │ │ │ │ │ ├── VoteCommentParser.kt │ │ │ │ │ └── VoteTagParser.kt │ │ │ │ ├── coil/ │ │ │ │ │ ├── DiskCache.kt │ │ │ │ │ ├── DownloadThumbInterceptor.kt │ │ │ │ │ ├── LockPool.kt │ │ │ │ │ ├── MergeInterceptor.kt │ │ │ │ │ └── NamedMutex.kt │ │ │ │ ├── dao/ │ │ │ │ │ ├── BasicDao.kt │ │ │ │ │ ├── BookmarkInfo.kt │ │ │ │ │ ├── BookmarksDao.kt │ │ │ │ │ ├── DownloadDirname.kt │ │ │ │ │ ├── DownloadDirnameDao.kt │ │ │ │ │ ├── DownloadInfo.kt │ │ │ │ │ ├── DownloadLabel.kt │ │ │ │ │ ├── DownloadLabelDao.kt │ │ │ │ │ ├── DownloadsDao.kt │ │ │ │ │ ├── EhDatabase.kt │ │ │ │ │ ├── Filter.kt │ │ │ │ │ ├── FilterDao.kt │ │ │ │ │ ├── HistoryDao.kt │ │ │ │ │ ├── HistoryInfo.kt │ │ │ │ │ ├── LocalFavoriteInfo.kt │ │ │ │ │ ├── LocalFavoritesDao.kt │ │ │ │ │ ├── QuickSearch.kt │ │ │ │ │ └── QuickSearchDao.kt │ │ │ │ ├── download/ │ │ │ │ │ ├── DownloadManager.kt │ │ │ │ │ └── DownloadService.kt │ │ │ │ ├── gallery/ │ │ │ │ │ ├── ArchiveGalleryProvider.kt │ │ │ │ │ ├── EhGalleryProvider.kt │ │ │ │ │ └── GalleryProvider2.kt │ │ │ │ ├── jni/ │ │ │ │ │ ├── Archive.kt │ │ │ │ │ ├── GifUtils.kt │ │ │ │ │ ├── Hash.kt │ │ │ │ │ └── Image.kt │ │ │ │ ├── preference/ │ │ │ │ │ ├── AccountPreference.kt │ │ │ │ │ ├── CleanRedundancyPreference.kt │ │ │ │ │ ├── ClearSearchHistoryPreference.kt │ │ │ │ │ ├── ImageLimitsPreference.kt │ │ │ │ │ ├── ProxyPreference.kt │ │ │ │ │ ├── RestoreDownloadPreference.kt │ │ │ │ │ ├── TaskPreference.kt │ │ │ │ │ ├── UserAgentPreference.kt │ │ │ │ │ └── VersionPreference.kt │ │ │ │ ├── shortcuts/ │ │ │ │ │ └── ShortcutsActivity.kt │ │ │ │ ├── spider/ │ │ │ │ │ ├── DownloadInfoMagics.kt │ │ │ │ │ ├── SpiderDen.kt │ │ │ │ │ ├── SpiderInfo.kt │ │ │ │ │ └── SpiderQueen.kt │ │ │ │ ├── ui/ │ │ │ │ │ ├── CommonOperations.kt │ │ │ │ │ ├── EhActivity.kt │ │ │ │ │ ├── GalleryActivity.kt │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ ├── SettingsActivity.kt │ │ │ │ │ ├── WebViewActivity.kt │ │ │ │ │ ├── dialog/ │ │ │ │ │ │ └── SelectItemWithIconAdapter.kt │ │ │ │ │ ├── fragment/ │ │ │ │ │ │ ├── AboutFragment.kt │ │ │ │ │ │ ├── AdvancedFragment.kt │ │ │ │ │ │ ├── BaseFragment.kt │ │ │ │ │ │ ├── BasePreferenceFragment.kt │ │ │ │ │ │ ├── DownloadFragment.kt │ │ │ │ │ │ ├── EhFragment.kt │ │ │ │ │ │ ├── FilterFragment.kt │ │ │ │ │ │ ├── MyTagsFragment.kt │ │ │ │ │ │ ├── PrivacyFragment.kt │ │ │ │ │ │ ├── ReadFragment.kt │ │ │ │ │ │ ├── SetSecurityFragment.kt │ │ │ │ │ │ ├── SettingsFragment.kt │ │ │ │ │ │ └── UConfigFragment.kt │ │ │ │ │ └── scene/ │ │ │ │ │ ├── BaseScene.kt │ │ │ │ │ ├── CookieSignInScene.kt │ │ │ │ │ ├── DownloadsScene.kt │ │ │ │ │ ├── EhCallback.kt │ │ │ │ │ ├── EnterGalleryDetailTransaction.kt │ │ │ │ │ ├── FavoritesScene.kt │ │ │ │ │ ├── GalleryAdapter.kt │ │ │ │ │ ├── GalleryCommentsScene.kt │ │ │ │ │ ├── GalleryDetailScene.kt │ │ │ │ │ ├── GalleryHolder.kt │ │ │ │ │ ├── GalleryInfoScene.kt │ │ │ │ │ ├── GalleryListScene.kt │ │ │ │ │ ├── GalleryPreviewsScene.kt │ │ │ │ │ ├── HistoryScene.kt │ │ │ │ │ ├── ProgressScene.kt │ │ │ │ │ ├── SecurityScene.kt │ │ │ │ │ ├── SelectSiteScene.kt │ │ │ │ │ ├── SignInScene.kt │ │ │ │ │ ├── SolidScene.kt │ │ │ │ │ ├── ToolbarScene.kt │ │ │ │ │ ├── TransitionNameFactory.kt │ │ │ │ │ └── WebViewSignInScene.kt │ │ │ │ ├── util/ │ │ │ │ │ └── WebViewExtensions.kt │ │ │ │ └── widget/ │ │ │ │ ├── AdvanceSearchTable.kt │ │ │ │ ├── CategoryTable.kt │ │ │ │ ├── DialogWebChromeClient.java │ │ │ │ ├── EhStageLayout.kt │ │ │ │ ├── FixedThumb.kt │ │ │ │ ├── GalleryGuideView.java │ │ │ │ ├── GalleryHeader.java │ │ │ │ ├── GalleryInfoContentHelper.kt │ │ │ │ ├── GalleryRatingBar.java │ │ │ │ ├── ImageSearchLayout.kt │ │ │ │ ├── ResizeableFixedThumb.kt │ │ │ │ ├── ReversibleSeekBar.java │ │ │ │ ├── SearchBar.kt │ │ │ │ ├── SearchDatabase.java │ │ │ │ ├── SearchEditText.java │ │ │ │ ├── SearchLayout.kt │ │ │ │ ├── SeekBarPanel.kt │ │ │ │ ├── SimpleRatingView.java │ │ │ │ └── TileThumb.java │ │ │ ├── glgallery/ │ │ │ │ ├── DownUpDetector.java │ │ │ │ ├── Fling.java │ │ │ │ ├── GalleryPageView.java │ │ │ │ ├── GalleryProvider.kt │ │ │ │ ├── GalleryView.java │ │ │ │ ├── GestureRecognizer.java │ │ │ │ ├── ImageView.java │ │ │ │ ├── PagerLayoutManager.java │ │ │ │ ├── ScrollLayoutManager.java │ │ │ │ └── SimpleAdapter.java │ │ │ ├── glview/ │ │ │ │ ├── anim/ │ │ │ │ │ ├── AlphaAnimation.java │ │ │ │ │ ├── Animation.java │ │ │ │ │ ├── CanvasAnimation.java │ │ │ │ │ └── FloatAnimation.java │ │ │ │ ├── glrenderer/ │ │ │ │ │ ├── BasicTexture.java │ │ │ │ │ ├── CanvasTexture.java │ │ │ │ │ ├── GLCanvas.java │ │ │ │ │ ├── GLES11Canvas.java │ │ │ │ │ ├── GLES11IdImpl.java │ │ │ │ │ ├── GLES20Canvas.java │ │ │ │ │ ├── GLES20IdImpl.java │ │ │ │ │ ├── GLId.java │ │ │ │ │ ├── GLPaint.java │ │ │ │ │ ├── MovableTextTexture.java │ │ │ │ │ ├── NativeTexture.java │ │ │ │ │ ├── RawTexture.java │ │ │ │ │ ├── SpriteTexture.java │ │ │ │ │ ├── StringTexture.java │ │ │ │ │ ├── Texture.java │ │ │ │ │ ├── TiledTexture.java │ │ │ │ │ └── UploadedTexture.java │ │ │ │ ├── image/ │ │ │ │ │ ├── GLImageMovableTextView.java │ │ │ │ │ ├── ImageMovableTextTexture.java │ │ │ │ │ ├── ImageSpriteTexture.java │ │ │ │ │ ├── ImageTexture.java │ │ │ │ │ └── ImageWrapper.java │ │ │ │ ├── util/ │ │ │ │ │ └── GalleryUtils.java │ │ │ │ ├── view/ │ │ │ │ │ ├── AnimationTime.java │ │ │ │ │ ├── GLRoot.java │ │ │ │ │ ├── GLRootView.java │ │ │ │ │ ├── GLView.java │ │ │ │ │ ├── Gravity.java │ │ │ │ │ ├── OrientationSource.java │ │ │ │ │ ├── TouchHelper.java │ │ │ │ │ └── TouchOwner.java │ │ │ │ └── widget/ │ │ │ │ ├── GLFrameLayout.java │ │ │ │ ├── GLLinearLayout.java │ │ │ │ ├── GLProgressView.java │ │ │ │ └── GLTextureView.java │ │ │ ├── image/ │ │ │ │ └── Image.kt │ │ │ ├── network/ │ │ │ │ ├── CookieDatabase.kt │ │ │ │ ├── CookieSet.kt │ │ │ │ ├── InetValidator.kt │ │ │ │ ├── StatusCodeException.kt │ │ │ │ └── UrlBuilder.kt │ │ │ ├── okhttp/ │ │ │ │ └── ChromeRequestBuilder.kt │ │ │ ├── preference/ │ │ │ │ ├── DialogPreference.kt │ │ │ │ └── UrlPreference.kt │ │ │ ├── scene/ │ │ │ │ ├── Announcer.kt │ │ │ │ ├── SceneApplication.kt │ │ │ │ ├── SceneFragment.kt │ │ │ │ ├── StageActivity.kt │ │ │ │ ├── StageLayout.kt │ │ │ │ └── TransitionHelper.kt │ │ │ ├── text/ │ │ │ │ └── URLImageGetter.kt │ │ │ ├── unifile/ │ │ │ │ ├── Contracts.kt │ │ │ │ ├── DocumentsContractApi19.kt │ │ │ │ ├── DocumentsContractApi21.kt │ │ │ │ ├── FilenameFilter.kt │ │ │ │ ├── MediaContract.kt │ │ │ │ ├── MediaFile.kt │ │ │ │ ├── RawFile.kt │ │ │ │ ├── SingleDocumentFile.kt │ │ │ │ ├── TreeDocumentFile.kt │ │ │ │ ├── UniFile.kt │ │ │ │ ├── UniFileExtensions.kt │ │ │ │ ├── UriHandler.kt │ │ │ │ └── Utils.kt │ │ │ ├── util/ │ │ │ │ ├── AppHelper.kt │ │ │ │ ├── BBCode.kt │ │ │ │ ├── ClipboardUtil.kt │ │ │ │ ├── CoroutinesExtensions.kt │ │ │ │ ├── DateTimeUtil.kt │ │ │ │ ├── ExceptionUtils.kt │ │ │ │ ├── FDUtils.kt │ │ │ │ ├── HtmlCompat.kt │ │ │ │ ├── JsoupUtils.java │ │ │ │ ├── LogCat.java │ │ │ │ ├── ParcelableCompat.kt │ │ │ │ ├── ReadableTime.kt │ │ │ │ ├── SDKUtils.kt │ │ │ │ ├── SqlUtils.java │ │ │ │ ├── TextUrl.java │ │ │ │ └── URLEncoderCompat.kt │ │ │ ├── view/ │ │ │ │ ├── BringOutTransition.java │ │ │ │ └── ViewTransition.java │ │ │ ├── widget/ │ │ │ │ ├── AutoWrapLayout.java │ │ │ │ ├── BatteryView.kt │ │ │ │ ├── CheckTextView.kt │ │ │ │ ├── ColorView.java │ │ │ │ ├── ContentLayout.kt │ │ │ │ ├── CuteSpinner.java │ │ │ │ ├── DateUtils.java │ │ │ │ ├── DrawerView.java │ │ │ │ ├── FabLayout.kt │ │ │ │ ├── FixedAspectImageView.kt │ │ │ │ ├── IgnoreFitsSystemWindowsFullyDraggableDrawerContentLayout.kt │ │ │ │ ├── IndicatingListView.java │ │ │ │ ├── LinkifyTextView.java │ │ │ │ ├── LoadImageView.kt │ │ │ │ ├── MaxSizeContainer.java │ │ │ │ ├── ObservedTextView.java │ │ │ │ ├── RadioGridGroup.java │ │ │ │ ├── SearchBarMover.java │ │ │ │ ├── SimpleGridAutoSpanLayout.java │ │ │ │ ├── SimpleGridLayout.java │ │ │ │ ├── Slider.java │ │ │ │ ├── TextClock.java │ │ │ │ ├── lockpattern/ │ │ │ │ │ ├── LockPatternUtils.java │ │ │ │ │ └── LockPatternView.java │ │ │ │ └── recyclerview/ │ │ │ │ ├── AutoGridLayoutManager.java │ │ │ │ └── AutoStaggeredGridLayoutManager.java │ │ │ └── yorozuya/ │ │ │ ├── AnimationUtils.java │ │ │ ├── AssertError.java │ │ │ ├── AssertException.java │ │ │ ├── AssertUtils.java │ │ │ ├── ConcurrentPool.java │ │ │ ├── FileUtils.java │ │ │ ├── IOUtils.java │ │ │ ├── IOUtils.kt │ │ │ ├── IntIdGenerator.java │ │ │ ├── LayoutUtils.java │ │ │ ├── MathUtils.java │ │ │ ├── NumberUtils.java │ │ │ ├── OSUtils.java │ │ │ ├── ObjectUtils.java │ │ │ ├── Pool.java │ │ │ ├── ResourcesUtils.java │ │ │ ├── SimpleAnimatorListener.java │ │ │ ├── SimpleHandler.java │ │ │ ├── StringUtils.java │ │ │ ├── StringUtils.kt │ │ │ ├── Utilities.java │ │ │ ├── ViewUtils.java │ │ │ ├── collect/ │ │ │ │ ├── IntList.kt │ │ │ │ └── LongList.kt │ │ │ └── thread/ │ │ │ ├── InfiniteThreadExecutor.java │ │ │ ├── PriorityThread.java │ │ │ └── PriorityThreadFactory.java │ │ └── eu/ │ │ └── kanade/ │ │ └── tachiyomi/ │ │ └── network/ │ │ └── interceptor/ │ │ ├── CloudflareInterceptor.kt │ │ └── WebViewInterceptor.kt │ └── res/ │ ├── anim/ │ │ ├── accelerate_quart.xml │ │ ├── decelerate_quart.xml │ │ ├── decelerate_quint.xml │ │ ├── scene_close_exit.xml │ │ ├── scene_open_enter.xml │ │ ├── scene_open_enter_horizontal.xml │ │ └── scene_open_exit.xml │ ├── color/ │ │ ├── content_reactive.xml │ │ ├── content_reactive_black.xml │ │ └── primary_text_material_black.xml │ ├── drawable/ │ │ ├── big_download.xml │ │ ├── big_filter.xml │ │ ├── big_history.xml │ │ ├── big_sad_pandroid.xml │ │ ├── category_background.xml │ │ ├── check_text_view_foreground.xml │ │ ├── default_avatar.xml │ │ ├── divider_gallery_detail.xml │ │ ├── divider_gallery_detail_dark.xml │ │ ├── ic_baseline_dark_mode_24.xml │ │ ├── ic_baseline_format_list_numbered_24.xml │ │ ├── ic_baseline_menu_24.xml │ │ ├── ic_baseline_reorder_24.xml │ │ ├── ic_baseline_warning_24.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_monochrome.xml │ │ ├── ic_pause_108dp.xml │ │ ├── ic_play_arrow_108dp.xml │ │ ├── image_failed.xml │ │ ├── round_side_rect.xml │ │ ├── spacer_keyline.xml │ │ ├── spacer_x6.xml │ │ ├── tile_background.xml │ │ ├── v_adb_primary_x24.xml │ │ ├── v_archive_primary_x48.xml │ │ ├── v_arrow_left_dark_x24.xml │ │ ├── v_book_open_primary_x24.xml │ │ ├── v_book_open_x24.xml │ │ ├── v_check_all_dark_x24.xml │ │ ├── v_check_dark_x24.xml │ │ ├── v_clear_all_dark_x24.xml │ │ ├── v_close_dark_x24.xml │ │ ├── v_cookie_brown_x48.xml │ │ ├── v_delete_x24.xml │ │ ├── v_dots_vertical_secondary_dark_x24.xml │ │ ├── v_download_box_dark_x24.xml │ │ ├── v_download_primary_x24.xml │ │ ├── v_download_x16.xml │ │ ├── v_download_x24.xml │ │ ├── v_eh_subscription_black_x24.xml │ │ ├── v_filter_dark_x24.xml │ │ ├── v_fire_black_x24.xml │ │ ├── v_folder_move_x24.xml │ │ ├── v_go_to_dark_x24.xml │ │ ├── v_heart_box_dark_x24.xml │ │ ├── v_heart_broken_x24.xml │ │ ├── v_heart_outline_primary_x48.xml │ │ ├── v_heart_primary_x48.xml │ │ ├── v_heart_x16.xml │ │ ├── v_heart_x24.xml │ │ ├── v_help_circle_x24.xml │ │ ├── v_history_black_x24.xml │ │ ├── v_homepage_black_x24.xml │ │ ├── v_info_outline_dark_x24.xml │ │ ├── v_info_primary_x24.xml │ │ ├── v_last_page_x24.xml │ │ ├── v_magnify_x24.xml │ │ ├── v_pause_x24.xml │ │ ├── v_pencil_dark_x24.xml │ │ ├── v_pin_top_24.xml │ │ ├── v_play_x24.xml │ │ ├── v_plus_dark_x24.xml │ │ ├── v_refresh_dark_x24.xml │ │ ├── v_reply_dark_x24.xml │ │ ├── v_sad_panda_primary_x24.xml │ │ ├── v_sec_primary_x24.xml │ │ ├── v_send_dark_x24.xml │ │ ├── v_settings_black_x24.xml │ │ ├── v_share_primary_x48.xml │ │ ├── v_similar_primary_x48.xml │ │ ├── v_slider_bubble.xml │ │ ├── v_star_half_x16.xml │ │ ├── v_star_outline_x16.xml │ │ ├── v_star_x16.xml │ │ └── v_utorrent_primary_x48.xml │ ├── drawable-v25/ │ │ ├── ic_shortcut_start.xml │ │ └── ic_shortcut_stop.xml │ ├── drawable-v26/ │ │ ├── ic_shortcut_start.xml │ │ └── ic_shortcut_stop.xml │ ├── layout/ │ │ ├── activity_filter.xml │ │ ├── activity_gallery.xml │ │ ├── activity_main.xml │ │ ├── activity_preference.xml │ │ ├── activity_set_security.xml │ │ ├── activity_webview.xml │ │ ├── dialog_add_filter.xml │ │ ├── dialog_archive_list.xml │ │ ├── dialog_checkbox_builder.xml │ │ ├── dialog_edittext_builder.xml │ │ ├── dialog_edittextcheckbox_builder.xml │ │ ├── dialog_gallery_menu.xml │ │ ├── dialog_go_to.xml │ │ ├── dialog_item_select_with_icon.xml │ │ ├── dialog_js_prompt.xml │ │ ├── dialog_list_checkbox_builder.xml │ │ ├── dialog_rate.xml │ │ ├── dialog_recycler_view.xml │ │ ├── dialog_torrent_list.xml │ │ ├── drawer_list_rv.xml │ │ ├── gallery_detail_actions.xml │ │ ├── gallery_detail_comments.xml │ │ ├── gallery_detail_content.xml │ │ ├── gallery_detail_header.xml │ │ ├── gallery_detail_info.xml │ │ ├── gallery_detail_previews.xml │ │ ├── gallery_detail_tags.xml │ │ ├── gallery_tag_group.xml │ │ ├── item_cute_spinner_item.xml │ │ ├── item_download.xml │ │ ├── item_drawer_favorites.xml │ │ ├── item_drawer_list.xml │ │ ├── item_filter.xml │ │ ├── item_filter_header.xml │ │ ├── item_gallery_comment.xml │ │ ├── item_gallery_comment_more.xml │ │ ├── item_gallery_comment_progress.xml │ │ ├── item_gallery_grid.xml │ │ ├── item_gallery_info_data.xml │ │ ├── item_gallery_info_header.xml │ │ ├── item_gallery_list.xml │ │ ├── item_gallery_list_thumb_height.xml │ │ ├── item_gallery_preview.xml │ │ ├── item_gallery_tag.xml │ │ ├── item_history.xml │ │ ├── item_hosts.xml │ │ ├── item_select_dialog.xml │ │ ├── item_simple_list_2.xml │ │ ├── nav_header_main.xml │ │ ├── preference_dialog_proxy.xml │ │ ├── preference_dialog_task.xml │ │ ├── preference_recyclerview.xml │ │ ├── scene_cookie_sign_in.xml │ │ ├── scene_download.xml │ │ ├── scene_favorites.xml │ │ ├── scene_gallery_comments.xml │ │ ├── scene_gallery_detail.xml │ │ ├── scene_gallery_info.xml │ │ ├── scene_gallery_list.xml │ │ ├── scene_gallery_previews.xml │ │ ├── scene_history.xml │ │ ├── scene_login.xml │ │ ├── scene_progress.xml │ │ ├── scene_security.xml │ │ ├── scene_select_site.xml │ │ ├── scene_toolbar.xml │ │ ├── search_action.xml │ │ ├── search_advance.xml │ │ ├── search_category.xml │ │ ├── search_image.xml │ │ ├── search_normal.xml │ │ ├── widget_advance_search_table.xml │ │ ├── widget_category_table.xml │ │ ├── widget_content_layout.xml │ │ ├── widget_gallery_guide_1.xml │ │ ├── widget_gallery_guide_2.xml │ │ ├── widget_image_search.xml │ │ └── widget_search_bar.xml │ ├── layout-land/ │ │ └── activity_set_security.xml │ ├── menu/ │ │ ├── activity_filter.xml │ │ ├── activity_u_config.xml │ │ ├── context_comment.xml │ │ ├── download_label_option.xml │ │ ├── drawer_download.xml │ │ ├── drawer_favorites.xml │ │ ├── drawer_gallery_list.xml │ │ ├── nav_drawer_main.xml │ │ ├── quicksearch_option.xml │ │ ├── scene_download.xml │ │ ├── scene_gallery_detail.xml │ │ ├── scene_gallery_previews.xml │ │ └── scene_history.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── raw/ │ │ └── isrgrootx1 │ ├── transition/ │ │ ├── trans_fade.xml │ │ └── trans_move.xml │ ├── values/ │ │ ├── arrays.xml │ │ ├── attrs.xml │ │ ├── bools.xml │ │ ├── color_launcher.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── drawables.xml │ │ ├── ids.xml │ │ ├── pathdata.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ ├── themes.xml │ │ └── themes_override.xml │ ├── values-ja/ │ │ └── strings.xml │ ├── values-night/ │ │ ├── color_launcher.xml │ │ ├── colors.xml │ │ └── styles.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-sw720dp-land/ │ │ └── dimens.xml │ ├── values-v24/ │ │ └── arrays.xml │ ├── values-zh-rCN/ │ │ ├── bools.xml │ │ └── strings.xml │ ├── values-zh-rHK/ │ │ ├── bools.xml │ │ └── strings.xml │ ├── values-zh-rTW/ │ │ ├── bools.xml │ │ └── strings.xml │ ├── xml/ │ │ ├── about_settings.xml │ │ ├── advanced_settings.xml │ │ ├── backup_scheme.xml │ │ ├── data_extraction_rules.xml │ │ ├── download_settings.xml │ │ ├── eh_settings.xml │ │ ├── filepaths.xml │ │ ├── locale_config.xml │ │ ├── privacy_settings.xml │ │ ├── read_settings.xml │ │ └── settings_headers.xml │ └── xml-v25/ │ └── shortcuts.xml ├── build.gradle.kts ├── docs/ │ ├── CHANGELOG/ │ │ └── zh-cn.md │ └── README/ │ └── zh-cn.md ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*.{kt,kts}] ij_kotlin_imports_layout = * ij_kotlin_line_break_after_multiline_when_entry = false ktlint_code_style = intellij_idea ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_class-signature = disabled ktlint_standard_mixed-condition-operators = disabled ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: Bug 反馈 / Bug report description: 提交一个问题报告 / Create a bug report labels: - "bug" body: - type: markdown attributes: value: | 感谢您愿意为 Ehviewer-NekoInverter 做出贡献! 提交问题报告前,还请首先完成文末的自查步骤 Thanks for your contribution to the Ehviewer-NekoInverter Project! Please complete the self-review steps at the end of the article before submitting the bug report - type: textarea id: reproduce attributes: label: 复现步骤 / Steps to reproduce description: | 在此处写下复现的方式,请详细描述每一个步骤,包括画廊链接、相关设置等 Describe how to reproduce here, please describe each step in detail, include gallery links or settings placeholder: | 1. 2. 3. validations: required: true - type: textarea id : expected attributes: label: 预期行为 / Expected behaviour description: | 在此处说明正常情况下应用的预期行为 Describe what should be happened here placeholder: | 它应该 ... It should be ... validations: required: true - type: textarea id: actual attributes: label: 实际行为 / Actual behaviour description: | 在此处描绘应用的实际行为,最好附上截图或录屏 Describe what actually happened here, screenshots or screen recordings are better placeholder: | 实际上它 ... Actually it ... [截图或录屏] / [Screenshots or screen recordings] validations: required: true - type: textarea id: log attributes: label: 应用日志 / App logs description: | 您可以通过设置-高级-导出日志 来获得日志文件,请确保日志完整,过长的日志请以文件形式上传 You can get logs file in Settings - Advanced - Dump logcat placeholder: 06-15 17:44:53.704 23382 23382 E ... validations: required: true - type: textarea id: more attributes: label: 备注 / Additional details description: | 在此处写下其他您想说的内容 Describe additional details here placeholder: | 其他有用的信息与附件 Additional details and attachments validations: required: false - type: input id: site attributes: label: 浏览站点 / Browsing site description: E-Hentai / ExHentai placeholder: E-Hentai validations: required: true - type: input id: version attributes: label: EhViewer 版本号 / EhViewer version code description: | 您可以在设置 - 关于处找到版本号 You can get version code in Settings - About placeholder: 1.7.28 validations: required: true - type: input id: ci attributes: label: EhViewer CI 版本 / EhViewer CI version description: | 请确保您已经使用 [最新 CI 版本](https://github.com/EhViewer-NekoInverter/EhViewer/actions/workflows/ci.yml) 测试,请填入您使用的 CI 版本网址 Please make sure you have tested with the [latest CI version](https://github.com/EhViewer-NekoInverter/EhViewer/actions/workflows/ci.yml), simply drop GitHub Action CI download page url here placeholder: https://github.com/EhViewer-NekoInverter/EhViewer/actions/runs/XXXXXXXXXX validations: required: true - type: input id: system attributes: label: Android 系统版本 / Android version description: Android 分支名称 + 版本号 / AOSP fork name + version code placeholder: MIUI 12.5, ArrowOS 12.1 validations: required: true - type: input id: device attributes: label: 设备型号 / Device model description: 在此填入设备型号 / Put device model here placeholder: OnePlus 7 Pro, Xiaomi 12 Ultra validations: required: true - type: input id: SoC attributes: label: SoC 型号 / SoC model description: 在此填入 SoC 型号 / Put SoC model here placeholder: 骁龙 8+ Gen 1, Snapdragon 8+ Gen 1 validations: required: true - type: checkboxes id: check attributes: label: 自查步骤 / Self-review steps description: | 请确保您已经遵守以下所有必选项,否则 issue 会被立即关闭 Please ensure you have obtained all needed options, otherwise the issue will be closed immediately options: - label: 如果您有足够的时间和能力,并愿意为修复此问题提交 PR ,请勾上此复选框 / Pull request is welcome. Check this if you want to start a pull request required: false - label: 您已仔细查看并知情 [Q&A](https://github.com/EhViewer-NekoInverter/EhViewer/issues/18) 中的内容 / You have checked [Q&A](https://github.com/EhViewer-NekoInverter/EhViewer/issues/18) carefully required: true - label: 您已搜索过 [Issue Tracker](https://github.com/EhViewer-NekoInverter/EhViewer/issues),没有找到类似的问题 / I have searched on [Issue Tracker](https://github.com/EhViewer-NekoInverter/EhViewer/issues), No duplicate or related open issue has been found required: true - label: 您确保这个 Issue 只提及一个问题。如果您有多个问题报告,烦请发起多个 Issue / Ensure there is only one bug report in this issue. Please make mutiply issue for mutiply bugs required: true - label: 您确保已使用 [最新 CI 版本](https://github.com/EhViewer-NekoInverter/EhViewer/actions/workflows/ci.yml) 测试,并且该问题在最新 CI 版本中并未解决 / This bug have not solved in [latest CI version](https://github.com/EhViewer-NekoInverter/EhViewer/actions/workflows/ci.yml) required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 所有其他问题 / All other questions url: https://www.google.com/ about: 咨询谷歌 / Ask Google for help ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - '*' pull_request: workflow_dispatch: jobs: linux: name: Build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Spotless Check run: ./gradlew spotlessCheck - name: Gradle Build run: ./gradlew assembleRelease - name: Upload Universal uses: actions/upload-artifact@v4 with: name: universal-${{ github.sha }} path: app/build/outputs/apk/release/app-universal-release.apk - name: Upload ARM64 uses: actions/upload-artifact@v4 with: name: arm64-v8a-${{ github.sha }} path: app/build/outputs/apk/release/app-arm64-v8a-release.apk - name: Upload ARM32 uses: actions/upload-artifact@v4 with: name: armeabi-v7a-${{ github.sha }} path: app/build/outputs/apk/release/app-armeabi-v7a-release.apk - name: Upload x86_64 uses: actions/upload-artifact@v4 with: name: x86_64-${{ github.sha }} path: app/build/outputs/apk/release/app-x86_64-release.apk - name: Upload mapping uses: actions/upload-artifact@v4 with: name: mapping-${{ github.sha }} path: app/build/outputs/mapping/release/mapping.txt - name: Upload native debug symbols uses: actions/upload-artifact@v4 with: name: native-debug-symbols-${{ github.sha }} path: app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip ================================================ FILE: .github/workflows/releases.yml ================================================ name: Releases on: push: tags: - "*" jobs: linux: name: Build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Gradle Build run: ./gradlew assembleRelease - name: Rename Apks run: | mv app/build/outputs/apk/release/app-universal-release.apk EhViewer-${{ github.ref_name }}-universal.apk mv app/build/outputs/apk/release/app-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-arm64-v8a.apk mv app/build/outputs/apk/release/app-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-armeabi-v7a.apk mv app/build/outputs/apk/release/app-x86_64-release.apk EhViewer-${{ github.ref_name }}-x86_64.apk mv app/build/outputs/mapping/release/mapping.txt EhViewer-${{ github.ref_name }}-mapping.txt mv app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-native-debug-symbols.zip - name: Releases uses: softprops/action-gh-release@v2 with: body: Bump Version files: | EhViewer-${{ github.ref_name }}-universal.apk EhViewer-${{ github.ref_name }}-arm64-v8a.apk EhViewer-${{ github.ref_name }}-armeabi-v7a.apk EhViewer-${{ github.ref_name }}-x86_64.apk EhViewer-${{ github.ref_name }}-mapping.txt EhViewer-${{ github.ref_name }}-native-debug-symbols.zip ================================================ FILE: .gitignore ================================================ .gradle /local.properties .DS_Store /build /src *.iml .idea /captures release google-services.json /.kotlin ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: NOTICE ================================================ EhViewer Copyright 2014-2016 Hippo Seven An Unofficial E-Hentai Application for Android. 香风智乃是最可爱的女孩子。 chino kafuu is the cutest girl. ================================================ FILE: README.md ================================================

English | 简体中文

EhViewer
EhViewer

Github Actions LICENSE Releases Issues

# Description An EhViewer fork with classic Material Design 2 style This fork is for personal use and does not accept feature requests; for common usage issues, please refer to the [Q&A](https://github.com/EhViewer-NekoInverter/EhViewer/issues/18) If you prefer Material Design 3, consider using [EhViewer-Overhauled](https://github.com/FooIbar/EhViewer) # Download | Android Version | Notes | |-----------------|------------------------------| | 6.0-8.1 | No support for animated WebP | | 9.0+ | Full support | - Please go to **[GitHub Releases](https://github.com/EhViewer-NekoInverter/EhViewer/releases)** to download the release version - If the release version has unresolved issues, go to **[GitHub Actions](https://github.com/EhViewer-NekoInverter/EhViewer/actions/workflows/ci.yml?query=branch%3Amaster)** to download the CI version (GitHub account login required) # Screenshot ![screenshot-01](https://github.com/EhViewer-NekoInverter/Arts/blob/main/screenshot-01.webp) ![screenshot-02](https://github.com/EhViewer-NekoInverter/Arts/blob/main/screenshot-02.webp) # Thanks Here are the libraries - [AOSP & AndroidX](https://source.android.com/) - [Kotlin & KotlinX](https://kotlinlang.org/) - [Coil](https://coil-kt.github.io/coil/) - [FullDraggableDrawer](https://github.com/PureWriter/FullDraggableDrawer) - [Jsoup](https://jsoup.org/) - [Ktor](https://ktor.io/) - [Libarchive](http://www.libarchive.org/) - [MDC-Android](https://github.com/material-components/material-components-android) - [OkHttp](https://square.github.io/okhttp/) - [RikkaX](https://github.com/RikkaApps/RikkaX) Tag translation - [EhTagTranslation](https://github.com/EhTagTranslation/Database) Translators - ja: [Re*Index. (ot_inc)](https://github.com/reindex-ot) # License Copyright 2014-2019 Hippo Seven Copyright 2020-2022 NekoInverter Copyright 2022-2025 Moedog EhViewer is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. EhViewer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with EhViewer. If not, see . ================================================ FILE: app/.gitignore ================================================ /build /src/main/java-gen manifest-merger-release-report.txt /src/main/libs /src/main/obj # Intellij *.iml /.cxx ================================================ FILE: app/build.gradle.kts ================================================ import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter import org.jetbrains.kotlin.gradle.dsl.JvmTarget val isRelease: Boolean get() = gradle.startParameter.taskNames.any { it.contains("Release") } val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a") plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.spotless) } @Suppress("UnstableApiUsage") android { androidResources { localeFilters += listOf( "zh", "zh-rCN", "zh-rHK", "zh-rTW", "ja", ) } splits { abi { isEnable = true reset() if (isRelease) { include(*supportedAbis) isUniversalApk = true } else { include("x86_64", "x86") } } } val signConfig = signingConfigs.create("release") { storeFile = File(projectDir.path + "/keystore/androidkey.jks") storePassword = "000000" keyAlias = "key0" keyPassword = "000000" enableV3Signing = true enableV4Signing = true } val commitSha by lazy { val stdout = providers.exec { commandLine = "git rev-parse --short=7 HEAD".split(' ') }.standardOutput stdout.asText.get().trim() } val buildTime by lazy { val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm").withZone(ZoneOffset.UTC) formatter.format(Instant.now()) } defaultConfig { applicationId = "org.moedog.ehviewer" versionCode = 180014 versionName = "1.8.13" buildConfigField("String", "VERSION_CODE", "\"${defaultConfig.versionCode}\"") buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"") ndk { if (isRelease) { abiFilters.addAll(supportedAbis) } debugSymbolLevel = "FULL" } } externalNativeBuild { cmake { path = File("src/main/cpp/CMakeLists.txt") } } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } lint { abortOnError = true checkReleaseBuilds = false disable.add("MissingTranslation") } packaging { resources { excludes += "/META-INF/**" excludes += "/kotlin/**" excludes += "**.txt" excludes += "**.bin" } } dependenciesInfo.includeInApk = false buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles("proguard-rules.pro") signingConfig = signConfig buildConfigField("String", "BUILD_TIME", "\"$buildTime\"") } debug { applicationIdSuffix = ".debug" buildConfigField("String", "BUILD_TIME", "\"\"") } } buildFeatures { buildConfig = true } namespace = "com.hippo.ehviewer" } dependencies { // https://developer.android.com/jetpack/androidx/releases/activity implementation(libs.androidx.activity) implementation(libs.androidx.appcompat) implementation(libs.androidx.biometric) implementation(libs.androidx.browser) implementation(libs.androidx.collection) implementation(libs.androidx.webkit) implementation(libs.androidx.core) implementation(libs.androidx.coordinatorlayout) implementation(libs.androidx.fragment) // https://developer.android.com/jetpack/androidx/releases/lifecycle implementation(libs.androidx.lifecycle.process) // https://developer.android.com/jetpack/androidx/releases/paging implementation(libs.androidx.paging.runtime) implementation(libs.androidx.preference) implementation(libs.androidx.recyclerview) // https://developer.android.com/jetpack/androidx/releases/room ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.paging) implementation(libs.androidx.swiperefreshlayout) implementation(libs.drawer) implementation(libs.material) // https://square.github.io/okhttp/changelogs/changelog/ implementation(platform(libs.okhttp.bom)) implementation(libs.okhttp.coroutines) implementation(libs.okhttp.tls) implementation(libs.okio.jvm) // https://github.com/RikkaApps/RikkaX implementation(libs.bundles.rikkax) // https://coil-kt.github.io/coil/changelog/ implementation(platform(libs.coil.bom)) implementation(libs.bundles.coil) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.cbor) implementation(libs.ktor.utils) implementation(libs.jsoup) coreLibraryDesugaring(libs.desugar) } kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 progressiveMode = true optIn.addAll( "coil3.annotation.ExperimentalCoilApi", "kotlin.contracts.ExperimentalContracts", "kotlin.time.ExperimentalTime", "kotlinx.coroutines.ExperimentalCoroutinesApi", "kotlinx.coroutines.FlowPreview", "kotlinx.coroutines.InternalCoroutinesApi", "kotlinx.serialization.ExperimentalSerializationApi", ) } } configurations.all { exclude("dev.rikka.rikkax.appcompat", "appcompat") } ksp { arg("room.schemaLocation", "$projectDir/schemas") arg("room.generateKotlin", "true") } val ktlintVersion = libs.ktlint.get().version spotless { kotlin { // https://github.com/diffplug/spotless/issues/111 target("src/**/*.kt") ktlint(ktlintVersion) } kotlinGradle { ktlint(ktlintVersion) } } ================================================ FILE: app/proguard-rules.pro ================================================ -keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; } -keepclasseswithmembernames,includedescriptorclasses class * { native ; } -keepattributes LineNumberTable,SourceFile -renamesourcefileattribute SourceFile -repackageclasses -allowaccessmodification -overloadaggressively ================================================ FILE: app/schemas/com.hippo.network.CookiesDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "c801ee62729ba20a203b150211234d5c", "entities": [ { "tableName": "OK_HTTP_3_COOKIE", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT, `VALUE` TEXT, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT, `PATH` TEXT, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))", "fields": [ { "fieldPath": "name", "columnName": "NAME", "affinity": "TEXT", "notNull": false }, { "fieldPath": "value", "columnName": "VALUE", "affinity": "TEXT", "notNull": false }, { "fieldPath": "expiresAt", "columnName": "EXPIRES_AT", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domain", "columnName": "DOMAIN", "affinity": "TEXT", "notNull": false }, { "fieldPath": "path", "columnName": "PATH", "affinity": "TEXT", "notNull": false }, { "fieldPath": "secure", "columnName": "SECURE", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "httpOnly", "columnName": "HTTP_ONLY", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "persistent", "columnName": "PERSISTENT", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "hostOnly", "columnName": "HOST_ONLY", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "_id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c801ee62729ba20a203b150211234d5c')" ] } } ================================================ FILE: app/schemas/com.hippo.network.CookiesDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "1b80cd29939b0f43934721f1289ba94d", "entities": [ { "tableName": "OK_HTTP_3_COOKIE", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT NOT NULL, `VALUE` TEXT NOT NULL, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT NOT NULL, `PATH` TEXT NOT NULL, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))", "fields": [ { "fieldPath": "name", "columnName": "NAME", "affinity": "TEXT", "notNull": true }, { "fieldPath": "value", "columnName": "VALUE", "affinity": "TEXT", "notNull": true }, { "fieldPath": "expiresAt", "columnName": "EXPIRES_AT", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "domain", "columnName": "DOMAIN", "affinity": "TEXT", "notNull": true }, { "fieldPath": "path", "columnName": "PATH", "affinity": "TEXT", "notNull": true }, { "fieldPath": "secure", "columnName": "SECURE", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "httpOnly", "columnName": "HTTP_ONLY", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "persistent", "columnName": "PERSISTENT", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "hostOnly", "columnName": "HOST_ONLY", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "_id", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "_id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b80cd29939b0f43934721f1289ba94d')" ] } } ================================================ FILE: app/src/debug/res/values/strings.xml ================================================ EhViewer Debug ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/cpp/0001-Insert-link-libs.patch ================================================ From aee8f7147ddb262fd9e5feee8d5b17094cf3470f Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Tue, 26 Sep 2023 00:09:25 +0800 Subject: [PATCH] Insert link libs --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ec97e4c7..420f204c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}") # # endif ENDIF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}") -SET(ADDITIONAL_LIBS "") +SET(ADDITIONAL_LIBS ${LIBARCHIVE_CUSTOM_LIBS}) # # Find ZLIB # -- 2.34.1 ================================================ FILE: app/src/main/cpp/0002-Fix-zip_time-performance.patch ================================================ From e971b9f23727833cc39b9e325db30f383e5bfc30 Mon Sep 17 00:00:00 2001 From: Dude so hot Date: Thu, 14 Nov 2024 00:28:13 +0800 Subject: [PATCH] Fix zip_time performance Signed-off-by: Dude so hot --- libarchive/archive_time.c | 1 + 1 file changed, 1 insertion(+) diff --git a/libarchive/archive_time.c b/libarchive/archive_time.c index 3352c809..9289bf1e 100644 --- a/libarchive/archive_time.c +++ b/libarchive/archive_time.c @@ -53,6 +53,7 @@ FILETIME_to_ntfs(const FILETIME* filetime) int64_t dos_to_unix(uint32_t dos_time) { + return 0; uint16_t msTime, msDate; struct tm ts; time_t t; -- 2.47.0 ================================================ FILE: app/src/main/cpp/0003-Use-UTF-8-as-default-charset-on-bionic.patch ================================================ From 86d199429b3fabc83f7dcc9afd72e7b4c7750b1a Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Thu, 14 Nov 2024 20:22:52 +0800 Subject: [PATCH] Use UTF-8 as default charset on bionic --- libarchive/archive_string.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libarchive/archive_string.c b/libarchive/archive_string.c index abf7ad66..63a73c58 100644 --- a/libarchive/archive_string.c +++ b/libarchive/archive_string.c @@ -423,7 +423,9 @@ static const char * default_iconv_charset(const char *charset) { if (charset != NULL && charset[0] != '\0') return charset; -#if HAVE_LOCALE_CHARSET && !defined(__APPLE__) +#ifdef __BIONIC__ + return "UTF-8"; +#elif HAVE_LOCALE_CHARSET && !defined(__APPLE__) /* locale_charset() is broken on Mac OS */ return locale_charset(); #elif HAVE_NL_LANGINFO -- 2.43.0 ================================================ FILE: app/src/main/cpp/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) project(ehviewer C) include(FetchContent) if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Ofast -fvisibility=hidden -fvisibility-inlines-hidden -funroll-loops -flto \ -mllvm -polly \ -mllvm -polly-run-dce \ -mllvm -polly-run-inliner \ -mllvm -polly-isl-arg=--no-schedule-serialize-sccs \ -mllvm -polly-ast-use-context \ -mllvm -polly-detect-keep-going \ -mllvm -polly-position=before-vectorizer \ -mllvm -polly-vectorizer=stripmine \ -mllvm -polly-detect-profitability-min-per-loop-insts=40 \ -mllvm -polly-invariant-load-hoisting") endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") option(BUILD_TESTING OFF) option(XZ_DOC OFF) option(XZ_LZIP_DECODER OFF) option(XZ_MICROLZMA_DECODER OFF) option(XZ_MICROLZMA_ENCODER OFF) option(XZ_TOOL_LZMADEC OFF) option(XZ_TOOL_LZMAINFO OFF) option(XZ_TOOL_XZ OFF) option(XZ_TOOL_XZDEC OFF) FetchContent_Declare( liblzma GIT_REPOSITORY https://github.com/tukaani-project/xz.git GIT_TAG v5.8.1 GIT_SHALLOW 1 ) FetchContent_MakeAvailable(liblzma) include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api) # Build GNUTLS libnettle FetchContent_Declare( nettle URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.2.tar.gz URL_MD5 b28bcbf6f045ff007940a9401673600d SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}/nettle/nettle ) FetchContent_MakeAvailable(nettle) add_subdirectory(nettle) # Configure libnettle support for libarchive include_directories(nettle) include_directories(.) set(HAVE_LIBNETTLE 1) set(HAVE_NETTLE_AES_H 1) set(HAVE_NETTLE_HMAC_H 1) set(HAVE_NETTLE_MD5_H 1) set(HAVE_NETTLE_PBKDF2_H 1) set(HAVE_NETTLE_RIPEMD160_H 1) set(HAVE_NETTLE_SHA_H 1) # Configure lzma support for libarchive SET(HAVE_LIBLZMA 1) SET(HAVE_LZMA_H 1) SET(HAVE_LZMA_STREAM_ENCODER_MT 1) SET(HAVE_LZMADEC_H 1) SET(HAVE_LIBLZMADEC 1) option(ENABLE_OPENSSL OFF) option(ENABLE_TAR OFF) option(ENABLE_CPIO OFF) option(ENABLE_CAT OFF) option(ENABLE_UNZIP OFF) option(ENABLE_TEST OFF) # Configure libarchive link's static lib SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma") set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch ${CMAKE_CURRENT_LIST_DIR}/0002-Fix-zip_time-performance.patch ${CMAKE_CURRENT_LIST_DIR}/0003-Use-UTF-8-as-default-charset-on-bionic.patch ) FetchContent_Declare( libarchive GIT_REPOSITORY https://github.com/libarchive/libarchive.git GIT_TAG v3.8.1 GIT_SHALLOW 1 PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH} ) FetchContent_MakeAvailable(libarchive) include_directories(${libarchive_SOURCE_DIR}/libarchive) # Build and link our app's native lib add_library(${PROJECT_NAME} SHARED archive.c image.c gifutils.c hash.c natsort/strnatcmp.c) target_link_libraries(${PROJECT_NAME} archive_static log jnigraphics GLESv3 -Wl,--exclude-libs,ALL) ================================================ FILE: app/src/main/cpp/archive.c ================================================ /* * Copyright 2022-2024 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * EhViewer. If not, see . */ #include #include #include #include #include #include #include #include #include #include #define LOG_TAG "libarchive_wrapper" #include "natsort/strnatcmp.h" #include "ehviewer.h" typedef struct { int using; int next_index; struct archive *arc; struct archive_entry *entry; } archive_ctx; typedef struct { const char *filename; int index; ssize_t size; void *addr; } entry; #define CTX_POOL_SIZE 20 #define MAX_PARALLEL_DECOMP 4 #define max(a, b) ((a) > (b) ? (a) : (b)) static pthread_mutex_t ctx_pool_mutex = PTHREAD_MUTEX_INITIALIZER; static archive_ctx **ctx_pool = NULL; static pthread_mutex_t buffer_mutex = PTHREAD_MUTEX_INITIALIZER; static void *decode_buffer[MAX_PARALLEL_DECOMP]; static bool need_encrypt = false; static char *passwd = NULL; static void *archiveAddr = MAP_FAILED; static size_t archiveSize = 0; static entry *entries = NULL; static size_t entryCount = 0; static ssize_t max_file_size = 0; #define SUPPORT_EXT_COUNT 11 const char supportExt[SUPPORT_EXT_COUNT][5] = { "jpeg", "jpg", "png", "gif", "webp", "bmp", "ico", "wbmp", "heic", "heif", "avif" }; static inline int filename_is_playable_file(const char *name) { if (!name) return false; const char *dotptr = strrchr(name, '.'); if (!dotptr++) return false; int i; for (i = 0; i < SUPPORT_EXT_COUNT; i++) if (strcmp(dotptr, supportExt[i]) == 0) return true; return false; } static inline bool archive_entry_is_file(struct archive_entry *entry) { return archive_entry_filetype(entry) == AE_IFREG; } static inline bool archive_entry_is_playable(struct archive_entry *entry) { return archive_entry_is_file(entry) && filename_is_playable_file(archive_entry_pathname(entry)); } static inline int compare_entries(const void *a, const void *b) { const char *fa = ((entry *) a)->filename; const char *fb = ((entry *) b)->filename; return strnatcmp(fa, fb); } #define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize) static bool fill_entry_zero_copy(struct archive *arc, entry *entry) { void *buffer = NULL; size_t buffer_size = 0; la_int64_t output_ofs = 0; archive_read_data_block(arc, (const void **) &buffer, &buffer_size, &output_ofs); bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == entry->size; entry->addr = zero_copy ? buffer : NULL; return zero_copy; } static void archive_map_entries_index(archive_ctx *ctx, bool sort) { int count = 0; bool zero_copy = true; while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) { const char *name = archive_entry_pathname(ctx->entry); if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) { entries[count].filename = strdup(name); entries[count].index = count; ssize_t size = archive_entry_size(ctx->entry); max_file_size = max(size, max_file_size); entries[count].size = size; // We don't expect zero copy if first content can't do zero copy if (zero_copy) zero_copy = fill_entry_zero_copy(ctx->arc, &entries[count]); count++; } } if (sort) qsort(entries, entryCount, sizeof(entry), compare_entries); } static void *acquire_decode_buffer() { void *addr = NULL; pthread_mutex_lock(&buffer_mutex); for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) { addr = decode_buffer[i]; if (addr) { decode_buffer[i] = NULL; break; } } pthread_mutex_unlock(&buffer_mutex); if (!addr) addr = malloc(max_file_size); return addr; } static void release_decode_buffer(void *buffer) { pthread_mutex_lock(&buffer_mutex); for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) { void *addr = decode_buffer[i]; if (!addr) { decode_buffer[i] = buffer; pthread_mutex_unlock(&buffer_mutex); return; } } pthread_mutex_unlock(&buffer_mutex); free(buffer); } static int archive_list_all_entries(archive_ctx *ctx) { int count = 0; while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) if (archive_entry_is_playable(ctx->entry)) count++; return count; } static void archive_release_ctx(archive_ctx *ctx) { if (ctx) { archive_read_close(ctx->arc); archive_read_free(ctx->arc); free(ctx); } } static archive_ctx *archive_alloc_ctx() { archive_ctx *ctx = calloc(1, sizeof(archive_ctx)); ctx->arc = archive_read_new(); ctx->using = 1; archive_read_support_format_tar(ctx->arc); archive_read_support_format_7zip(ctx->arc); archive_read_support_format_rar5(ctx->arc); archive_read_support_format_zip(ctx->arc); archive_read_support_filter_gzip(ctx->arc); archive_read_support_filter_xz(ctx->arc); archive_read_set_option(ctx->arc, "zip", "ignorecrc32", "1"); if (passwd) archive_read_add_passphrase(ctx->arc, passwd); int err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize); if (err < ARCHIVE_OK) { LOGE("%s%s", "Open archive failed: ", archive_error_string(ctx->arc)); archive_read_free(ctx->arc); free(ctx); return NULL; } return ctx; } static int archive_skip_to_index(archive_ctx *ctx, int index) { while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) { if (!archive_entry_is_playable(ctx->entry)) continue; if (ctx->next_index++ == index) { return ctx->next_index - 1; } } return ARCHIVE_FATAL; } static int archive_get_ctx(archive_ctx **ctxptr, int idx) { int ret; archive_ctx *ctx = NULL; pthread_mutex_lock(&ctx_pool_mutex); for (int i = 0; i < CTX_POOL_SIZE; i++) { if (!ctx_pool[i]) continue; if (ctx_pool[i]->using) continue; if (ctx_pool[i]->next_index > idx) continue; if (!ctx || ctx_pool[i]->next_index > ctx->next_index) ctx = ctx_pool[i]; if (ctx->next_index == idx) break; } if (ctx) ctx->using = 1; pthread_mutex_unlock(&ctx_pool_mutex); if (!ctx) { archive_ctx *victimCtx = NULL; int victimIdx = 0; int replace = 1; ctx = archive_alloc_ctx(); pthread_mutex_lock(&ctx_pool_mutex); for (int i = 0; i < CTX_POOL_SIZE; i++) { if (!ctx_pool[i]) { ctx_pool[i] = ctx; replace = 0; break; } if (ctx_pool[i]->using) continue; if (!victimCtx || ctx_pool[i]->next_index > victimCtx->next_index) { victimCtx = ctx_pool[i]; victimIdx = i; } } if (replace) ctx_pool[victimIdx] = ctx; pthread_mutex_unlock(&ctx_pool_mutex); if (replace) archive_release_ctx(victimCtx); } ret = archive_skip_to_index(ctx, idx); if (ret != idx) { ret = archive_errno(ctx->arc); LOGE("Skip to index failed: %s", archive_error_string(ctx->arc)); archive_release_ctx(ctx); return ret; } *ctxptr = ctx; return 0; } JNIEXPORT jint JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) { EH_UNUSED(env); EH_UNUSED(thiz); archive_ctx *ctx = NULL; archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0); if (archiveAddr == MAP_FAILED) { LOGE("%s%s", "mmap failed with error ", strerror(errno)); return 0; } archiveSize = size; ctx_pool = calloc(CTX_POOL_SIZE, sizeof(archive_ctx **)); ctx = archive_alloc_ctx(); if (!ctx) return 0; entryCount = archive_list_all_entries(ctx); LOGI("%s%zu%s", "Found ", entryCount, " images in archive"); if (!entryCount) { LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc)); archive_release_ctx(ctx); return 0; } // We must read through the file|vm then we can know whether it is encrypted int encryptRet = archive_read_has_encrypted_entries(ctx->arc); switch (encryptRet) { case 1: // At lease 1 encrypted entry need_encrypt = true; break; case 0: // format supports but no encrypted entry found default: need_encrypt = false; } int format = archive_format(ctx->arc); switch (format) { case ARCHIVE_FORMAT_ZIP: case ARCHIVE_FORMAT_RAR_V5: madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL); break; case ARCHIVE_FORMAT_7ZIP: // Seek is bad madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM); break; default:; } archive_release_ctx(ctx); ctx = archive_alloc_ctx(); if (!ctx) return 0; entries = calloc(entryCount, sizeof(entry)); archive_map_entries_index(ctx, sort_entries); archive_release_ctx(ctx); return (int) entryCount; } JNIEXPORT jobject JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz, jint index) { EH_UNUSED(env); EH_UNUSED(thiz); entry *entry = &entries[index]; ssize_t size = entry->size; if (entry->addr) { return (*env)->NewDirectByteBuffer(env, entry->addr, size); } else { archive_ctx *ctx = NULL; if (!archive_get_ctx(&ctx, entry->index)) { void *addr = acquire_decode_buffer(); ssize_t bytes = archive_read_data(ctx->arc, addr, size); ctx->using = 0; if (bytes == size) { return (*env)->NewDirectByteBuffer(env, addr, size); } else { if (bytes < 0) { LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc)); } else { LOGE("%s", "No enough data read, WTF?"); } } release_decode_buffer(addr); } } return 0; } JNIEXPORT void JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) { EH_UNUSED(env); EH_UNUSED(thiz); if (ctx_pool) { for (int i = 0; i < CTX_POOL_SIZE; i++) archive_release_ctx(ctx_pool[i]); free(ctx_pool); ctx_pool = NULL; } free(passwd); passwd = NULL; need_encrypt = false; if (archiveAddr != MAP_FAILED) { munmap(archiveAddr, archiveSize); archiveAddr = MAP_FAILED; } for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) { free(decode_buffer[i]); decode_buffer[i] = NULL; } max_file_size = 0; if (entries) { for (int i = 0; i < entryCount; ++i) { free((void *) entries[i].filename); } free(entries); entries = NULL; } } JNIEXPORT jboolean JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_needPassword(JNIEnv *env, jclass thiz) { EH_UNUSED(env); EH_UNUSED(thiz); return need_encrypt; } JNIEXPORT jboolean JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz, jstring str) { EH_UNUSED(thiz); struct archive_entry *entry; archive_ctx *ctx; jboolean ret = true; int len = (*env)->GetStringUTFLength(env, str); passwd = realloc(passwd, len + 1); (*env)->GetStringUTFRegion(env, str, 0, len, passwd); passwd[len] = 0; ctx = archive_alloc_ctx(); char tmpBuf[4096]; while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) { if (!archive_entry_is_playable(entry)) continue; if (!archive_entry_is_encrypted(entry)) continue; if (archive_read_data(ctx->arc, tmpBuf, 4096) < ARCHIVE_OK) { LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc)); ret = false; } break; } archive_release_ctx(ctx); return ret; } JNIEXPORT jstring JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz, jint index) { EH_UNUSED(env); EH_UNUSED(thiz); index = entries[index].index; archive_ctx *ctx = NULL; int ret; ret = archive_get_ctx(&ctx, index); if (ret) return NULL; jstring str = (*env)->NewStringUTF(env, archive_entry_pathname(ctx->entry)); ctx->using = 0; return str; } JNIEXPORT jboolean JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz, jint index, jint fd) { EH_UNUSED(env); EH_UNUSED(thiz); index = entries[index].index; archive_ctx *ctx = NULL; int ret; ret = archive_get_ctx(&ctx, index); if (!ret) { ret = archive_read_data_into_fd(ctx->arc, fd); ctx->using = 0; } return ret == ARCHIVE_OK; } JNIEXPORT void JNICALL Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz, jobject buffer) { EH_UNUSED(thiz); void *addr = (*env)->GetDirectBufferAddress(env, buffer); if (!ADDR_IN_FILE_MAPPING(addr)) { release_decode_buffer(addr); } } ================================================ FILE: app/src/main/cpp/ehviewer.h ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * EhViewer. If not, see . */ #ifndef EHVIEWER_EHVIEWER_H #define EHVIEWER_EHVIEWER_H #define EH_UNUSED(x) (void)x #define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG ,__VA_ARGS__) #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG ,__VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG ,__VA_ARGS__) #define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG ,__VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG ,__VA_ARGS__) #define LOGF(...) __android_log_print(ANDROID_LOG_FATAL, LOG_TAG ,__VA_ARGS__) #define madvise_log_if_error(addr, len, advice) \ if (madvise(addr, len, advice)) \ LOGE("%s%p%s%zu%s%d%s%s%s", "madvise addr:", addr, "len:", len, "with advice ", advice, " failed with error: ", strerror(errno), ", Ignored") #endif /* EHVIEWER_EHVIEWER_H */ ================================================ FILE: app/src/main/cpp/gifutils.c ================================================ /* * Copyright 2023 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * EhViewer. If not, see . */ #include #include #include #include #include #include #include #define LOG_TAG "gifUtils" #include "ehviewer.h" #define GIF_HEADER_87A "GIF87a" #define GIF_HEADER_89A "GIF89a" #define GIF_HEADER_LENGTH 6 static int FRAME_DELAY_START_MARKER = 0x0021F904; typedef signed char byte; #define FRAME_DELAY_START_MARKER ((byte*)(&FRAME_DELAY_START_MARKER)) #define MINIMUM_FRAME_DELAY 2 #define DEFAULT_FRAME_DELAY 10 static inline bool isGif(void *addr) { return !memcmp(addr, GIF_HEADER_87A, GIF_HEADER_LENGTH) || !memcmp(addr, GIF_HEADER_89A, GIF_HEADER_LENGTH); } static void doRewrite(byte *addr, size_t size) { if (size < 7 || !isGif(addr)) return; for (size_t i = 0; i < size - 8; i++) { // TODO: Optimize this hex find with SIMD? if (addr[i] == FRAME_DELAY_START_MARKER[3] && addr[i + 1] == FRAME_DELAY_START_MARKER[2] && addr[i + 2] == FRAME_DELAY_START_MARKER[1] && addr[i + 3] == FRAME_DELAY_START_MARKER[0]) { byte *end = addr + i + 4; if (end[4] != 0) continue; int frameDelay = end[2] << 8 | end[1]; if (frameDelay >= MINIMUM_FRAME_DELAY) break; // Quit if the first block looks normal, for performance end[1] = DEFAULT_FRAME_DELAY; end[2] = 0; } } } JNIEXPORT jboolean JNICALL Java_com_hippo_ehviewer_jni_GifUtilsKt_isGif(JNIEnv *env, jclass clazz, jint fd) { byte buffer[GIF_HEADER_LENGTH]; return read(fd, buffer, GIF_HEADER_LENGTH) == GIF_HEADER_LENGTH && isGif(buffer); } JNIEXPORT void JNICALL Java_com_hippo_ehviewer_jni_GifUtilsKt_rewriteGifSource(JNIEnv *env, jclass clazz, jobject buffer) { byte *addr = (*env)->GetDirectBufferAddress(env, buffer); size_t size = (*env)->GetDirectBufferCapacity(env, buffer); doRewrite(addr, size); } JNIEXPORT jobject JNICALL Java_com_hippo_ehviewer_jni_GifUtilsKt_mmap(JNIEnv *env, jclass clazz, jint fd) { struct stat64 st; fstat64(fd, &st); size_t size = st.st_size; byte *addr = mmap64(0, size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0); if (addr == MAP_FAILED) return NULL; return (*env)->NewDirectByteBuffer(env, addr, size); } JNIEXPORT void JNICALL Java_com_hippo_ehviewer_jni_GifUtilsKt_munmap(JNIEnv *env, jclass clazz, jobject buffer) { byte *addr = (*env)->GetDirectBufferAddress(env, buffer); size_t size = (*env)->GetDirectBufferCapacity(env, buffer); munmap(addr, size); } ================================================ FILE: app/src/main/cpp/hash.c ================================================ /* * Copyright 2022-2024 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * EhViewer. If not, see . */ #include #include #include #include "ehviewer.h" #define BUFFER_SIZE 8192 typedef uint8_t byte; const char hex_digits[] = "0123456789abcdef"; JNIEXPORT jstring JNICALL Java_com_hippo_ehviewer_jni_HashKt_sha1(JNIEnv *env, jclass clazz, jint fd) { EH_UNUSED(clazz); struct sha1_ctx ctx; sha1_init(&ctx); size_t bytes_read; byte buffer[BUFFER_SIZE]; while ((bytes_read = read(fd, buffer, BUFFER_SIZE)) > 0) { sha1_update(&ctx, bytes_read, buffer); } byte digest[SHA1_DIGEST_SIZE]; sha1_digest(&ctx, SHA1_DIGEST_SIZE, digest); byte byte; char hex_digest[2 * SHA1_DIGEST_SIZE + 1]; for (int i = 0; i < SHA1_DIGEST_SIZE; i++) { byte = digest[i]; hex_digest[2 * i] = hex_digits[byte >> 4 & 0xF]; hex_digest[2 * i + 1] = hex_digits[byte & 0xF]; } hex_digest[2 * SHA1_DIGEST_SIZE] = '\0'; return (*env)->NewStringUTF(env, hex_digest); } ================================================ FILE: app/src/main/cpp/image.c ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * EhViewer. If not, see . */ #include #include #include #include #include #include #include #define TAG "ImageDecoder_wrapper" #include "ehviewer.h" #define IMAGE_TILE_MAX_SIZE (512 * 512) static char tile_buffer[IMAGE_TILE_MAX_SIZE * 8]; bool copy_pixels(const void *src, int src_w, int src_h, int src_x, int src_y, void *dst, int dst_w, int dst_h, int dst_x, int dst_y, int width, int height, int stride) { int left; int line; size_t line_stride; int src_stride; int src_pos; int dst_pos; size_t dst_blank_length; // Sanitize if (src_x < 0) { width -= src_x; dst_x -= src_x; src_x = 0; } if (dst_x < 0) { width -= dst_x; src_x -= dst_x; dst_x = 0; } if (width <= 0) { return false; } if (src_y < 0) { height -= src_y; dst_y -= src_y; src_y = 0; } if (dst_y < 0) { height -= dst_y; src_y -= dst_y; dst_y = 0; } if (height <= 0) { return false; } left = src_x + width - src_w; if (left > 0) { width -= left; } left = dst_x + width - dst_w; if (left > 0) { width -= left; } if (width <= 0) { return false; } left = src_y + height - src_h; if (left > 0) { height -= left; } left = dst_y + height - dst_h; if (left > 0) { height -= left; } if (height <= 0) { return false; } // Init line_stride = (size_t) (width * stride); src_stride = src_w * stride; src_pos = src_y * src_stride + src_x * stride; dst_pos = 0; dst_blank_length = (size_t) (dst_y * dst_w + dst_x) * stride; // First line dst_pos += (int) dst_blank_length; memcpy(dst + dst_pos, src + src_pos, line_stride); dst_pos += (int) line_stride; src_pos += src_stride; // Other lines dst_blank_length = (size_t) ((dst_w - width) * stride); for (line = 1; line < height; line++) { dst_pos += (int) dst_blank_length; memcpy(dst + dst_pos, src + src_pos, line_stride); dst_pos += (int) line_stride; src_pos += src_stride; } return true; } JNIEXPORT void JNICALL Java_com_hippo_ehviewer_jni_ImageKt_nativeTexImage(JNIEnv *env, jclass clazz, jobject bitmap, jboolean init, jint offset_x, jint offset_y, jint width, jint height) { if (width * height > IMAGE_TILE_MAX_SIZE) return; AndroidBitmapInfo info; void *pixels = NULL; AndroidBitmap_lockPixels(env, bitmap, &pixels); AndroidBitmap_getInfo(env, bitmap, &info); bool is_f16 = info.format == ANDROID_BITMAP_FORMAT_RGBA_F16; copy_pixels(pixels, info.width, info.height, offset_x, offset_y, tile_buffer, width, height, 0, 0, width, height, is_f16 ? 8 : 4); AndroidBitmap_unlockPixels(env, bitmap); if (init) { glTexImage2D(GL_TEXTURE_2D, 0, is_f16 ? GL_RGBA16F : GL_RGBA8, width, height, 0, GL_RGBA, is_f16 ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE, tile_buffer); } else { glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, is_f16 ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE, tile_buffer); } } ================================================ FILE: app/src/main/cpp/natsort/strnatcmp.c ================================================ /* -*- mode: c; c-file-style: "k&r" -*- strnatcmp.c -- Perform 'natural order' comparisons of strings in C. Copyright (C) 2000, 2004 by Martin Pool This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 3. This notice may not be removed or altered from any source distribution. */ /* partial change history: * * 2004-10-10 mbp: Lift out character type dependencies into macros. * * Eric Sosman pointed out that ctype functions take a parameter whose * value must be that of an unsigned int, even on platforms that have * negative chars in their default char type. */ #include #include "strnatcmp.h" /* These are defined as macros to make it easier to adapt this code to * different characters types or comparison functions. */ static inline int nat_isdigit(const char *a) { return isdigit((unsigned char) *a); } static inline int nat_isspace(const char *a) { return isspace((unsigned char) *a) || *a == '0' && nat_isdigit(a + 1); } static int compare_right(const char *a, const char *b) { int bias = 0; /* The longest run of digits wins. That aside, the greatest value wins, but we can't know that it will until we've scanned both numbers to know that they have the same magnitude, so we remember it in BIAS. */ for (;; a++, b++) { if (!nat_isdigit(a) && !nat_isdigit(b)) return bias; if (!nat_isdigit(a)) return -1; if (!nat_isdigit(b)) return +1; if (*a < *b) { if (!bias) bias = -1; } else if (*a > *b) { if (!bias) bias = +1; } else if (!*a && !*b) return bias; } } int strnatcmp(const char *a, const char *b) { int result; while (1) { /* skip over leading spaces or zeros */ while (nat_isspace(a)) a++; while (nat_isspace(b)) b++; /* process run of digits */ if (nat_isdigit(a) && nat_isdigit(b)) { if ((result = compare_right(a, b)) != 0) return result; } if (!*a && !*b) { /* The strings compare the same. Perhaps the caller will want to call strcmp to break the tie. */ return 0; } if (*a < *b) return -1; if (*a > *b) return +1; a++; b++; } } ================================================ FILE: app/src/main/cpp/natsort/strnatcmp.h ================================================ /* -*- mode: c; c-file-style: "k&r" -*- strnatcmp.c -- Perform 'natural order' comparisons of strings in C. Copyright (C) 2000, 2004 by Martin Pool This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 3. This notice may not be removed or altered from any source distribution. */ #ifdef __cplusplus extern "C" { #endif int strnatcmp(const char *a, const char *b); #ifdef __cplusplus } #endif ================================================ FILE: app/src/main/cpp/nettle/.gitignore ================================================ /nettle ================================================ FILE: app/src/main/cpp/nettle/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.4.1) project(nettle C) set(LIBNETTLE_DEFINITIONS -DHAVE_CONFIG_H) set(LIBNETTLE_SOURCES nettle/aes-decrypt-internal.c nettle/aes-decrypt.c nettle/aes-decrypt-table.c nettle/aes128-decrypt.c nettle/aes192-decrypt.c nettle/aes256-decrypt.c nettle/aes-encrypt-internal.c nettle/aes-encrypt.c nettle/aes-encrypt-table.c nettle/aes128-encrypt.c nettle/aes192-encrypt.c nettle/aes256-encrypt.c nettle/aes-invert-internal.c nettle/aes-set-key-internal.c nettle/aes-set-encrypt-key.c nettle/aes-set-decrypt-key.c nettle/aes128-set-encrypt-key.c nettle/aes128-set-decrypt-key.c nettle/aes128-meta.c nettle/aes192-set-encrypt-key.c nettle/aes192-set-decrypt-key.c nettle/aes192-meta.c nettle/aes256-set-encrypt-key.c nettle/aes256-set-decrypt-key.c nettle/aes256-meta.c nettle/nist-keywrap.c nettle/arcfour.c nettle/arctwo.c nettle/arctwo-meta.c nettle/blowfish.c nettle/blowfish-bcrypt.c nettle/base16-encode.c nettle/base16-decode.c nettle/base16-meta.c nettle/base64-encode.c nettle/base64-decode.c nettle/base64-meta.c nettle/base64url-encode.c nettle/base64url-decode.c nettle/base64url-meta.c nettle/buffer.c nettle/buffer-init.c nettle/camellia-crypt-internal.c nettle/camellia-table.c nettle/camellia-absorb.c nettle/camellia-invert-key.c nettle/camellia128-set-encrypt-key.c nettle/camellia128-crypt.c nettle/camellia128-set-decrypt-key.c nettle/camellia128-meta.c nettle/camellia192-meta.c nettle/camellia256-set-encrypt-key.c nettle/camellia256-crypt.c nettle/camellia256-set-decrypt-key.c nettle/camellia256-meta.c nettle/cast128.c nettle/cast128-meta.c nettle/cbc.c nettle/cbc-aes128-encrypt.c nettle/cbc-aes192-encrypt.c nettle/cbc-aes256-encrypt.c nettle/ccm.c nettle/ccm-aes128.c nettle/ccm-aes192.c nettle/ccm-aes256.c nettle/cfb.c nettle/siv-cmac.c nettle/siv-cmac-aes128.c nettle/siv-cmac-aes256.c nettle/cnd-memcpy.c nettle/chacha-crypt.c nettle/chacha-core-internal.c nettle/chacha-poly1305.c nettle/chacha-poly1305-meta.c nettle/chacha-set-key.c nettle/chacha-set-nonce.c nettle/ctr.c nettle/ctr16.c nettle/des.c nettle/des3.c nettle/eax.c nettle/eax-aes128.c nettle/eax-aes128-meta.c nettle/ghash-set-key.c nettle/ghash-update.c nettle/gcm.c nettle/gcm-aes.c nettle/gcm-aes128.c nettle/gcm-aes128-meta.c nettle/gcm-aes192.c nettle/gcm-aes192-meta.c nettle/gcm-aes256.c nettle/gcm-aes256-meta.c nettle/gcm-camellia128.c nettle/gcm-camellia128-meta.c nettle/gcm-camellia256.c nettle/gcm-camellia256-meta.c nettle/cmac.c nettle/cmac64.c nettle/cmac-aes128.c nettle/cmac-aes256.c nettle/cmac-des3.c nettle/cmac-aes128-meta.c nettle/cmac-aes256-meta.c nettle/cmac-des3-meta.c nettle/gost28147.c nettle/gosthash94.c nettle/gosthash94-meta.c nettle/hmac.c nettle/hmac-gosthash94.c nettle/hmac-md5.c nettle/hmac-ripemd160.c nettle/hmac-sha1.c nettle/hmac-sha224.c nettle/hmac-sha256.c nettle/hmac-sha384.c nettle/hmac-sha512.c nettle/hmac-streebog.c nettle/hmac-sm3.c nettle/hmac-gosthash94-meta.c nettle/hmac-md5-meta.c nettle/hmac-ripemd160-meta.c nettle/hmac-sha1-meta.c nettle/hmac-sha224-meta.c nettle/hmac-sha256-meta.c nettle/hmac-sha384-meta.c nettle/hmac-sha512-meta.c nettle/hmac-streebog-meta.c nettle/hmac-sm3-meta.c nettle/knuth-lfib.c nettle/hkdf.c nettle/md2.c nettle/md2-meta.c nettle/md4.c nettle/md4-meta.c nettle/md5.c nettle/md5-compat.c nettle/md5-meta.c nettle/memeql-sec.c nettle/memxor.c nettle/memxor3.c nettle/nettle-lookup-hash.c nettle/nettle-meta-aeads.c nettle/nettle-meta-armors.c nettle/nettle-meta-ciphers.c nettle/nettle-meta-hashes.c nettle/nettle-meta-macs.c nettle/pbkdf2.c nettle/pbkdf2-hmac-gosthash94.c nettle/pbkdf2-hmac-sha1.c nettle/pbkdf2-hmac-sha256.c nettle/pbkdf2-hmac-sha384.c nettle/pbkdf2-hmac-sha512.c nettle/poly1305-aes.c nettle/poly1305-internal.c nettle/realloc.c nettle/ripemd160.c nettle/ripemd160-compress.c nettle/ripemd160-meta.c nettle/salsa20-core-internal.c nettle/salsa20-crypt-internal.c nettle/salsa20-crypt.c nettle/salsa20r12-crypt.c nettle/salsa20-set-key.c nettle/salsa20-set-nonce.c nettle/salsa20-128-set-key.c nettle/salsa20-256-set-key.c nettle/sha1.c nettle/sha1-compress.c nettle/sha1-meta.c nettle/sha256.c nettle/sha256-compress-n.c nettle/sha224-meta.c nettle/sha256-meta.c nettle/sha512.c nettle/sha512-compress.c nettle/sha384-meta.c nettle/sha512-meta.c nettle/sha512-224-meta.c nettle/sha512-256-meta.c nettle/sha3.c nettle/sha3-permute.c nettle/sha3-shake.c nettle/sha3-224.c nettle/sha3-224-meta.c nettle/sha3-256.c nettle/sha3-256-meta.c nettle/sha3-384.c nettle/sha3-384-meta.c nettle/sha3-512.c nettle/sha3-512-meta.c nettle/shake128.c nettle/shake256.c nettle/sm3.c nettle/sm3-meta.c nettle/serpent-set-key.c nettle/serpent-encrypt.c nettle/serpent-decrypt.c nettle/serpent-meta.c nettle/streebog.c nettle/streebog-meta.c nettle/twofish.c nettle/twofish-meta.c nettle/umac-nh.c nettle/umac-nh-n.c nettle/umac-l2.c nettle/umac-l3.c nettle/umac-poly64.c nettle/umac-poly128.c nettle/umac-set-key.c nettle/umac32.c nettle/umac64.c nettle/umac96.c nettle/umac128.c nettle/version.c nettle/write-be32.c nettle/write-le32.c nettle/write-le64.c nettle/yarrow256.c nettle/yarrow_key_event.c nettle/xts.c nettle/xts-aes128.c nettle/xts-aes256.c ) set(LIBNETTLE_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/nettle ) add_library(nettle STATIC ${LIBNETTLE_SOURCES}) target_include_directories(nettle PUBLIC ${LIBNETTLE_INCLUDES}) target_compile_definitions(nettle PRIVATE ${LIBNETTLE_DEFINITIONS}) ================================================ FILE: app/src/main/cpp/nettle/config.h ================================================ /* config.h. Generated from config.h.in by configure. */ /* config.h.in. Generated from configure.ac by autoheader. */ /* Define if building universal (internal helper macro) */ /* #undef AC_APPLE_UNIVERSAL_BUILD */ /* Define to 1 if using 'alloca.c'. */ /* #undef C_ALLOCA */ /* Define to 1 if you have 'alloca', as a function or macro. */ #define HAVE_ALLOCA 1 /* Define to 1 if works. */ #define HAVE_ALLOCA_H 1 /* Define if __builtin_bswap64 is available */ #define HAVE_BUILTIN_BSWAP64 1 /* Define if clock_gettime is available */ #define HAVE_CLOCK_GETTIME 1 /* Define to 1 if you have the header file. */ #define HAVE_DLFCN_H 1 /* Define to 1 if you have the `elf_aux_info' function. */ /* #undef HAVE_ELF_AUX_INFO */ /* Define if fcntl file locking is available */ #define HAVE_FCNTL_LOCKING 1 /* Define if the compiler understands __attribute__ */ #define HAVE_GCC_ATTRIBUTE 1 /* Define to 1 if you have the `getline' function. */ #define HAVE_GETLINE 1 /* Define to 1 if you have the header file. */ #define HAVE_INTTYPES_H 1 /* Define to 1 if you have dlopen (with -ldl). */ #define HAVE_LIBDL 1 /* Define to 1 if you have the `gmp' library (-lgmp). */ /* #undef HAVE_LIBGMP */ /* Define if compiler and linker supports __attribute__ ifunc */ #define HAVE_LINK_IFUNC 1 /* Define to 1 if you have the header file. */ #define HAVE_MALLOC_H 1 /* Define to 1 each of the following for which a native (ie. CPU specific) implementation of the corresponding routine exists. */ /* #undef HAVE_NATIVE_memxor3 */ /* #undef HAVE_NATIVE_aes_decrypt */ /* #undef HAVE_NATIVE_aes_encrypt */ /* #undef HAVE_NATIVE_aes_invert */ /* #undef HAVE_NATIVE_aes128_decrypt */ /* #undef HAVE_NATIVE_aes128_encrypt */ /* #undef HAVE_NATIVE_aes128_invert_key */ /* #undef HAVE_NATIVE_aes128_set_decrypt_key */ /* #undef HAVE_NATIVE_aes128_set_encrypt_key */ /* #undef HAVE_NATIVE_aes192_decrypt */ /* #undef HAVE_NATIVE_aes192_encrypt */ /* #undef HAVE_NATIVE_aes192_invert_key */ /* #undef HAVE_NATIVE_aes192_set_decrypt_key */ /* #undef HAVE_NATIVE_aes192_set_encrypt_key */ /* #undef HAVE_NATIVE_aes256_decrypt */ /* #undef HAVE_NATIVE_aes256_encrypt */ /* #undef HAVE_NATIVE_aes256_invert_key */ /* #undef HAVE_NATIVE_aes256_set_decrypt_key */ /* #undef HAVE_NATIVE_aes256_set_encrypt_key */ /* #undef HAVE_NATIVE_cbc_aes128_encrypt */ /* #undef HAVE_NATIVE_cbc_aes192_encrypt */ /* #undef HAVE_NATIVE_cbc_aes256_encrypt */ /* #undef HAVE_NATIVE_chacha_core */ /* #undef HAVE_NATIVE_chacha_2core */ #define HAVE_NATIVE_chacha_3core 1 /* #undef HAVE_NATIVE_chacha_4core */ /* #undef HAVE_NATIVE_fat_chacha_2core */ /* #undef HAVE_NATIVE_fat_chacha_3core */ /* #undef HAVE_NATIVE_fat_chacha_4core */ /* #undef HAVE_NATIVE_ecc_curve25519_modp */ /* #undef HAVE_NATIVE_ecc_curve448_modp */ /* #undef HAVE_NATIVE_ecc_secp192r1_modp */ /* #undef HAVE_NATIVE_ecc_secp192r1_redc */ /* #undef HAVE_NATIVE_ecc_secp224r1_modp */ /* #undef HAVE_NATIVE_ecc_secp224r1_redc */ /* #undef HAVE_NATIVE_ecc_secp256r1_modp */ /* #undef HAVE_NATIVE_ecc_secp256r1_redc */ /* #undef HAVE_NATIVE_ecc_secp384r1_modp */ /* #undef HAVE_NATIVE_ecc_secp384r1_redc */ /* #undef HAVE_NATIVE_ecc_secp521r1_modp */ /* #undef HAVE_NATIVE_ecc_secp521r1_redc */ /* #undef HAVE_NATIVE_poly1305_set_key */ /* #undef HAVE_NATIVE_poly1305_block */ /* #undef HAVE_NATIVE_poly1305_digest */ /* #undef HAVE_NATIVE_poly1305_blocks */ /* #undef HAVE_NATIVE_fat_poly1305_blocks */ /* #undef HAVE_NATIVE_ghash_set_key */ /* #undef HAVE_NATIVE_ghash_update */ /* #undef HAVE_NATIVE_gcm_aes_encrypt */ /* #undef HAVE_NATIVE_gcm_aes_decrypt */ /* #undef HAVE_NATIVE_salsa20_core */ #define HAVE_NATIVE_salsa20_2core 1 /* #undef HAVE_NATIVE_fat_salsa20_2core */ /* #undef HAVE_NATIVE_sha1_compress */ /* #undef HAVE_NATIVE_sha256_compress_n */ /* #undef HAVE_NATIVE_sha512_compress */ /* #undef HAVE_NATIVE_sha3_permute */ /* #undef HAVE_NATIVE_umac_nh */ /* #undef HAVE_NATIVE_umac_nh_n */ /* Define to 1 if you have the header file. */ /* #undef HAVE_OPENSSL_EC_H */ /* Define to 1 if you have the header file. */ /* #undef HAVE_OPENSSL_EVP_H */ /* Define to 1 if you have the header file. */ /* #undef HAVE_OPENSSL_RSA_H */ /* Define to 1 if you have the `secure_getenv' function. */ #define HAVE_SECURE_GETENV 1 /* Define to 1 if you have the header file. */ #define HAVE_STDINT_H 1 /* Define to 1 if you have the header file. */ #define HAVE_STDIO_H 1 /* Define to 1 if you have the header file. */ #define HAVE_STDLIB_H 1 /* Define to 1 if you have the header file. */ #define HAVE_STRINGS_H 1 /* Define to 1 if you have the header file. */ #define HAVE_STRING_H 1 /* Define to 1 if you have the header file. */ #define HAVE_SYS_STAT_H 1 /* Define to 1 if you have the header file. */ #define HAVE_SYS_TYPES_H 1 /* Define to 1 if you have the header file. */ #define HAVE_UNISTD_H 1 /* Define to 1 if you have the header file. */ /* #undef HAVE_VALGRIND_MEMCHECK_H */ /* Define to the address where bug reports for this package should be sent. */ #define PACKAGE_BUGREPORT "nettle-bugs@lists.lysator.liu.se" /* Define to the full name of this package. */ #define PACKAGE_NAME "nettle" /* Define to the full name and version of this package. */ #define PACKAGE_STRING "nettle 3.10.2" /* Define to the one symbol short name of this package. */ #define PACKAGE_TARNAME "nettle" /* Define to the home page for this package. */ #define PACKAGE_URL "" /* Define to the version of this package. */ #define PACKAGE_VERSION "3.10.2" /* The size of `long', as computed by sizeof. */ #define SIZEOF_LONG __SIZEOF_LONG__ /* The size of `size_t', as computed by sizeof. */ #define SIZEOF_SIZE_T __SIZEOF_SIZE_T__ /* If using the C implementation of alloca, define if you know the direction of stack growth for your system; otherwise it will be automatically deduced at runtime. STACK_DIRECTION > 0 => grows toward higher addresses STACK_DIRECTION < 0 => grows toward lower addresses STACK_DIRECTION = 0 => direction of growth unknown */ /* #undef STACK_DIRECTION */ /* Define to 1 if all of the C90 standard headers exist (not just the ones required in a freestanding environment). This macro is provided for backward compatibility; new code need not use it. */ #define STDC_HEADERS 1 /* Defined to enable additional asserts */ /* #undef WITH_EXTRA_ASSERTS */ /* Defined if public key features are enabled */ /* #undef WITH_HOGWEED */ /* Define if you have openssl libcrypto (used for benchmarking) */ /* #undef WITH_OPENSSL */ /* Define WORDS_BIGENDIAN to 1 if your processor stores words with the most significant byte first (like Motorola and SPARC, unlike Intel). */ #if defined AC_APPLE_UNIVERSAL_BUILD # if defined __BIG_ENDIAN__ # define WORDS_BIGENDIAN 1 # endif #else # ifndef WORDS_BIGENDIAN /* # undef WORDS_BIGENDIAN */ # endif #endif /* Define to empty if `const' does not conform to ANSI C. */ /* #undef const */ /* Define to `int' if doesn't define. */ /* #undef gid_t */ /* Define to `__inline__' or `__inline' if that's what the C compiler calls it, or to nothing if 'inline' is not supported under any name. */ #ifndef __cplusplus /* #undef inline */ #endif /* Define to `unsigned int' if does not define. */ /* #undef size_t */ /* Define to `int' if doesn't define. */ /* #undef uid_t */ /* AIX requires this to be the first thing in the file. */ #ifndef __GNUC__ # if HAVE_ALLOCA_H # include # else # ifdef _AIX #pragma alloca # else # ifndef alloca /* predefined by HP cc +Olibcalls */ char *alloca (); # endif # endif /* Needed for alloca on windows */ # if HAVE_MALLOC_H # include # endif # endif #else /* defined __GNUC__ */ # if HAVE_ALLOCA_H # include # else /* Needed for alloca on windows, also with gcc */ # if HAVE_MALLOC_H # include # endif # endif #endif #if __GNUC__ && HAVE_GCC_ATTRIBUTE # define NORETURN __attribute__ ((__noreturn__)) # define PRINTF_STYLE(f, a) __attribute__ ((__format__ (__printf__, f, a))) # define UNUSED __attribute__ ((__unused__)) #else # define NORETURN # define PRINTF_STYLE(f, a) # define UNUSED #endif #if defined(__x86_64__) || defined(__arch64__) # define HAVE_NATIVE_64_BIT 1 #else /* Needs include of before use. */ # define HAVE_NATIVE_64_BIT (SIZEOF_LONG * CHAR_BIT >= 64) #endif ================================================ FILE: app/src/main/cpp/nettle/keymap.h ================================================ ================================================ FILE: app/src/main/cpp/nettle/rotors.h ================================================ ================================================ FILE: app/src/main/cpp/nettle/version.h ================================================ /* version.h Information about library version. Copyright (C) 2015 Red Hat, Inc. Copyright (C) 2015 Niels Möller This file is part of GNU Nettle. GNU Nettle is free software: you can redistribute it and/or modify it under the terms of either: * the GNU Lesser General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. or * the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. or both in parallel, as here. GNU Nettle is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received copies of the GNU General Public License and the GNU Lesser General Public License along with this program. If not, see http://www.gnu.org/licenses/. */ #ifndef NETTLE_VERSION_H_INCLUDED #define NETTLE_VERSION_H_INCLUDED #ifdef __cplusplus extern "C" { #endif /* Individual version numbers in decimal */ #define NETTLE_VERSION_MAJOR 3 #define NETTLE_VERSION_MINOR 10 #define NETTLE_USE_MINI_GMP 0 /* We need a preprocessor constant for GMP_NUMB_BITS, simply using sizeof(mp_limb_t) * CHAR_BIT is not good enough. */ #if NETTLE_USE_MINI_GMP # define GMP_NUMB_BITS n/a #endif int nettle_version_major (void); int nettle_version_minor (void); #ifdef __cplusplus } #endif #endif /* NETTLE_VERSION_H_INCLUDED */ ================================================ FILE: app/src/main/java/com/hippo/app/CheckBoxDialogBuilder.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.app import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.widget.CheckBox import androidx.appcompat.app.AlertDialog import com.hippo.ehviewer.R @SuppressLint("InflateParams") class CheckBoxDialogBuilder( context: Context, message: String?, checkText: String?, checked: Boolean, ) : AlertDialog.Builder( context, ) { private val mCheckBox: CheckBox val isChecked: Boolean get() = mCheckBox.isChecked init { val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_checkbox_builder, null) setView(view) setMessage(message) mCheckBox = view.findViewById(R.id.checkbox) mCheckBox.text = checkText mCheckBox.isChecked = checked view.setOnClickListener { mCheckBox.toggle() } } } ================================================ FILE: app/src/main/java/com/hippo/app/EditTextCheckBoxDialogBuilder.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.app import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.view.KeyEvent import android.view.LayoutInflater import android.widget.CheckBox import android.widget.EditText import android.widget.TextView import android.widget.TextView.OnEditorActionListener import androidx.appcompat.app.AlertDialog import com.google.android.material.textfield.TextInputLayout import com.hippo.ehviewer.R @SuppressLint("InflateParams") class EditTextCheckBoxDialogBuilder( context: Context, text: String?, hint: String?, checkText: String?, checked: Boolean, ) : AlertDialog.Builder( context, ), OnEditorActionListener { private val mCheckBox: CheckBox val isChecked: Boolean get() = mCheckBox.isChecked private val mTextInputLayout: TextInputLayout private val editText: EditText private var mDialog: AlertDialog? = null val text: String get() = editText.text.toString() fun setError(error: CharSequence?) { mTextInputLayout.error = error } override fun create(): AlertDialog { mDialog = super.create() return mDialog as AlertDialog } override fun onEditorAction(v: TextView?, p1: Int, event: KeyEvent?): Boolean = if (mDialog != null) { val button = mDialog!!.getButton(DialogInterface.BUTTON_POSITIVE) button?.performClick() true } else { false } init { val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_edittextcheckbox_builder, null) setView(view) mCheckBox = view.findViewById(R.id.checkbox) mCheckBox.text = checkText mCheckBox.isChecked = checked view.setOnClickListener { mCheckBox.toggle() } editText = view.findViewById(R.id.edit_text) editText.setText(text) editText.setSelection(editText.text.length) editText.setOnEditorActionListener(this) mTextInputLayout = view.findViewById(R.id.text_input_layout) mTextInputLayout.hint = hint } } ================================================ FILE: app/src/main/java/com/hippo/app/EditTextDialogBuilder.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.app import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.view.KeyEvent import android.view.LayoutInflater import android.widget.EditText import android.widget.TextView import android.widget.TextView.OnEditorActionListener import androidx.appcompat.app.AlertDialog import com.google.android.material.textfield.TextInputLayout import com.hippo.ehviewer.R @SuppressLint("InflateParams") class EditTextDialogBuilder( context: Context, text: String?, hint: String?, ) : AlertDialog.Builder( context, ), OnEditorActionListener { private val mTextInputLayout: TextInputLayout val editText: EditText private var mDialog: AlertDialog? = null val text: String get() = editText.text.toString() fun setError(error: CharSequence?) { mTextInputLayout.error = error } override fun create(): AlertDialog { mDialog = super.create() return mDialog as AlertDialog } override fun onEditorAction(v: TextView?, p1: Int, event: KeyEvent?): Boolean = if (mDialog != null) { val button = mDialog!!.getButton(DialogInterface.BUTTON_POSITIVE) button?.performClick() true } else { false } init { val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_edittext_builder, null) setView(view) mTextInputLayout = view as TextInputLayout editText = view.findViewById(R.id.edit_text) editText.setText(text) editText.setSelection(editText.text.length) editText.setOnEditorActionListener(this) mTextInputLayout.hint = hint } } ================================================ FILE: app/src/main/java/com/hippo/app/ListCheckBoxDialogBuilder.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.app import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.CheckBox import android.widget.ListView import androidx.appcompat.app.AlertDialog import com.hippo.ehviewer.R import com.hippo.yorozuya.ViewUtils @SuppressLint("InflateParams") class ListCheckBoxDialogBuilder( context: Context, items: List, listener: (ListCheckBoxDialogBuilder?, AlertDialog?, Int) -> Unit, checkText: String?, checked: Boolean, ) : AlertDialog.Builder( context, ) { private val mCheckBox: CheckBox private var mDialog: AlertDialog? = null val isChecked: Boolean get() = mCheckBox.isChecked override fun create(): AlertDialog { mDialog = super.create() return mDialog as AlertDialog } init { val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_list_checkbox_builder, null) setView(view) val listView = ViewUtils.`$$`(view, R.id.list_view) as ListView mCheckBox = ViewUtils.`$$`(view, R.id.checkbox) as CheckBox listView.adapter = ArrayAdapter(getContext(), R.layout.item_select_dialog, items) listView.onItemClickListener = AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long -> listener(this@ListCheckBoxDialogBuilder, mDialog, position) mDialog?.dismiss() } mCheckBox.text = checkText mCheckBox.isChecked = checked } } ================================================ FILE: app/src/main/java/com/hippo/database/MSQLiteBuilder.kt ================================================ /* * Copyright 2017 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.database import android.content.Context import android.database.sqlite.SQLiteOpenHelper import android.util.SparseArray class MSQLiteBuilder { companion object { const val COLUMN_ID = "_id" private val JAVA_TYPE_TO_SQLITE_TYPE: MutableMap?, String> = HashMap() private fun javaTypeToSQLiteType(clazz: Class<*>?): String = JAVA_TYPE_TO_SQLITE_TYPE[clazz] ?: throw IllegalStateException("Unknown type: $clazz") init { JAVA_TYPE_TO_SQLITE_TYPE[Boolean::class.javaPrimitiveType] = "INTEGER NOT NULL DEFAULT 0" JAVA_TYPE_TO_SQLITE_TYPE[Byte::class.javaPrimitiveType] = "INTEGER NOT NULL DEFAULT 0" JAVA_TYPE_TO_SQLITE_TYPE[Short::class.javaPrimitiveType] = "INTEGER NOT NULL DEFAULT 0" JAVA_TYPE_TO_SQLITE_TYPE[Int::class.javaPrimitiveType] = "INTEGER NOT NULL DEFAULT 0" JAVA_TYPE_TO_SQLITE_TYPE[Long::class.javaPrimitiveType] = "INTEGER NOT NULL DEFAULT 0" JAVA_TYPE_TO_SQLITE_TYPE[Float::class.javaPrimitiveType] = "REAL NOT NULL DEFAULT 0" JAVA_TYPE_TO_SQLITE_TYPE[Double::class.javaPrimitiveType] = "REAL NOT NULL DEFAULT 0" JAVA_TYPE_TO_SQLITE_TYPE[String::class.java] = "TEXT" } } private val statementsMap = SparseArray?>() private var version = 0 private var statements: MutableList? = null /** * Bump database version. */ fun version(version: Int): MSQLiteBuilder { check(version > this.version) { ( "New version must be bigger than current version. " + "current version: " + this.version + ", new version: " + version + "." ) } this.version = version statements = ArrayList() statementsMap.put(version, statements) return this } /** * Creates a table with int [.COLUMN_ID] primary key. */ fun createTable( table: String, column: String = COLUMN_ID, clazz: Class<*>? = Int::class.javaPrimitiveType, ): MSQLiteBuilder = statement("CREATE TABLE " + table + " (" + column + " " + javaTypeToSQLiteType(clazz) + " PRIMARY KEY);") /** * Drops a table. */ fun dropTable(table: String): MSQLiteBuilder = statement("DROP TABLE $table;") /** * Inserts a column to the table. */ fun insertColumn(table: String, column: String, clazz: Class<*>?): MSQLiteBuilder = statement( "ALTER TABLE $table ADD COLUMN $column " + javaTypeToSQLiteType( clazz, ) + ";", ) /** * Add a statement. */ fun statement(statement: String): MSQLiteBuilder { check(!(version == 0 || statements == null)) { "Call version() first!" } statements!!.add(statement) return this } /** * Build a SQLiteOpenHelper from it. */ fun build(context: Context?, name: String?, version: Int): SQLiteOpenHelper = MSQLiteOpenHelper(context, name, version, this) fun getStatements(oldVersion: Int, newVersion: Int): List { val result: MutableList = ArrayList() for (i in oldVersion + 1..newVersion) { val list = statementsMap[i] if (list != null) { result.addAll(list) } } return result } } ================================================ FILE: app/src/main/java/com/hippo/database/MSQLiteOpenHelper.kt ================================================ /* * Copyright 2017 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.database import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import com.hippo.util.SqlUtils internal class MSQLiteOpenHelper( context: Context?, name: String?, private val version: Int, private val builder: MSQLiteBuilder, ) : SQLiteOpenHelper(context, name, null, version) { override fun onCreate(db: SQLiteDatabase) { onUpgrade(db, 0, version) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { for (command in builder.getStatements(oldVersion, newVersion)) { db.execSQL(command) } } override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { SqlUtils.dropAllTable(db) onCreate(db) } } ================================================ FILE: app/src/main/java/com/hippo/drawable/AddDeleteDrawable.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.drawable import android.animation.ObjectAnimator import android.content.Context import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PixelFormat import android.graphics.drawable.Drawable import androidx.annotation.Keep import androidx.core.graphics.withTranslation import com.hippo.ehviewer.R import com.hippo.yorozuya.MathUtils import kotlin.math.roundToInt /** * @param context used to get the configuration for the drawable from */ class AddDeleteDrawable(context: Context, color: Int) : Drawable() { private val mPaint = Paint() private val mPath = Path() private val mSize: Int private var mProgress = 0f private var mAutoUpdateMirror = false private var mVerticalMirror = false init { val resources = context.resources mSize = resources.getDimensionPixelSize(R.dimen.add_size) val barThickness = resources.getDimension(R.dimen.add_thickness).roundToInt().toFloat() mPaint.setColor(color) mPaint.style = Paint.Style.STROKE mPaint.strokeJoin = Paint.Join.MITER mPaint.strokeCap = Paint.Cap.BUTT mPaint.strokeWidth = barThickness val halfSize = (mSize / 2).toFloat() mPath.moveTo(0f, -halfSize) mPath.lineTo(0f, halfSize) mPath.moveTo(-halfSize, 0f) mPath.lineTo(halfSize, 0f) } override fun draw(canvas: Canvas) { val bounds = getBounds() val canvasRotate: Float = if (mVerticalMirror) { MathUtils.lerp(270f, 135f, mProgress) } else { MathUtils.lerp(0f, 135f, mProgress) } canvas.withTranslation(bounds.centerX().toFloat(), bounds.centerY().toFloat()) { rotate(canvasRotate) drawPath(mPath, mPaint) } } fun setColor(color: Int) { mPaint.setColor(color) invalidateSelf() } override fun setAlpha(alpha: Int) { mPaint.setAlpha(alpha) } override fun setColorFilter(cf: ColorFilter?) { mPaint.setColorFilter(cf) } override fun getIntrinsicHeight(): Int = mSize * 6 / 5 override fun getIntrinsicWidth(): Int = mSize * 6 / 5 /** * If set, canvas is flipped when progress reached to end and going back to start. */ private fun setVerticalMirror(verticalMirror: Boolean) { mVerticalMirror = verticalMirror } @get:Keep @set:Keep var progress: Float get() = mProgress set(progress) { if (mAutoUpdateMirror) { if (progress == 1f) { setVerticalMirror(true) } else if (progress == 0f) { setVerticalMirror(false) } } mProgress = progress invalidateSelf() } fun setAdd(duration: Long) { setShape(false, duration) } fun setDelete(duration: Long) { setShape(true, duration) } private fun setShape(delete: Boolean, duration: Long) { if (!(!delete && mProgress == 0f || delete && mProgress == 1f)) { val endProgress = if (delete) 1f else 0f if (duration <= 0) { progress = endProgress } else { val oa = ObjectAnimator.ofFloat(this, "progress", endProgress) oa.setDuration(duration) oa.setAutoCancel(true) oa.start() } } } @Deprecated( "Deprecated in Java", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"), ) override fun getOpacity(): Int = PixelFormat.TRANSLUCENT } ================================================ FILE: app/src/main/java/com/hippo/drawable/BatteryDrawable.kt ================================================ /* * Copyright (C) 2014 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.drawable import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.drawable.Drawable import com.hippo.yorozuya.MathUtils import kotlin.math.sqrt class BatteryDrawable : Drawable() { private val mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG) private val mTopRect: Rect private val mBottomRect: Rect private val mRightRect: Rect private val mHeadRect: Rect private val mElectRect: Rect private var mColor = Color.WHITE private var mWarningColor = Color.RED private var mElect = -1 private var mStart = 0 private var mStop = 0 init { mPaint.style = Paint.Style.FILL mTopRect = Rect() mBottomRect = Rect() mRightRect = Rect() mHeadRect = Rect() mElectRect = Rect() updatePaint() } override fun onBoundsChange(bounds: Rect) { val width = bounds.width() val height = bounds.height() val strokeWidth = (sqrt((width * width + height * height).toDouble()) * 0.06f).toInt() val turn1 = width * 6 / 7 val turn2 = height / 3 val secBottom = height - strokeWidth mStart = strokeWidth mStop = turn1 - strokeWidth mTopRect[0, 0, turn1] = strokeWidth mBottomRect[0, secBottom, turn1] = height mRightRect[turn1 - strokeWidth, strokeWidth, turn1] = secBottom mHeadRect[turn1, turn2, width] = height - turn2 mElectRect[0, strokeWidth, mStop] = secBottom } /** * How to draw: * |------------------------------| * |\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\| * |------------------------------|---| * |/////////////////| |//|\\\| * |/////////////////| |//|\\\| * |------------------------------|---| * |\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\| * |------------------------------| */ override fun draw(canvas: Canvas) { if (mElect == -1) { return } mElectRect.right = MathUtils.lerp(mStart, mStop, mElect / 100.0f) canvas.drawRect(mTopRect, mPaint) canvas.drawRect(mBottomRect, mPaint) canvas.drawRect(mRightRect, mPaint) canvas.drawRect(mHeadRect, mPaint) canvas.drawRect(mElectRect, mPaint) } private val isWarn: Boolean get() = mElect <= WARN_LIMIT fun setColor(color: Int) { if (mColor == color) { return } mColor = color if (!isWarn) { mPaint.setColor(mColor) invalidateSelf() } } fun setWarningColor(color: Int) { if (mWarningColor == color) { return } mWarningColor = color if (isWarn) { mPaint.setColor(mWarningColor) invalidateSelf() } } fun setElect(elect: Int) { if (mElect == elect) { return } mElect = elect updatePaint() } fun setElect(elect: Int, warn: Boolean) { if (mElect == elect) { return } mElect = elect updatePaint(warn) } private fun updatePaint(warn: Boolean = isWarn) { if (warn) { mPaint.setColor(mWarningColor) } else { mPaint.setColor(mColor) } invalidateSelf() } override fun setAlpha(alpha: Int) { mPaint.setAlpha(alpha) } override fun setColorFilter(cf: ColorFilter?) { mPaint.setColorFilter(cf) } @Deprecated( "Deprecated in Java", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"), ) override fun getOpacity(): Int = PixelFormat.TRANSLUCENT companion object { const val WARN_LIMIT = 15 } } ================================================ FILE: app/src/main/java/com/hippo/drawable/DrawerArrowDrawable.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.drawable import android.animation.ObjectAnimator import android.content.Context import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PixelFormat import android.graphics.drawable.Drawable import androidx.annotation.ColorInt import androidx.annotation.Keep import androidx.core.graphics.withTranslation import com.hippo.ehviewer.R import com.hippo.yorozuya.MathUtils import kotlin.math.cos import kotlin.math.roundToInt import kotlin.math.sin /** * A drawable that can draw a "Drawer hamburger" menu or an Arrow and animate between them. * @param context used to get the configuration for the drawable from */ class DrawerArrowDrawable(context: Context, color: Int) : Drawable() { private val mPaint = Paint() private val mBarThickness: Float // The length of top and bottom bars when they merge into an arrow private val mTopBottomArrowSize: Float // The length of middle bar private val mBarSize: Float // The length of the middle bar when arrow is shaped private val mMiddleArrowSize: Float // The space between bars when they are parallel private val mBarGap: Float // Whether bars should spin or not during progress private val mSpin: Boolean // Use Path instead of canvas operations so that if color has transparency, overlapping sections // wont look different private val mPath = Path() // The reported intrinsic size of the drawable. private val mSize: Int // the amount that overlaps w/ bar size when rotation is max private val mMaxCutForBarSize: Float // Whether we should mirror animation when animation is reversed. private var mVerticalMirror = false // The interpolated version of the original progress private var mProgress = 0f init { val resources = context.resources mPaint.isAntiAlias = true mPaint.setColor(color) mSize = resources.getDimensionPixelSize(R.dimen.dad_drawable_size) // round this because having this floating may cause bad measurements mBarSize = resources.getDimension(R.dimen.dad_bar_size).roundToInt().toFloat() // round this because having this floating may cause bad measurements mTopBottomArrowSize = resources.getDimension(R.dimen.dad_top_bottom_bar_arrow_size).roundToInt().toFloat() mBarThickness = resources.getDimension(R.dimen.dad_thickness) // round this because having this floating may cause bad measurements mBarGap = resources.getDimension(R.dimen.dad_gap_between_bars).roundToInt().toFloat() mSpin = resources.getBoolean(R.bool.dad_spin_bars) mMiddleArrowSize = resources.getDimension(R.dimen.dad_middle_bar_arrow_size) mPaint.style = Paint.Style.STROKE mPaint.strokeJoin = Paint.Join.MITER mPaint.strokeCap = Paint.Cap.BUTT mPaint.strokeWidth = mBarThickness mMaxCutForBarSize = (mBarThickness / 2 * cos(ARROW_HEAD_ANGLE.toDouble())).toFloat() } /** * If set, canvas is flipped when progress reached to end and going back to start. */ private fun setVerticalMirror(verticalMirror: Boolean) { mVerticalMirror = verticalMirror } override fun draw(canvas: Canvas) { val bounds = getBounds() // Interpolated widths of arrow bars val arrowSize = MathUtils.lerp(mBarSize, mTopBottomArrowSize, mProgress) val middleBarSize = MathUtils.lerp(mBarSize, mMiddleArrowSize, mProgress) // Interpolated size of middle bar val middleBarCut = MathUtils.lerp(0f, mMaxCutForBarSize, mProgress).roundToInt().toFloat() // The rotation of the top and bottom bars (that make the arrow head) val rotation = MathUtils.lerp(0f, ARROW_HEAD_ANGLE, mProgress) // The whole canvas rotates as the transition happens val canvasRotate = MathUtils.lerp(-180, 0, mProgress).toFloat() val arrowWidth = (arrowSize * cos(rotation.toDouble())).roundToInt().toFloat() val arrowHeight = (arrowSize * sin(rotation.toDouble())).roundToInt().toFloat() mPath.rewind() val topBottomBarOffset = MathUtils.lerp( mBarGap + mBarThickness, -mMaxCutForBarSize, mProgress, ) val arrowEdge = -middleBarSize / 2 // draw middle bar mPath.moveTo(arrowEdge + middleBarCut, 0f) mPath.rLineTo(middleBarSize - middleBarCut * 2, 0f) // bottom bar mPath.moveTo(arrowEdge, topBottomBarOffset) mPath.rLineTo(arrowWidth, arrowHeight) // top bar mPath.moveTo(arrowEdge, -topBottomBarOffset) mPath.rLineTo(arrowWidth, -arrowHeight) mPath.close() canvas.withTranslation(bounds.centerX().toFloat(), bounds.centerY().toFloat()) { // Rotate the whole canvas if spinning, if not, rotate it 180 to get // the arrow pointing the other way for RTL. if (mSpin) { rotate(canvasRotate * if (mVerticalMirror) -1 else 1) } drawPath(mPath, mPaint) } } fun setColor(@ColorInt color: Int) { mPaint.setColor(color) invalidateSelf() } override fun setAlpha(i: Int) { mPaint.setAlpha(i) } override fun setColorFilter(colorFilter: ColorFilter?) { mPaint.setColorFilter(colorFilter) } override fun getIntrinsicHeight(): Int = mSize override fun getIntrinsicWidth(): Int = mSize @Deprecated( "Deprecated in Java", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"), ) override fun getOpacity(): Int = PixelFormat.TRANSLUCENT @get:Keep @set:Keep var progress: Float get() = mProgress set(progress) { if (progress == 1f) { setVerticalMirror(true) } else if (progress == 0f) { setVerticalMirror(false) } mProgress = progress invalidateSelf() } fun setMenu(duration: Long) { setShape(false, duration) } fun setArrow(duration: Long) { setShape(true, duration) } private fun setShape(arrow: Boolean, duration: Long) { if (!(!arrow && mProgress == 0f || arrow && mProgress == 1f)) { val endProgress = if (arrow) 1f else 0f if (duration <= 0) { progress = endProgress } else { val oa = ObjectAnimator.ofFloat(this, "progress", endProgress) oa.setDuration(duration) oa.setAutoCancel(true) oa.start() } } } companion object { // The angle in degrees that the arrow head is inclined at. private val ARROW_HEAD_ANGLE = Math.toRadians(45.0).toFloat() } } ================================================ FILE: app/src/main/java/com/hippo/drawable/PreciselyClipDrawable.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.drawable import android.graphics.Canvas import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable import android.graphics.drawable.DrawableWrapper import androidx.core.graphics.withClip /** * Show a part of the original drawable */ class PreciselyClipDrawable( drawable: Drawable, offsetX: Int, offsetY: Int, width: Int, height: Int, ) : DrawableWrapper(drawable) { private val mScale: RectF private val mTemp = Rect() init { val originWidth = drawable.intrinsicWidth.toFloat() val originHeight = drawable.intrinsicHeight.toFloat() mScale = RectF( (offsetX / originWidth).coerceIn(0.0f, 1.0f), (offsetY / originHeight).coerceIn(0.0f, 1.0f), ((offsetX + width) / originWidth).coerceIn(0.0f, 1.0f), ((offsetY + height) / originHeight).coerceIn(0.0f, 1.0f), ) } override fun onBoundsChange(bounds: Rect) { mTemp.left = ((mScale.left * bounds.right - mScale.right * bounds.left) / (mScale.left * (1 - mScale.right) - mScale.right * (1 - mScale.left))).toInt() mTemp.right = (((1 - mScale.right) * bounds.left - (1 - mScale.left) * bounds.right) / (mScale.left * (1 - mScale.right) - mScale.right * (1 - mScale.left))).toInt() mTemp.top = ((mScale.top * bounds.bottom - mScale.bottom * bounds.top) / (mScale.top * (1 - mScale.bottom) - mScale.bottom * (1 - mScale.top))).toInt() mTemp.bottom = (((1 - mScale.bottom) * bounds.top - (1 - mScale.top) * bounds.bottom) / (mScale.top * (1 - mScale.bottom) - mScale.bottom * (1 - mScale.top))).toInt() super.onBoundsChange(mTemp) } override fun getIntrinsicWidth(): Int = (super.intrinsicWidth * mScale.width()).toInt() override fun getIntrinsicHeight(): Int = (super.intrinsicHeight * mScale.height()).toInt() override fun draw(canvas: Canvas) { canvas.withClip(bounds) { super.draw(canvas) } } } ================================================ FILE: app/src/main/java/com/hippo/drawable/TriangleDrawable.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.drawable import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.drawable.Drawable class TriangleDrawable(color: Int) : Drawable() { private val mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG) private val mPath: Path init { mPaint.setColor(color) mPath = Path() } fun setColor(color: Int) { mPaint.setColor(color) invalidateSelf() } override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) mPath.reset() mPath.moveTo(bounds.left.toFloat(), bounds.top.toFloat()) mPath.lineTo(bounds.right.toFloat(), bounds.top.toFloat()) mPath.lineTo(bounds.right.toFloat(), bounds.bottom.toFloat()) mPath.close() } override fun draw(canvas: Canvas) { canvas.drawPath(mPath, mPaint) } override fun setAlpha(alpha: Int) { mPaint.setAlpha(alpha) } override fun setColorFilter(colorFilter: ColorFilter?) { mPaint.setColorFilter(colorFilter) } @Deprecated( "Deprecated in Java", ReplaceWith("PixelFormat.OPAQUE", "android.graphics.PixelFormat"), ) override fun getOpacity(): Int = PixelFormat.OPAQUE } ================================================ FILE: app/src/main/java/com/hippo/drawable/UnikeryDrawable.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.drawable import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import coil3.Image import coil3.asDrawable import coil3.imageLoader import coil3.request.Disposable import coil3.request.ImageRequest import com.hippo.widget.ObservedTextView class UnikeryDrawable(private val mTextView: ObservedTextView) : WrapDrawable(), ObservedTextView.OnWindowAttachListener { private var mUrl: String? = null private var task: Disposable? = null init { mTextView.setOnWindowAttachListener(this) } override fun onAttachedToWindow() { load(mUrl) } override fun onDetachedFromWindow() { if (task != null && !task!!.isDisposed) task!!.dispose() clearDrawable() } fun load(url: String?) { if (url != null) { mUrl = url val request = ImageRequest.Builder(mTextView.context).data(url) .memoryCacheKey(url) .diskCacheKey(url) .target(onSuccess = ::onGetValue) .build() task = mTextView.context.imageLoader.enqueue(request) } } private fun clearDrawable() { drawable = null } override var drawable: Drawable? get() = super.drawable set(newDrawable) { // Remove old callback val oldDrawable = drawable oldDrawable?.callback = null super.drawable = newDrawable newDrawable?.callback = mTextView updateBounds() if (newDrawable != null) { invalidateSelf() } } override fun invalidateSelf() { val cs = mTextView.getText() mTextView.text = cs } private fun onGetValue(image: Image) { clearDrawable() drawable = image.asDrawable(mTextView.resources) (drawable as? Animatable)?.start() } } ================================================ FILE: app/src/main/java/com/hippo/drawable/WrapDrawable.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.drawable import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.drawable.Drawable open class WrapDrawable : Drawable() { open var drawable: Drawable? = null fun updateBounds() { setBounds(0, 0, intrinsicWidth, intrinsicHeight) } override fun draw(canvas: Canvas) { drawable?.draw(canvas) } override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { super.setBounds(left, top, right, bottom) drawable?.setBounds(left, top, right, bottom) } override fun setBounds(bounds: Rect) { super.bounds = bounds drawable?.bounds = bounds } override fun getChangingConfigurations(): Int = drawable?.changingConfigurations ?: super.changingConfigurations override fun setChangingConfigurations(configs: Int) { super.changingConfigurations = configs drawable?.changingConfigurations = configs } override fun setFilterBitmap(filter: Boolean) { super.setFilterBitmap(filter) drawable?.setFilterBitmap(filter) } override fun setAlpha(alpha: Int) { drawable?.alpha = alpha } override fun setColorFilter(cf: ColorFilter?) { drawable?.colorFilter = cf } @Deprecated( "Deprecated in Java", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"), ) override fun getOpacity(): Int = PixelFormat.TRANSLUCENT override fun getIntrinsicWidth(): Int = drawable?.intrinsicWidth ?: super.intrinsicWidth override fun getIntrinsicHeight(): Int = drawable?.intrinsicHeight ?: super.intrinsicHeight } ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/EasyRecyclerView.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.content.Context import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import android.util.SparseBooleanArray import android.view.ActionMode import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Checkable import androidx.collection.LongSparseArray import androidx.core.util.isEmpty import androidx.core.util.size import androidx.recyclerview.widget.RecyclerView import com.hippo.util.readParcelableCompat import com.hippo.yorozuya.NumberUtils /** * Add setChoiceMode for RecyclerView */ // Get some code from twoway-view and AbsListView. open class EasyRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : RecyclerView( context, attrs, defStyle, ) { /** * Wrapper for the multiple choice mode callback; AbsListView needs to perform * a few extra actions around what application code does. */ private var mMultiChoiceModeCallback: MultiChoiceModeWrapper? = null /** * Controls if/how the user may choose/check items in the list */ private var mChoiceMode = CHOICE_MODE_NONE /** * Controls CHOICE_MODE_MULTIPLE_MODAL. null when inactive. */ private var mChoiceActionMode: ActionMode? = null /** * Listener for custom multiple choices */ private var mCustomChoiceListener: CustomChoiceListener? = null var isInCustomChoice = false private set /** * A lock, avoid OutOfCustomChoiceMode when doing OutOfCustomChoiceMode */ private var mOutOfCustomChoiceModing = false private var mTempCheckStates: SparseBooleanArray? = null /** * Running count of how many items are currently checked */ var checkedItemCount = 0 private set /** * Running state of which positions are currently checked */ private var mCheckStates: SparseBooleanArray? = null /** * Running state of which IDs are currently checked. * If there is a value for a given key, the checked state for that ID is true * and the value holds the last known position in the adapter for that id. */ private var mCheckedIdStates: LongSparseArray? = null private var mAdapter: Adapter<*>? = null /** * {@inheritDoc} */ override fun setAdapter(adapter: Adapter<*>?) { super.setAdapter(adapter) mAdapter = adapter if (adapter != null && adapter.hasStableIds() && mChoiceMode != CHOICE_MODE_NONE && mCheckedIdStates == null ) { mCheckedIdStates = LongSparseArray() } mCheckStates?.clear() mCheckedIdStates?.clear() } override fun onChildAttachedToWindow(child: View) { super.onChildAttachedToWindow(child) if (mCheckStates != null) { val position = getChildAdapterPosition(child) if (position >= 0) { setViewChecked(child, mCheckStates!![position]) } } } /** * Returns the checked state of the specified position. The result is only * valid if the choice mode has been set to [.CHOICE_MODE_SINGLE] * or [.CHOICE_MODE_MULTIPLE]. * * @param position The item whose checked state to return * @return The item's checked state or `false` if choice mode * is invalid * @see .setChoiceMode */ private fun isItemChecked(position: Int): Boolean = if (mChoiceMode != CHOICE_MODE_NONE && mCheckStates != null) { mCheckStates!![position] } else { false } /** * Returns the set of checked items in the list. The result is only valid if * the choice mode has not been set to [.CHOICE_MODE_NONE]. * * @return A SparseBooleanArray which will return true for each call to * get(int position) where position is a checked position in the * list and false otherwise, or `null` if the choice * mode is set to [.CHOICE_MODE_NONE]. */ val checkedItemPositions: SparseBooleanArray? get() = if (mChoiceMode != CHOICE_MODE_NONE) { mCheckStates } else { null } fun intoCustomChoiceMode() { if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && !isInCustomChoice) { isInCustomChoice = true mCustomChoiceListener!!.onIntoCustomChoice(this) } } fun outOfCustomChoiceMode() { if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && isInCustomChoice && !mOutOfCustomChoiceModing) { mOutOfCustomChoiceModing = true // Copy mCheckStates mTempCheckStates!!.clear() run { for (i in 0 until mCheckStates!!.size) { mTempCheckStates!!.put(mCheckStates!!.keyAt(i), mCheckStates!!.valueAt(i)) } } // Uncheck remain checked items for (i in 0 until mTempCheckStates!!.size) { if (mTempCheckStates!!.valueAt(i)) { setItemChecked(mTempCheckStates!!.keyAt(i), false) } } isInCustomChoice = false mCustomChoiceListener!!.onOutOfCustomChoice(this) mOutOfCustomChoiceModing = false } } /** * Clear any choices previously set */ private fun clearChoices() { mCheckStates?.clear() mCheckedIdStates?.clear() checkedItemCount = 0 updateOnScreenCheckedViews() } /** * Sets all items checked. */ fun checkAll() { if (mChoiceMode == CHOICE_MODE_NONE || mChoiceMode == CHOICE_MODE_SINGLE) { return } // Check is intoCheckMode check(!(mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && !isInCustomChoice)) { "Call intoCheckMode first" } // Start selection mode if needed. We don't need to if we're unchecking something. if (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL && mChoiceActionMode == null) { check( !( mMultiChoiceModeCallback == null || !mMultiChoiceModeCallback!!.hasWrappedCallback() ), ) { "EasyRecyclerView: attempted to start selection mode " + "for CHOICE_MODE_MULTIPLE_MODAL but no choice mode callback was " + "supplied. Call setMultiChoiceModeListener to set a callback." } mChoiceActionMode = startActionMode(mMultiChoiceModeCallback) } for (i in 0 until mAdapter!!.itemCount) { val oldValue = mCheckStates!![i] mCheckStates!!.put(i, true) if (mCheckedIdStates != null && mAdapter!!.hasStableIds()) { mCheckedIdStates!!.put(mAdapter!!.getItemId(i), i) } if (!oldValue) { checkedItemCount++ } if (mChoiceActionMode != null) { val id = mAdapter!!.getItemId(i) mMultiChoiceModeCallback!!.onItemCheckedStateChanged( mChoiceActionMode!!, i, id, true, ) } if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { val id = mAdapter!!.getItemId(i) mCustomChoiceListener!!.onItemCheckedStateChanged(this, i, id, true) } } updateOnScreenCheckedViews() } fun toggleItemChecked(position: Int) { if (mCheckStates != null) { setItemChecked(position, !mCheckStates!![position]) } } /** * Sets the checked state of the specified position. The is only valid if * the choice mode has been set to [.CHOICE_MODE_SINGLE] or * [.CHOICE_MODE_MULTIPLE]. * * @param position The item whose checked state is to be checked * @param value The new checked state for the item */ private fun setItemChecked(position: Int, value: Boolean) { if (mChoiceMode == CHOICE_MODE_NONE) { return } // Check is intoCheckMode check(!(mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM && !isInCustomChoice)) { "Call intoCheckMode first" } // Start selection mode if needed. We don't need to if we're unchecking something. if (value && mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL && mChoiceActionMode == null) { check( !( mMultiChoiceModeCallback == null || !mMultiChoiceModeCallback!!.hasWrappedCallback() ), ) { "EasyRecyclerView: attempted to start selection mode " + "for CHOICE_MODE_MULTIPLE_MODAL but no choice mode callback was " + "supplied. Call setMultiChoiceModeListener to set a callback." } mChoiceActionMode = startActionMode(mMultiChoiceModeCallback) } if (mChoiceMode == CHOICE_MODE_MULTIPLE || mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL || mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { val oldValue = mCheckStates!![position] mCheckStates!!.put(position, value) if (mCheckedIdStates != null && mAdapter!!.hasStableIds()) { if (value) { mCheckedIdStates!!.put(mAdapter!!.getItemId(position), position) } else { mCheckedIdStates!!.remove(mAdapter!!.getItemId(position)) } } if (oldValue != value) { if (value) { checkedItemCount++ } else { checkedItemCount-- } } if (mChoiceActionMode != null) { val id = mAdapter!!.getItemId(position) mMultiChoiceModeCallback!!.onItemCheckedStateChanged( mChoiceActionMode!!, position, id, value, ) } if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { val id = mAdapter!!.getItemId(position) mCustomChoiceListener!!.onItemCheckedStateChanged(this, position, id, value) } } else { val updateIds = mCheckedIdStates != null && mAdapter!!.hasStableIds() // Clear all values if we're checking something, or unchecking the currently // selected item if (value || isItemChecked(position)) { mCheckStates!!.clear() if (updateIds) { mCheckedIdStates!!.clear() } } // this may end up selecting the value we just cleared but this way // we ensure length of mCheckStates is 1, a fact getCheckedItemPosition relies on if (value) { mCheckStates!!.put(position, true) if (updateIds) { mCheckedIdStates!!.put(mAdapter!!.getItemId(position), position) } checkedItemCount = 1 } else if (mCheckStates!!.isEmpty() || !mCheckStates!!.valueAt(0)) { checkedItemCount = 0 } } updateOnScreenCheckedViews() } /** * Defines the choice behavior for the List. By default, Lists do not have any choice behavior * ([.CHOICE_MODE_NONE]). By setting the choiceMode to [.CHOICE_MODE_SINGLE], the * List allows up to one item to be in a chosen state. By setting the choiceMode to * [.CHOICE_MODE_MULTIPLE], the list allows any number of items to be chosen. * * @param choiceMode One of [.CHOICE_MODE_NONE], [.CHOICE_MODE_SINGLE], or * [.CHOICE_MODE_MULTIPLE] */ fun setChoiceMode(choiceMode: Int) { mChoiceMode = choiceMode if (mChoiceActionMode != null) { mChoiceActionMode!!.finish() mChoiceActionMode = null } if (mChoiceMode != CHOICE_MODE_NONE) { if (mCheckStates == null) { mCheckStates = SparseBooleanArray(0) } if (mCheckedIdStates == null && mAdapter != null && mAdapter!!.hasStableIds()) { mCheckedIdStates = LongSparseArray(0) } // Modal multi-choice mode only has choices when the mode is active. Clear them. if (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL) { clearChoices() isLongClickable = true } else if (mChoiceMode == CHOICE_MODE_MULTIPLE_CUSTOM) { if (mTempCheckStates == null) { mTempCheckStates = SparseBooleanArray(0) } clearChoices() } } } fun setCustomCheckedListener(listener: CustomChoiceListener?) { mCustomChoiceListener = listener } /** * Perform a quick, in-place update of the checked or activated state * on all visible item views. This should only be called when a valid * choice mode is active. */ private fun updateOnScreenCheckedViews() { for (i in 0 until childCount) { val child = getChildAt(i) val position = getChildAdapterPosition(child) setViewChecked(child, mCheckStates!![position]) } } public override fun onSaveInstanceState(): Parcelable { val ss = SavedState(super.onSaveInstanceState()) ss.choiceMode = mChoiceMode ss.customChoice = isInCustomChoice ss.checkedItemCount = checkedItemCount ss.checkState = mCheckStates ss.checkIdState = mCheckedIdStates return ss } public override fun onRestoreInstanceState(state: Parcelable) { val ss = state as SavedState super.onRestoreInstanceState(ss.superState) setChoiceMode(ss.choiceMode) isInCustomChoice = ss.customChoice checkedItemCount = ss.checkedItemCount if (ss.checkState != null) { mCheckStates = ss.checkState } if (ss.checkIdState != null) { mCheckedIdStates = ss.checkIdState } if (mChoiceMode == CHOICE_MODE_MULTIPLE_MODAL && checkedItemCount > 0) { mChoiceActionMode = startActionMode(mMultiChoiceModeCallback) } updateOnScreenCheckedViews() } /** * A MultiChoiceModeListener receives events for [android.widget.AbsListView.CHOICE_MODE_MULTIPLE_MODAL]. * It acts as the [android.view.ActionMode.Callback] for the selection mode and also receives * [.onItemCheckedStateChanged] events when the user * selects and deselects list items. */ interface MultiChoiceModeListener : ActionMode.Callback { /** * Called when an item is checked or unchecked during selection mode. * * @param mode The [android.view.ActionMode] providing the selection mode * @param position Adapter position of the item that was checked or unchecked * @param id Adapter ID of the item that was checked or unchecked * @param checked `true` if the item is now checked, `false` * if the item is now unchecked. */ fun onItemCheckedStateChanged( mode: ActionMode, position: Int, id: Long, checked: Boolean, ) } /** * Custom checked */ interface CustomChoiceListener { fun onIntoCustomChoice(view: EasyRecyclerView) fun onOutOfCustomChoice(view: EasyRecyclerView) fun onItemCheckedStateChanged( view: EasyRecyclerView, position: Int, id: Long, checked: Boolean, ) } /** * This saved state class is a Parcelable and should not extend * [android.view.View.BaseSavedState] nor [android.view.AbsSavedState] * because its super class AbsSavedState's constructor * currently passes null * as a class loader to read its superstate from Parcelable. * This causes [android.os.BadParcelableException] when restoring saved states. * * * The super class "RecyclerView" is a part of the support library, * and restoring its saved state requires the class loader that loaded the RecyclerView. * It seems that the class loader is not required when restoring from RecyclerView itself, * but it is required when restoring from RecyclerView's subclasses. */ internal open class SavedState : Parcelable { var choiceMode = 0 var customChoice = false var checkedItemCount = 0 var checkState: SparseBooleanArray? = null var checkIdState: LongSparseArray? = null // This keeps the parent(RecyclerView)'s state var superState: Parcelable? constructor() { superState = null } /** * Constructor called from [.onSaveInstanceState] */ constructor(superState: Parcelable?) { this.superState = if (superState !== EMPTY_STATE) superState else null } /** * Constructor called from [.CREATOR] */ private constructor(`in`: Parcel) { // Parcel 'in' has its parent(RecyclerView)'s saved state. // To restore it, class loader that loaded RecyclerView is required. val superState = `in`.readParcelableCompat(RecyclerView::class.java.getClassLoader()) this.superState = superState ?: EMPTY_STATE choiceMode = `in`.readInt() customChoice = NumberUtils.int2boolean(`in`.readInt()) checkedItemCount = `in`.readInt() checkState = `in`.readSparseBooleanArray() val n = `in`.readInt() if (n > 0) { checkIdState = LongSparseArray() (0 until n).forEach { i -> val key = `in`.readLong() val value = `in`.readInt() checkIdState!!.put(key, value) } } } override fun describeContents(): Int = 0 override fun writeToParcel(out: Parcel, flags: Int) { out.writeParcelable(superState, flags) out.writeInt(choiceMode) out.writeInt(NumberUtils.boolean2int(customChoice)) out.writeInt(checkedItemCount) out.writeSparseBooleanArray(checkState) val n = if (checkIdState != null) checkIdState!!.size() else 0 out.writeInt(n) for (i in 0 until n) { out.writeLong(checkIdState!!.keyAt(i)) out.writeInt(checkIdState!!.valueAt(i)) } } companion object CREATOR : Parcelable.Creator { override fun createFromParcel(`in`: Parcel): SavedState = SavedState(`in`) override fun newArray(size: Int): Array = arrayOfNulls(size) } } inner class MultiChoiceModeWrapper : MultiChoiceModeListener { private val mWrapped: MultiChoiceModeListener? = null fun hasWrappedCallback(): Boolean = mWrapped != null override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { if (mWrapped!!.onCreateActionMode(mode, menu)) { // Initialize checked graphic state? isLongClickable = false return true } return false } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = mWrapped!!.onPrepareActionMode(mode, menu) override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = mWrapped!!.onActionItemClicked(mode, item) override fun onDestroyActionMode(mode: ActionMode) { mWrapped!!.onDestroyActionMode(mode) mChoiceActionMode = null // Ending selection mode means deselecting everything. clearChoices() requestLayout() isLongClickable = true } override fun onItemCheckedStateChanged( mode: ActionMode, position: Int, id: Long, checked: Boolean, ) { mWrapped!!.onItemCheckedStateChanged(mode, position, id, checked) // If there are no items selected we no longer need the selection mode. if (checkedItemCount == 0) { mode.finish() } } } companion object { /** * Normal list that does not indicate choices */ const val CHOICE_MODE_NONE = 0 /** * The list allows up to one choice */ const val CHOICE_MODE_SINGLE = 1 /** * The list allows multiple choices */ const val CHOICE_MODE_MULTIPLE = 2 /** * The list allows multiple choices in a modal selection mode */ const val CHOICE_MODE_MULTIPLE_MODAL = 3 /** * The list allows multiple choices in custom action */ const val CHOICE_MODE_MULTIPLE_CUSTOM = 4 private val EMPTY_STATE: SavedState = object : SavedState() {} private fun setViewChecked(view: View, checked: Boolean) { if (view is Checkable) { (view as Checkable).isChecked = checked } else { view.isActivated = checked } } } } ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/FastScroller.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.animation.Animator import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.drawable.Drawable import android.os.Handler import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import androidx.core.content.withStyledAttributes import androidx.core.graphics.withTranslation import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import com.hippo.ehviewer.R import com.hippo.yorozuya.AnimationUtils import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.SimpleAnimatorListener import com.hippo.yorozuya.SimpleHandler import kotlin.math.abs import kotlin.math.max import kotlin.math.min class FastScroller @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : View( context, attrs, defStyleAttr, ) { private var mSimpleHandler: Handler? = null private var mDraggable = false private var mMinHandlerHeight = 0 private var mRecyclerView: RecyclerView? = null private var mOnScrollChangeListener: RecyclerView.OnScrollListener? = null private var mAdapter: RecyclerView.Adapter<*>? = null private var mAdapterDataObserver: AdapterDataObserver? = null private var mHandler: Drawable? = null private var mHandlerOffset = INVALID private var mHandlerHeight = INVALID private var mDownX = INVALID.toFloat() private var mDownY = INVALID.toFloat() private var mLastMotionY = INVALID.toFloat() private var mDragged = false private var mCantDrag = false private var mTouchSlop = 0 private var mListener: OnDragHandlerListener? = null private var mShowAnimator: ObjectAnimator? = null private var mHideAnimator: ObjectAnimator? = null private val mHideRunnable = Runnable { mHideAnimator!!.start() } init { init(context, attrs, defStyleAttr) } private fun init(context: Context, attrs: AttributeSet?, defStyleAttr: Int) { mSimpleHandler = SimpleHandler.getInstance() context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr, 0) { mHandler = getDrawable(R.styleable.FastScroller_handler) mDraggable = getBoolean(R.styleable.FastScroller_draggable, true) } setAlpha(0.0f) visibility = INVISIBLE mMinHandlerHeight = LayoutUtils.dp2pix(context, MIN_HANDLER_HEIGHT_DP.toFloat()) mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop mShowAnimator = ObjectAnimator.ofFloat(this, "alpha", 1.0f) mShowAnimator!!.interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR mShowAnimator!!.setDuration(SCROLL_BAR_FADE_DURATION.toLong()) mHideAnimator = ObjectAnimator.ofFloat(this, "alpha", 0.0f) mHideAnimator!!.interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR mHideAnimator!!.setDuration(SCROLL_BAR_FADE_DURATION.toLong()) mHideAnimator!!.addListener(object : SimpleAnimatorListener() { private var mCancel = false override fun onAnimationCancel(animation: Animator) { mCancel = true } override fun onAnimationEnd(animation: Animator) { if (mCancel) { mCancel = false } else { visibility = INVISIBLE } } }) } fun setOnDragHandlerListener(listener: OnDragHandlerListener?) { mListener = listener } private fun updatePosition(show: Boolean) { if (mRecyclerView == null) { return } val paddingTop = paddingTop val paddingBottom = paddingBottom val height = height - paddingTop - paddingBottom val offset = mRecyclerView!!.computeVerticalScrollOffset() val extent = mRecyclerView!!.computeVerticalScrollExtent() val range = mRecyclerView!!.computeVerticalScrollRange() if (height <= 0 || extent >= range || extent <= 0) { return } var endOffset = height.toLong() * offset / range var endHeight = height * extent / range endHeight = max(endHeight.toDouble(), mMinHandlerHeight.toDouble()).toInt() endOffset = min(endOffset.toDouble(), (height - endHeight).toDouble()).toLong() mHandlerOffset = (endOffset + paddingTop).toInt() mHandlerHeight = endHeight if (show) { if (mHideAnimator!!.isRunning) { mHideAnimator!!.cancel() mShowAnimator!!.start() } else if (visibility != VISIBLE && !mShowAnimator!!.isRunning) { visibility = VISIBLE mShowAnimator!!.start() } val handler = mSimpleHandler handler!!.removeCallbacks(mHideRunnable) if (!mDragged) { handler.postDelayed(mHideRunnable, SCROLL_BAR_DELAY.toLong()) } } } fun setHandlerDrawable(drawable: Drawable?) { mHandler = drawable invalidate() } var isDraggable: Boolean get() = mDraggable set(draggable) { mDraggable = draggable if (mDragged) { mDragged = false } mSimpleHandler!!.removeCallbacks(mHideRunnable) mHideRunnable.run() } val isAttached: Boolean get() = mRecyclerView != null fun attachToRecyclerView(recyclerView: RecyclerView?) { if (recyclerView == null) { return } check(mRecyclerView == null) { "The FastScroller is already attached to a RecyclerView, " + "call detachedFromRecyclerView first" } mRecyclerView = recyclerView mOnScrollChangeListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { updatePosition(true) invalidate() } } recyclerView.addOnScrollListener(mOnScrollChangeListener!!) mAdapter = recyclerView.adapter if (mAdapter != null) { mAdapterDataObserver = object : AdapterDataObserver() { override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { super.onItemRangeChanged(positionStart, itemCount) updatePosition(false) invalidate() } override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { super.onItemRangeChanged(positionStart, itemCount, payload) updatePosition(false) invalidate() } override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { super.onItemRangeInserted(positionStart, itemCount) updatePosition(false) invalidate() } override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { super.onItemRangeRemoved(positionStart, itemCount) updatePosition(false) invalidate() } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { super.onItemRangeMoved(fromPosition, toPosition, itemCount) updatePosition(false) invalidate() } } mAdapter!!.registerAdapterDataObserver(mAdapterDataObserver!!) } } fun detachedFromRecyclerView() { if (mRecyclerView != null && mOnScrollChangeListener != null) { mRecyclerView!!.removeOnScrollListener(mOnScrollChangeListener!!) } mRecyclerView = null mOnScrollChangeListener = null if (mAdapter != null && mAdapterDataObserver != null) { mAdapter!!.unregisterAdapterDataObserver(mAdapterDataObserver!!) } mAdapter = null mAdapterDataObserver = null setAlpha(0.0f) visibility = INVISIBLE } override fun onDraw(canvas: Canvas) { if (mRecyclerView == null || mHandler == null) { return } if (mHandlerHeight == INVALID) { updatePosition(false) } if (mHandlerHeight == INVALID) { return } val paddingLeft = getPaddingLeft() canvas.withTranslation(paddingLeft.toFloat(), mHandlerOffset.toFloat()) { mHandler!!.setBounds(0, 0, width - paddingLeft - getPaddingRight(), mHandlerHeight) mHandler!!.draw(canvas) } } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (!mDraggable || visibility != VISIBLE || mRecyclerView == null || mHandlerHeight == INVALID) { return false } val action = event.action if (action == MotionEvent.ACTION_DOWN) { mCantDrag = false } if (mCantDrag) { return false } when (action) { MotionEvent.ACTION_DOWN -> { mDragged = false mDownX = event.x mDownY = event.y if (mDownY < mHandlerOffset || mDownY > mHandlerOffset + mHandlerHeight) { mCantDrag = true return false } } MotionEvent.ACTION_MOVE -> { if (!mDragged) { val x = event.x val y = event.y // Check touch slop if (MathUtils.dist(x, y, mDownX, mDownY) < mTouchSlop) { return true } if (abs((x - mDownX).toDouble()) > abs((y - mDownY).toDouble()) || y < mHandlerOffset || y > mHandlerOffset + mHandlerHeight) { mCantDrag = true return false } else { mDragged = true mSimpleHandler!!.removeCallbacks(mHideRunnable) // Update mLastMotionY mLastMotionY = if (mDownY < mHandlerOffset || mDownY >= mHandlerOffset + mHandlerHeight) { // the point out of handler, make the point in handler center mHandlerOffset + mHandlerHeight.toFloat() / 2 } else { mDownY } // Notify if (mListener != null) { mListener!!.onStartDragHandler() } } } val range = mRecyclerView!!.computeVerticalScrollRange() if (range <= 0) { return true } val y = event.y val scroll = (range * (y - mLastMotionY) / (height - paddingTop - paddingBottom)).toInt() mRecyclerView!!.scrollBy(0, scroll) mLastMotionY = y } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { // Notify if (mDragged && mListener != null) { mListener!!.onEndDragHandler() } mDragged = false mSimpleHandler!!.postDelayed(mHideRunnable, SCROLL_BAR_DELAY.toLong()) } } return true } interface OnDragHandlerListener { fun onStartDragHandler() fun onEndDragHandler() } companion object { private const val INVALID = -1 private const val SCROLL_BAR_FADE_DURATION = 500 private const val SCROLL_BAR_DELAY = 1500 private const val MIN_HANDLER_HEIGHT_DP = 48 } } ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/HandlerDrawable.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.PixelFormat import android.graphics.RectF import android.graphics.drawable.Drawable class HandlerDrawable : Drawable() { private val mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) private val mTemp = RectF() private var mColor = Color.BLACK init { mPaint.setColor(mColor) mPaint.style = Paint.Style.FILL } override fun draw(canvas: Canvas) { val width = getBounds().width() val height = getBounds().height() if (width > height) { canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), mPaint) } else { mTemp[0f, 0f, width.toFloat()] = width.toFloat() canvas.drawArc(mTemp, -180f, 180f, true, mPaint) mTemp[0f, (height - width).toFloat(), width.toFloat()] = height.toFloat() canvas.drawArc(mTemp, 0f, 180f, true, mPaint) val halfWidth = width.toFloat() / 2.0f canvas.drawRect(0f, halfWidth, width.toFloat(), height - halfWidth, mPaint) } } fun setColor(color: Int) { if (mColor != color) { mColor = color mPaint.setColor(color) } } override fun setAlpha(alpha: Int) { mPaint.setAlpha(alpha) } override fun setColorFilter(colorFilter: ColorFilter?) { mPaint.setColorFilter(colorFilter) } @Deprecated("Deprecated in Java") override fun getOpacity(): Int { val alpha = Color.alpha(mColor) return when (alpha) { 0xff -> { PixelFormat.OPAQUE } 0x00 -> { PixelFormat.TRANSPARENT } else -> { PixelFormat.TRANSLUCENT } } } } ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/LayoutManagerUtils.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.content.Context import android.graphics.PointF import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.SimpleHandler import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import kotlin.math.abs object LayoutManagerUtils { private var sCsdfp: Method? = null init { try { sCsdfp = StaggeredGridLayoutManager::class.java.getDeclaredMethod( "calculateScrollDirectionForPosition", Int::class.javaPrimitiveType, ) sCsdfp!!.isAccessible = true } catch (e: NoSuchMethodException) { // Ignore e.printStackTrace() } } fun scrollToPositionWithOffset( layoutManager: RecyclerView.LayoutManager, position: Int, offset: Int, ) { when (layoutManager) { is LinearLayoutManager -> { layoutManager.scrollToPositionWithOffset(position, offset) } is StaggeredGridLayoutManager -> { layoutManager.scrollToPositionWithOffset(position, offset) } else -> { throw IllegalStateException( "Can't do scrollToPositionWithOffset for " + layoutManager.javaClass.getName(), ) } } } fun smoothScrollToPosition( layoutManager: RecyclerView.LayoutManager, context: Context?, position: Int, millisecondsPerInch: Int = -1, ) { val smoothScroller: SimpleSmoothScroller when (layoutManager) { is LinearLayoutManager -> { smoothScroller = object : SimpleSmoothScroller(context!!, millisecondsPerInch.toFloat()) { override fun computeScrollVectorForPosition(targetPosition: Int): PointF? = layoutManager.computeScrollVectorForPosition(targetPosition) } } is StaggeredGridLayoutManager -> { smoothScroller = object : SimpleSmoothScroller(context!!, millisecondsPerInch.toFloat()) { override fun computeScrollVectorForPosition(targetPosition: Int): PointF? { var direction = 0 try { direction = sCsdfp!!.invoke(layoutManager, targetPosition) as Int } catch (e: IllegalAccessException) { e.printStackTrace() } catch (e: InvocationTargetException) { e.printStackTrace() } if (direction == 0) { return null } return if (layoutManager.orientation == StaggeredGridLayoutManager.HORIZONTAL) { PointF(direction.toFloat(), 0f) } else { PointF(0f, direction.toFloat()) } } } } else -> { throw IllegalStateException( "Can't do smoothScrollToPosition for " + layoutManager.javaClass.getName(), ) } } smoothScroller.targetPosition = position layoutManager.startSmoothScroll(smoothScroller) } fun scrollToPositionProperly( layoutManager: RecyclerView.LayoutManager, context: Context?, position: Int, listener: OnScrollToPositionListener?, ) { SimpleHandler.getInstance().postDelayed({ val first = getFirstVisibleItemPosition(layoutManager) val last = getLastVisibleItemPosition(layoutManager) val offset = abs((position - first).toDouble()).toInt() val max = last - first if (offset < max && max > 0) { smoothScrollToPosition( layoutManager, context, position, MathUtils.lerp(100, 25, (offset / max).toFloat()), ) } else { scrollToPositionWithOffset(layoutManager, position, 0) listener?.onScrollToPosition(position) } }, 200) } fun getFirstVisibleItemPosition(layoutManager: RecyclerView.LayoutManager): Int = when (layoutManager) { is LinearLayoutManager -> { layoutManager.findFirstVisibleItemPosition() } is StaggeredGridLayoutManager -> { val positions = layoutManager.findFirstVisibleItemPositions(null) MathUtils.min(*positions) } else -> { throw IllegalStateException( "Can't do getFirstVisibleItemPosition for " + layoutManager.javaClass.getName(), ) } } fun getLastVisibleItemPosition(layoutManager: RecyclerView.LayoutManager): Int = when (layoutManager) { is LinearLayoutManager -> { layoutManager.findLastVisibleItemPosition() } is StaggeredGridLayoutManager -> { val positions = layoutManager.findLastVisibleItemPositions(null) MathUtils.max(*positions) } else -> { throw IllegalStateException( "Can't do getLastVisibleItemPosition for " + layoutManager.javaClass.getName(), ) } } fun interface OnScrollToPositionListener { fun onScrollToPosition(position: Int) } } ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/LinearDividerItemDecoration.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration /** * Only work for [androidx.recyclerview.widget.LinearLayoutManager]. * Show divider between item, just like * [android.widget.ListView.setDivider] */ class LinearDividerItemDecoration(orientation: Int, color: Int, thickness: Int) : ItemDecoration() { private val mRect: Rect = Rect() private val mPaint: Paint = Paint() private var mShowFirstDivider = false private var mShowLastDivider = false private var mOrientation = 0 private var mThickness = 0 private var mPaddingStart = 0 private var mPaddingEnd = 0 private var mOverlap = false private var mShowDividerHelper: ShowDividerHelper? = null init { mPaint.style = Paint.Style.FILL setOrientation(orientation) setColor(color) setThickness(thickness) } fun setShowDividerHelper(showDividerHelper: ShowDividerHelper?) { mShowDividerHelper = showDividerHelper } fun setOrientation(orientation: Int) { require(!(orientation != HORIZONTAL && orientation != VERTICAL)) { "invalid orientation" } mOrientation = orientation } fun setColor(color: Int) { mPaint.setColor(color) } fun setThickness(thickness: Int) { mThickness = thickness } fun setShowFirstDivider(showFirstDivider: Boolean) { mShowFirstDivider = showFirstDivider } fun setShowLastDivider(showLastDivider: Boolean) { mShowLastDivider = showLastDivider } fun setPadding(padding: Int) { setPaddingStart(padding) setPaddingEnd(padding) } fun setPaddingStart(paddingStart: Int) { mPaddingStart = paddingStart } fun setPaddingEnd(paddingEnd: Int) { mPaddingEnd = paddingEnd } fun setOverlap(overlap: Boolean) { mOverlap = overlap } override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { if (parent.adapter == null) { outRect[0, 0, 0] = 0 return } if (mOverlap) { outRect[0, 0, 0] = 0 return } val position = parent.getChildLayoutPosition(view) val itemCount = parent.adapter!!.itemCount if (mShowDividerHelper != null) { if (mOrientation == VERTICAL) { if (position == 0 && mShowDividerHelper!!.showDivider(0)) { outRect.top = mThickness } if (mShowDividerHelper!!.showDivider(position + 1)) { outRect.bottom = mThickness } } else { if (position == 0 && mShowDividerHelper!!.showDivider(0)) { outRect.left = mThickness } if (mShowDividerHelper!!.showDivider(position + 1)) { outRect.right = mThickness } } } else { if (mOrientation == VERTICAL) { if (position == 0 && mShowFirstDivider) { outRect.top = mThickness } outRect.bottom = mThickness if (position == itemCount - 1 && !mShowLastDivider) { outRect.bottom = 0 } } else { if (position == 0 && mShowFirstDivider) { outRect.left = mThickness } outRect.right = mThickness if (position == itemCount - 1 && !mShowLastDivider) { outRect.right = 0 } } } } override fun onDrawOver( c: Canvas, parent: RecyclerView, state: RecyclerView.State, ) { val adapter = parent.adapter ?: return val itemCount = adapter.itemCount val overlap = mOverlap if (mOrientation == VERTICAL) { val isRtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL val mPaddingLeft: Int val mPaddingRight: Int if (isRtl) { mPaddingLeft = mPaddingEnd mPaddingRight = mPaddingStart } else { mPaddingLeft = mPaddingStart mPaddingRight = mPaddingEnd } val left = parent.getPaddingLeft() + mPaddingLeft val right = parent.width - parent.getPaddingRight() - mPaddingRight val childCount = parent.childCount for (i in 0 until childCount) { val child = parent.getChildAt(i) val lp = child.layoutParams as RecyclerView.LayoutParams val position = parent.getChildLayoutPosition(child) var show: Boolean show = if (mShowDividerHelper != null) { mShowDividerHelper!!.showDivider(position + 1) } else { position != itemCount - 1 || mShowLastDivider } if (show) { var top = child.bottom + lp.bottomMargin if (overlap) { top -= mThickness } val bottom = top + mThickness mRect[left, top, right] = bottom c.drawRect(mRect, mPaint) } if (position == 0) { show = if (mShowDividerHelper != null) { mShowDividerHelper!!.showDivider(0) } else { mShowFirstDivider } if (show) { var bottom = child.top + lp.topMargin if (overlap) { bottom += mThickness } val top = bottom - mThickness mRect[left, top, right] = bottom c.drawRect(mRect, mPaint) } } } } else { val top = parent.paddingTop + mPaddingStart val bottom = parent.height - parent.paddingBottom - mPaddingEnd val childCount = parent.childCount for (i in 0 until childCount) { val child = parent.getChildAt(i) val lp = child.layoutParams as RecyclerView.LayoutParams val position = parent.getChildLayoutPosition(child) var show: Boolean show = if (mShowDividerHelper != null) { mShowDividerHelper!!.showDivider(position + 1) } else { position != itemCount - 1 || mShowLastDivider } if (show) { var left = child.right + lp.rightMargin if (overlap) { left -= mThickness } val right = left + mThickness mRect[left, top, right] = bottom c.drawRect(mRect, mPaint) } if (position == 0) { show = if (mShowDividerHelper != null) { mShowDividerHelper!!.showDivider(0) } else { mShowFirstDivider } if (show) { var right = child.left + lp.leftMargin if (overlap) { right += mThickness } val left = right - mThickness mRect[left, top, right] = bottom c.drawRect(mRect, mPaint) } } } } } interface ShowDividerHelper { fun showDivider(index: Int): Boolean } companion object { const val HORIZONTAL = LinearLayoutManager.HORIZONTAL const val VERTICAL = LinearLayoutManager.VERTICAL } } ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/MarginItemDecoration.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration /** * @param margin gap between two item * @param paddingLeft gap between RecyclerView left and left item left * @param paddingTop gap between RecyclerView top and top item top * @param paddingRight gap between RecyclerView right and right item right * @param paddingBottom gap between RecyclerView bottom and bottom item bottom */ class MarginItemDecoration( margin: Int, paddingLeft: Int, paddingTop: Int, paddingRight: Int, paddingBottom: Int, ) : ItemDecoration() { private var mMargin = 0 private var mPaddingLeft = 0 private var mPaddingTop = 0 private var mPaddingRight = 0 private var mPaddingBottom = 0 init { val halfMargin = margin / 2 mMargin = halfMargin mPaddingLeft = paddingLeft - halfMargin mPaddingTop = paddingTop - halfMargin mPaddingRight = paddingRight - halfMargin mPaddingBottom = paddingBottom - halfMargin } override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { outRect[mMargin, mMargin, mMargin] = mMargin } fun applyPaddings(view: View) { view.setPadding(mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom) } } ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/SimpleHolder.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.view.View import androidx.recyclerview.widget.RecyclerView class SimpleHolder(itemView: View) : RecyclerView.ViewHolder(itemView) ================================================ FILE: app/src/main/java/com/hippo/easyrecyclerview/SimpleSmoothScroller.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.easyrecyclerview import android.content.Context import androidx.recyclerview.widget.LinearSmoothScroller import kotlin.math.abs import kotlin.math.ceil abstract class SimpleSmoothScroller(context: Context, millisecondsPerInch: Float) : LinearSmoothScroller(context) { private val mMillisecondsPerPx: Float = millisecondsPerInch / context.resources.displayMetrics.densityDpi override fun calculateTimeForScrolling(dx: Int): Int = if (mMillisecondsPerPx <= 0) { super.calculateTimeForScrolling(dx) } else { ceil(abs(dx.toDouble()) * mMillisecondsPerPx).toInt() } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/AppConfig.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer; import android.annotation.SuppressLint; import android.content.Context; import android.os.Environment; import androidx.annotation.Nullable; import com.hippo.ehviewer.client.exception.ParseException; import com.hippo.util.ReadableTime; import com.hippo.yorozuya.FileUtils; import com.hippo.yorozuya.IOUtils; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; public class AppConfig { public static final String APP_DIRNAME = "EhViewer"; private static final String DOWNLOAD = "download"; private static final String TEMP = "temp"; private static final String PARSE_ERROR = "parse_error"; private static final String CRASH = "crash"; @SuppressLint("StaticFieldLeak") private static Context sContext; public static void initialize(Context context) { sContext = context.getApplicationContext(); } @Nullable public static File getExternalAppDir() { if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { File dir = sContext.getExternalFilesDir(null); return FileUtils.ensureDirectory(dir) ? dir : null; } return null; } /** * mkdirs and get */ @Nullable public static File getDirInExternalAppDir(String filename) { File appFolder = getExternalAppDir(); if (appFolder != null) { File dir = new File(appFolder, filename); return FileUtils.ensureDirectory(dir) ? dir : null; } return null; } @Nullable public static File getFileInExternalAppDir(String filename) { File appFolder = getExternalAppDir(); if (appFolder != null) { File file = new File(appFolder, filename); return FileUtils.ensureFile(file) ? file : null; } return null; } @Nullable public static File getDefaultDownloadDir() { return getDirInExternalAppDir(DOWNLOAD); } @Nullable public static File getExternalTempDir() { File dir = sContext.getExternalCacheDir(); File file; if (null != dir && FileUtils.ensureDirectory(file = new File(dir, TEMP))) { return file; } else { return null; } } @Nullable public static File getExternalCopyTempDir() { File dir = sContext.getExternalCacheDir(); File file; if (null != dir && FileUtils.ensureDirectory(file = new File(dir, "copy"))) { return file; } else { return null; } } @Nullable public static File getExternalParseErrorDir() { return getDirInExternalAppDir(PARSE_ERROR); } @Nullable public static File getExternalCrashDir() { return getDirInExternalAppDir(CRASH); } @Nullable public static File getTempDir() { File dir = sContext.getCacheDir(); File file; if (null != dir && FileUtils.ensureDirectory(file = new File(dir, TEMP))) { return file; } else { return null; } } @Nullable public static File createTempFile() { return FileUtils.createTempFile(getTempDir(), null); } public static void saveParseErrorBody(ParseException e, String body) { File dir = getExternalParseErrorDir(); if (null == dir) { return; } File file = new File(dir, ReadableTime.INSTANCE.getFilenamableTime() + ".txt"); OutputStream os = null; try { os = new FileOutputStream(file); String message = e.getMessage(); if (null != message) { os.write(message.getBytes(StandardCharsets.UTF_8)); os.write('\n'); } if (null != body) { os.write(body.getBytes(StandardCharsets.UTF_8)); } os.flush(); } catch (IOException e1) { // Ignore } finally { IOUtils.closeQuietly(os); } } @Nullable public static File getFilesDir(String name) { File dir = sContext.getFilesDir(); if (dir == null) { return null; } dir = new File(dir, name); if (dir.isDirectory() || dir.mkdirs()) { return dir; } else { return null; } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/Crash.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer import android.os.Build import android.os.Debug import com.hippo.util.ReadableTime import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.OSUtils import java.io.File import java.io.FileWriter import java.io.PrintWriter private fun joinIfStringArray(any: Any?): String = if (any is Array<*>) any.joinToString() else any.toString() private fun collectClassStaticInfo(clazz: Class<*>): String = clazz.declaredFields.joinToString("\n") { "${it.name}=${joinIfStringArray(it.get(null))}" } object Crash { private fun collectInfo(fw: FileWriter) { fw.write("======== PackageInfo ========\n") fw.write("PackageName=${BuildConfig.APPLICATION_ID}\n") fw.write("VersionName=${BuildConfig.VERSION_NAME}\n") fw.write("VersionCode=${BuildConfig.VERSION_CODE}\n") fw.write("CommitSha=${BuildConfig.COMMIT_SHA}\n") fw.write("BuildTime=${BuildConfig.BUILD_TIME}\n") fw.write("\n") // Runtime fw.write("======== Runtime ========\n") fw.write("TopActivity=${EhApplication.application.topActivity?.javaClass?.name}\n") fw.write("\n") // Device info fw.write("======== DeviceInfo ========\n") fw.write("${collectClassStaticInfo(Build::class.java)}\n") fw.write("${collectClassStaticInfo(Build.VERSION::class.java)}\n") fw.write("MEMORY=") fw.write(FileUtils.humanReadableByteCount(OSUtils.getAppAllocatedMemory(), false)) fw.write("\n") fw.write("MEMORY_NATIVE=") fw.write(FileUtils.humanReadableByteCount(Debug.getNativeHeapAllocatedSize(), false)) fw.write("\n") fw.write("MEMORY_MAX=") fw.write(FileUtils.humanReadableByteCount(OSUtils.getAppMaxMemory(), false)) fw.write("\n") fw.write("MEMORY_TOTAL=") fw.write(FileUtils.humanReadableByteCount(OSUtils.getTotalMemory(), false)) fw.write("\n") fw.write("\n") } private fun getThrowableInfo(t: Throwable, fw: FileWriter) { val printWriter = PrintWriter(fw) t.printStackTrace(printWriter) var cause = t.cause while (cause != null) { cause.printStackTrace(printWriter) cause = cause.cause } } fun saveCrashLog(t: Throwable) { val dir = AppConfig.getExternalCrashDir() ?: return val nowString = ReadableTime.getFilenamableTime() val fileName = "crash-$nowString.log" val file = File(dir, fileName) runCatching { FileWriter(file).use { fw -> fw.write("TIME=${nowString}\n") fw.write("\n") collectInfo(fw) fw.write("======== CrashInfo ========\n") getThrowableInfo(t, fw) fw.write("\n") fw.flush() } }.onFailure { it.printStackTrace() file.delete() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/EhApplication.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer import android.app.Activity import android.content.Context import android.os.StrictMode import android.text.method.LinkMovementMethod import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDelegate import androidx.collection.LruCache import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.network.ConnectivityChecker import coil3.network.NetworkFetcher import coil3.network.okhttp.asNetworkClient import coil3.request.crossfade import coil3.serviceLoaderEnabled import coil3.util.DebugLogger import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhTagDatabase import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.coil.DownloadThumbInterceptor import com.hippo.ehviewer.coil.MergeInterceptor import com.hippo.ehviewer.dao.buildMainDB import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.ui.EhActivity import com.hippo.ehviewer.ui.keepNoMediaFileStatus import com.hippo.ehviewer.widget.SearchDatabase import com.hippo.scene.SceneApplication import com.hippo.util.ReadableTime import com.hippo.util.isAtLeastP import com.hippo.util.launchIO import com.hippo.util.loadHtml import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.IntIdGenerator import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import kotlinx.coroutines.DelicateCoroutinesApi import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.tls.HandshakeCertificates import okio.FileSystem import okio.Path.Companion.toOkioPath class EhApplication : SceneApplication(), SingletonImageLoader.Factory { private val mIdGenerator = IntIdGenerator() private val mGlobalStuffMap = HashMap() private val mActivityList = ArrayList() val topActivity: EhActivity? get() = if (mActivityList.isNotEmpty()) { mActivityList[mActivityList.size - 1] as EhActivity } else { null } fun recreateAllActivity() { mActivityList.forEach { it.recreate() } } @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { application = this val handler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { t, e -> try { if (Settings.saveCrashLog) { Crash.saveCrashLog(e) } } catch (_: Throwable) { } handler?.uncaughtException(t, e) } super.onCreate() System.loadLibrary("ehviewer") Settings.initialize() ReadableTime.initialize(this) AppConfig.initialize(this) AppCompatDelegate.setDefaultNightMode(Settings.theme) launchIO { launchIO { nonCacheOkHttpClient } launchIO { EhTagDatabase.read() } launchIO { ehDatabase } launchIO { DownloadManager.isIdle } launchIO { cleanupDownload() } launchIO { theDawnOfNewDay() } } mIdGenerator.setNextId(Settings.getInt(KEY_GLOBAL_STUFF_NEXT_ID, 0)) if (BuildConfig.DEBUG) { StrictMode.enableDefaults() } } private suspend fun cleanupDownload() { runCatching { keepNoMediaFileStatus() }.onFailure { it.printStackTrace() } runCatching { clearTempDir() }.onFailure { it.printStackTrace() } } private suspend fun theDawnOfNewDay() { runCatching { if (Settings.requestNews && EhCookieStore.hasSignedIn()) { EhEngine.getNews(true)?.let { showEventPane(it) } } }.onFailure { it.printStackTrace() } } fun showEventPane(html: String) { if (Settings.hideHvEvents && html.contains("You have encountered a monster!")) { return } val activity = topActivity activity?.runOnUiThread { val dialog = AlertDialog.Builder(activity) .setMessage(loadHtml(html)) .setPositiveButton(android.R.string.ok, null) .create() dialog.setOnShowListener { val messageView = dialog.findViewById(android.R.id.message) if (messageView is TextView) { messageView.movementMethod = LinkMovementMethod.getInstance() } } try { dialog.show() } catch (_: Throwable) { // ignore } } } private fun clearTempDir() { var dir = AppConfig.getTempDir() if (null != dir) { FileUtils.deleteContent(dir) } dir = AppConfig.getExternalTempDir() if (null != dir) { FileUtils.deleteContent(dir) } } fun putGlobalStuff(o: Any): Int { val id = mIdGenerator.nextId() mGlobalStuffMap[id] = o Settings.putInt(KEY_GLOBAL_STUFF_NEXT_ID, mIdGenerator.nextId()) return id } fun containGlobalStuff(id: Int): Boolean = mGlobalStuffMap.containsKey(id) fun removeGlobalStuff(id: Int): Any? = mGlobalStuffMap.remove(id) fun removeGlobalStuff(o: Any) { mGlobalStuffMap.values.removeAll(setOf(o)) } fun registerActivity(activity: Activity) { mActivityList.add(activity) } fun unregisterActivity(activity: Activity) { mActivityList.remove(activity) } override fun newImageLoader(context: Context) = ImageLoader.Builder(context).apply { serviceLoaderEnabled(false) components { if (isAtLeastP) { add(AnimatedImageDecoder.Factory(false)) } else { add(GifDecoder.Factory()) } add( NetworkFetcher.Factory( networkClient = { coilOkHttpClient.asNetworkClient() }, connectivityChecker = { ConnectivityChecker.ONLINE }, ), ) add(MergeInterceptor) add(DownloadThumbInterceptor) } crossfade(300) diskCache(thumbCache) if (BuildConfig.DEBUG) logger(DebugLogger()) }.build() companion object { private const val KEY_GLOBAL_STUFF_NEXT_ID = "global_stuff_next_id" lateinit var application: EhApplication private set val cacheDir by lazy { application.cacheDir.toOkioPath() } val ehProxySelector by lazy { EhProxySelector() } val nonCacheOkHttpClient by lazy { val cf = CertificateFactory.getInstance("X.509") val cert = application.resources.openRawResource(R.raw.isrgrootx1).use { cf.generateCertificates(it).first() as X509Certificate } val certs = HandshakeCertificates.Builder() .addPlatformTrustedCertificates() .addTrustedCertificate(cert) .build() OkHttpClient.Builder().apply { cookieJar(EhCookieStore) proxySelector(ehProxySelector) sslSocketFactory(certs.sslSocketFactory(), certs.trustManager) addInterceptor(CloudflareInterceptor(application)) }.build() } val noRedirectOkHttpClient by lazy { nonCacheOkHttpClient.newBuilder() .followRedirects(false) .build() } val coilOkHttpClient by lazy { nonCacheOkHttpClient.newBuilder() .addInterceptor { chain -> val request = chain.request() val newRequest = request.newBuilder() .header("User-Agent", Settings.userAgent!!) .build() chain.proceed(newRequest) } .build() } // Never use this okhttp client to download large blobs!!! val okHttpClient by lazy { nonCacheOkHttpClient.newBuilder() .cache(Cache(FileSystem.SYSTEM, cacheDir / "http_cache", 20 * 1024 * 1024)) .build() } val galleryDetailCache by lazy { LruCache(25).also { favouriteStatusRouter.addListener { gid, slot -> it[gid]?.favoriteSlot = slot } } } val favouriteStatusRouter by lazy { FavouriteStatusRouter() } val ehDatabase by lazy { buildMainDB(application) } val searchDatabase by lazy { SearchDatabase.getInstance(application)!! } val thumbCache by lazy { DiskCache.Builder() .directory(cacheDir / "thumb") .maxSizeBytes((Settings.readCacheSize / 5).coerceIn(64, 1024).toLong() * 1024 * 1024) .build() } val imageCache by lazy { DiskCache.Builder() .directory(cacheDir / "image") .maxSizeBytes((Settings.readCacheSize / 5 * 4).coerceIn(256, 4096).toLong() * 1024 * 1024) .build() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/EhDB.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer import android.content.Context import android.net.Uri import androidx.paging.PagingSource import androidx.room.Room.databaseBuilder import com.hippo.ehviewer.EhApplication.Companion.ehDatabase import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.dao.BasicDao import com.hippo.ehviewer.dao.DownloadDirname import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.dao.DownloadLabel import com.hippo.ehviewer.dao.EhDatabase import com.hippo.ehviewer.dao.Filter import com.hippo.ehviewer.dao.HistoryInfo import com.hippo.ehviewer.dao.LocalFavoriteInfo import com.hippo.ehviewer.dao.QuickSearch import com.hippo.ehviewer.download.DownloadManager import com.hippo.unifile.UniFile import com.hippo.util.sendTo object EhDB { private val db = ehDatabase // Fix state @get:Synchronized val allDownloadInfo: List get() = db.downloadsDao().list().onEach { if (it.state == DownloadInfo.STATE_WAIT || it.state == DownloadInfo.STATE_DOWNLOAD) { it.state = DownloadInfo.STATE_NONE } } @Synchronized fun updateDownloadInfo(downloadInfos: List) { val dao = db.downloadsDao() dao.update(downloadInfos) } @Synchronized fun putDownloadInfo(downloadInfo: DownloadInfo) { db.downloadsDao().run { if (load(downloadInfo.gid) != null) { update(downloadInfo) } else { insert(downloadInfo) } } } @Synchronized fun removeDownloadInfo(downloadInfo: DownloadInfo) { db.downloadsDao().delete(downloadInfo) } @get:Synchronized val allDownloadDirname: List get() = db.downloadDirnameDao().list() @Synchronized fun getDownloadDirname(gid: Long): String? { val dao = db.downloadDirnameDao() val raw = dao.load(gid) return raw?.dirname } @Synchronized fun putDownloadDirname(gid: Long, dirname: String?) { val dao = db.downloadDirnameDao() var raw = dao.load(gid) if (raw != null) { raw.dirname = dirname dao.update(raw) } else { raw = DownloadDirname(gid, dirname) dao.insert(raw) } } @Synchronized fun removeDownloadDirname(gid: Long) { val dao = db.downloadDirnameDao() dao.deleteByKey(gid) } @get:Synchronized val allDownloadLabelList: List get() = db.downloadLabelDao().list() @Synchronized fun addDownloadLabel(label: String): DownloadLabel { val dao = db.downloadLabelDao() val raw = DownloadLabel() raw.label = label raw.time = System.currentTimeMillis() raw.id = dao.insert(raw) return raw } @Synchronized fun addDownloadLabel(raw: DownloadLabel): DownloadLabel { // Reset id raw.id = null val dao = db.downloadLabelDao() raw.id = dao.insert(raw) return raw } @Synchronized fun updateDownloadLabel(raw: DownloadLabel?) { val dao = db.downloadLabelDao() dao.update(raw!!) } @Synchronized fun moveDownloadLabel(fromPosition: Int, toPosition: Int) { if (fromPosition == toPosition) { return } val reverse = fromPosition > toPosition val offset = if (reverse) toPosition else fromPosition val limit = if (reverse) fromPosition - toPosition + 1 else toPosition - fromPosition + 1 val dao = db.downloadLabelDao() val list = dao.list(offset, limit) val step = if (reverse) 1 else -1 val start = if (reverse) limit - 1 else 0 val end = if (reverse) 0 else limit - 1 val toTime = list[end].time var i = end while (if (reverse) i < start else i > 0) { val aTime = list[i].time val bTime = list[i + step].time list[i].time = if (aTime == bTime) bTime + step else bTime i += step } list[start].time = toTime dao.update(list) } @Synchronized fun removeDownloadLabel(raw: DownloadLabel?) { val dao = db.downloadLabelDao() dao.delete(raw!!) } @get:Synchronized val allLocalFavorites: List get() { val dao = db.localFavoritesDao() val list = dao.list() return ArrayList(list) } @Synchronized fun searchLocalFavorites(query: String): List { val dao = db.localFavoritesDao() val list = dao.list("%$query%") return ArrayList(list) } @Synchronized fun removeLocalFavorites(gid: Long) { db.localFavoritesDao().deleteByKey(gid) } @Synchronized fun removeLocalFavorites(gidArray: LongArray) { val dao = db.localFavoritesDao() for (gid in gidArray) { dao.deleteByKey(gid) } } @Synchronized fun containLocalFavorites(gid: Long): Boolean { val dao = db.localFavoritesDao() return dao.contains(gid) } @Synchronized fun putLocalFavorites(galleryInfo: GalleryInfo) { val dao = db.localFavoritesDao() if (null == dao.load(galleryInfo.gid)) { val info: LocalFavoriteInfo if (galleryInfo is LocalFavoriteInfo) { info = galleryInfo } else { info = LocalFavoriteInfo(galleryInfo) info.time = System.currentTimeMillis() } dao.insert(info) } } @Synchronized fun putLocalFavorites(galleryInfoList: List) { for (gi in galleryInfoList) { putLocalFavorites(gi) } } @get:Synchronized val allQuickSearch: List get() { val dao = db.quickSearchDao() return dao.list() } @Synchronized fun insertQuickSearch(quickSearch: QuickSearch) { val dao = db.quickSearchDao() quickSearch.id = null quickSearch.time = System.currentTimeMillis() quickSearch.id = dao.insert(quickSearch) } @Synchronized fun importQuickSearch(quickSearchList: List) { val dao = db.quickSearchDao() for (quickSearch in quickSearchList) { dao.insert(quickSearch!!) } } @Synchronized fun deleteQuickSearch(quickSearch: QuickSearch?) { quickSearch ?: return val dao = db.quickSearchDao() dao.delete(quickSearch) } @Synchronized fun moveQuickSearch(fromPosition: Int, toPosition: Int) { if (fromPosition == toPosition) { return } val reverse = fromPosition > toPosition val offset = if (reverse) toPosition else fromPosition val limit = if (reverse) fromPosition - toPosition + 1 else toPosition - fromPosition + 1 val dao = db.quickSearchDao() val list = dao.list(offset, limit) val step = if (reverse) 1 else -1 val start = if (reverse) limit - 1 else 0 val end = if (reverse) 0 else limit - 1 val toTime = list[end].time var i = end while (if (reverse) i < start else i > 0) { val aTime = list[i].time val bTime = list[i + step].time list[i].time = if (aTime == bTime) bTime + step else bTime i += step } list[start].time = toTime dao.update(list) } @get:Synchronized val historyLazyList: PagingSource get() = db.historyDao().listLazy() @Synchronized fun putHistoryInfo(galleryInfo: GalleryInfo) { val dao = db.historyDao() val info = galleryInfo as? HistoryInfo ?: HistoryInfo(galleryInfo) info.time = System.currentTimeMillis() if (null != dao.load(info.gid)) { dao.update(info) } else { dao.insert(info) } } @Synchronized fun updateHistoryFavSlot(gid: Long, slot: Int) { val dao = db.historyDao() val info = dao.load(gid) if (null != info) { info.favoriteSlot = slot dao.update(info) } } @Synchronized fun putHistoryInfo(historyInfoList: List) { val dao = db.historyDao() for (info in historyInfoList) { if (null == dao.load(info.gid)) { dao.insert(info) } } } @Synchronized fun deleteHistoryInfo(info: HistoryInfo?) { val dao = db.historyDao() dao.delete(info!!) } @Synchronized fun clearHistoryInfo() { val dao = db.historyDao() dao.deleteAll() } @get:Synchronized val allFilter: List get() = db.filterDao().list() @Synchronized fun addFilter(filter: Filter): Boolean { val existFilter: Filter? = try { db.filterDao().load(filter.text!!, filter.mode) } catch (_: Exception) { null } return if (existFilter == null) { filter.id = null filter.id = db.filterDao().insert(filter) true } else { false } } @Synchronized fun deleteFilter(filter: Filter) { db.filterDao().delete(filter) } @Synchronized fun triggerFilter(filter: Filter) { filter.enable = filter.enable?.not() == true db.filterDao().update(filter) } private fun copyDao(from: BasicDao, to: BasicDao) { val list = from.list() for (item in list) to.insert(item) } @Synchronized fun exportDB(context: Context, uri: Uri): Boolean { val ehExportName = "eh.export.db" runCatching { // Delete old export db context.deleteDatabase(ehExportName) val newDb = databaseBuilder(context, EhDatabase::class.java, ehExportName).build() // Copy data to a export db copyDao(db.downloadsDao(), newDb.downloadsDao()) copyDao(db.downloadLabelDao(), newDb.downloadLabelDao()) copyDao(db.downloadDirnameDao(), newDb.downloadDirnameDao()) copyDao(db.historyDao(), newDb.historyDao()) copyDao(db.quickSearchDao(), newDb.quickSearchDao()) copyDao(db.localFavoritesDao(), newDb.localFavoritesDao()) copyDao(db.filterDao(), newDb.filterDao()) // Close export db so we can copy it newDb.close() // Copy export db to data dir val dbFile = context.getDatabasePath(ehExportName) UniFile.fromFile(dbFile)!! sendTo UniFile.fromUri(context, uri)!! return true }.onFailure { it.printStackTrace() } return false } /** * @return error string, null for no error */ @Synchronized fun importDB(context: Context, uri: Uri): String? { val tmpDBName = "tmp.db" runCatching { val oldDB = databaseBuilder(context, EhDatabase::class.java, tmpDBName) .createFromInputStream { context.contentResolver.openInputStream(uri) }.build() // Download label val manager = DownloadManager runCatching { val downloadLabelList = oldDB.downloadLabelDao().list() manager.addDownloadLabel(downloadLabelList) } // Downloads runCatching { val downloadInfoList = oldDB.downloadsDao().list() manager.addDownload(downloadInfoList, false) } // Download dirname runCatching { oldDB.downloadDirnameDao().list().forEach { putDownloadDirname(it.gid, it.dirname) } } // History runCatching { val historyInfoList = oldDB.historyDao().list() putHistoryInfo(historyInfoList) } // QuickSearch runCatching { val quickSearchList = oldDB.quickSearchDao().list() val currentQuickSearchList = db.quickSearchDao().list() val importList = quickSearchList.mapNotNull { newQS -> newQS.takeIf { currentQuickSearchList.find { it.name == newQS.name } == null } } importQuickSearch(importList) } // LocalFavorites runCatching { oldDB.localFavoritesDao().list().forEach { putLocalFavorites(it) } } // Filter runCatching { val filterList = oldDB.filterDao().list() val currentFilterList = db.filterDao().list() filterList.forEach { if (it !in currentFilterList) addFilter(it) } } oldDB.close() context.deleteDatabase(tmpDBName) }.onFailure { it.printStackTrace() return context.getString(R.string.settings_advanced_import_data_cant_read) } return null } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/EhProxySelector.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer import android.text.TextUtils import com.hippo.ehviewer.Settings.proxyIp import com.hippo.ehviewer.Settings.proxyPort import com.hippo.ehviewer.Settings.proxyType import com.hippo.network.InetValidator.isValidInetPort import com.hippo.util.ExceptionUtils import java.io.IOException import java.net.InetAddress import java.net.InetSocketAddress import java.net.Proxy import java.net.ProxySelector import java.net.SocketAddress import java.net.URI class EhProxySelector internal constructor() : ProxySelector() { private var delegation: ProxySelector? = null private var alternative: ProxySelector? init { alternative = getDefault() ?: NullProxySelector() updateProxy() } fun updateProxy() { delegation = when (proxyType) { TYPE_DIRECT -> NullProxySelector() TYPE_SYSTEM -> alternative TYPE_HTTP, TYPE_SOCKS -> null else -> alternative } } override fun select(uri: URI): List { val type = proxyType if (type == TYPE_HTTP || type == TYPE_SOCKS) { runCatching { val ip = proxyIp val port = proxyPort if (!TextUtils.isEmpty(ip) && isValidInetPort(port)) { val inetAddress = InetAddress.getByName(ip) val socketAddress = InetSocketAddress(inetAddress, port) return listOf( Proxy( if (type == TYPE_HTTP) Proxy.Type.HTTP else Proxy.Type.SOCKS, socketAddress, ), ) } }.onFailure { ExceptionUtils.throwIfFatal(it) it.printStackTrace() } } return delegation?.select(uri) ?: alternative!!.select(uri) } override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) { delegation?.select(uri) } private class NullProxySelector : ProxySelector() { override fun select(uri: URI): List = listOf(Proxy.NO_PROXY) override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) {} } companion object { const val TYPE_DIRECT = 0 const val TYPE_SYSTEM = 1 const val TYPE_HTTP = 2 const val TYPE_SOCKS = 3 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/FavouriteStatusRouter.java ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer; import android.annotation.SuppressLint; import com.hippo.ehviewer.client.data.GalleryInfo; import com.hippo.yorozuya.IntIdGenerator; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class FavouriteStatusRouter { private static final String KEY_DATA_MAP_NEXT_ID = "data_map_next_id"; private final IntIdGenerator idGenerator = new IntIdGenerator(Settings.getInt(KEY_DATA_MAP_NEXT_ID, 0)); @SuppressLint("UseSparseArrays") private final HashMap> maps = new HashMap<>(); private final List listeners = new ArrayList<>(); public int saveDataMap(Map map) { int id = idGenerator.nextId(); maps.put(id, map); Settings.putInt(KEY_DATA_MAP_NEXT_ID, idGenerator.nextId()); return id; } public Map restoreDataMap(int id) { return maps.remove(id); } public void modifyFavourites(long gid, int slot) { for (Map map : maps.values()) { GalleryInfo info = map.get(gid); if (info != null) { info.setFavoriteSlot(slot); } } for (Listener listener : listeners) { listener.onModifyFavourites(gid, slot); } } public void addListener(Listener listener) { listeners.add(listener); } public void removeListener(Listener listener) { listeners.remove(listener); } public interface Listener { void onModifyFavourites(long gid, int slot); } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/GetText.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer import androidx.annotation.StringRes object GetText { fun getString(@StringRes id: Int): String = EhApplication.application.getString(id) fun getString(@StringRes id: Int, vararg formatArgs: Any?): String = EhApplication.application.getString(id, *formatArgs) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/Settings.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer import android.content.SharedPreferences import android.net.Uri import android.util.Log import androidx.core.content.edit import androidx.preference.PreferenceManager import com.hippo.ehviewer.EhApplication.Companion.application import com.hippo.ehviewer.client.data.FavListUrlBuilder import com.hippo.ehviewer.ui.scene.GalleryListScene import com.hippo.glgallery.GalleryView import com.hippo.okhttp.CHROME_USER_AGENT import com.hippo.unifile.UniFile import com.hippo.yorozuya.NumberUtils import java.util.Locale @Suppress("SameParameterValue") object Settings { /******************** ****** Eh ********************/ const val KEY_ACCOUNT = "account" const val KEY_GALLERY_SITE = "gallery_site" private const val DEFAULT_GALLERY_SITE = 0 private const val KEY_IMAGE_LIMITS = "image_limits" private const val KEY_U_CONFIG = "uconfig" private const val KEY_MY_TAGS = "mytags" const val KEY_THEME = "theme" private const val DEFAULT_THEME = -1 const val KEY_BLACK_DARK_THEME = "black_dark_theme" private const val DEFAULT_BLACK_DARK_THEME = false private const val KEY_LAUNCH_PAGE = "launch_page" private const val DEFAULT_LAUNCH_PAGE = 0 const val KEY_LIST_MODE = "list_mode" private const val DEFAULT_LIST_MODE = 0 const val KEY_DETAIL_SIZE = "detail_size_" private const val DEFAULT_DETAIL_SIZE = 8 const val KEY_LIST_THUMB_SIZE = "list_cover_size" private const val DEFAULT_LIST_THUMB_SIZE = 40 const val KEY_THUMB_SIZE = "cover_size" private const val DEFAULT_THUMB_SIZE = 4 const val KEY_THUMB_SHOW_TITLE = "thumb_show_title" private const val DEFAULT_THUMB_SHOW_TITLE = true private const val KEY_SHOW_JPN_TITLE = "show_jpn_title" private const val DEFAULT_SHOW_JPN_TITLE = false private const val KEY_SHOW_GALLERY_PAGES = "show_gallery_pages" private const val DEFAULT_SHOW_GALLERY_PAGES = true private const val KEY_SHOW_COMMENTS = "show_gallery_comments" private const val DEFAULT_SHOW_COMMENTS = true private const val KEY_COMMENT_THRESHOLD = "comment_threshold" private const val DEFAULT_COMMENT_THRESHOLD = -101 private const val KEY_PREVIEW_NUM = "preview_num" private const val DEFAULT_PREVIEW_NUM = 60 private const val KEY_PREVIEW_SIZE = "preview_size" private const val DEFAULT_PREVIEW_SIZE = 3 const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations" private const val DEFAULT_SHOW_TAG_TRANSLATIONS = false private const val KEY_TRANSLATIONS_LAST_UPDATE = "translations_last_update" const val KEY_TAG_TRANSLATIONS_SOURCE = "tag_translations_source" private const val KEY_METERED_NETWORK_WARNING = "cellular_network_warning" private const val DEFAULT_METERED_NETWORK_WARNING = false private const val KEY_REQUEST_NEWS = "request_news" private const val DEFAULT_REQUEST_NEWS = false private const val KEY_HIDE_HV_EVENTS = "hide_hv_events" private const val DEFAULT_HIDE_HV_EVENTS = false val SIGN_IN_REQUIRED = arrayOf( KEY_GALLERY_SITE, KEY_IMAGE_LIMITS, KEY_U_CONFIG, KEY_MY_TAGS, KEY_SHOW_JPN_TITLE, KEY_REQUEST_NEWS, KEY_HIDE_HV_EVENTS, ) /******************** ****** Read ********************/ private const val KEY_SCREEN_ROTATION = "screen_rotation" private const val DEFAULT_SCREEN_ROTATION = 0 private const val KEY_READING_DIRECTION = "reading_direction" private const val DEFAULT_READING_DIRECTION = GalleryView.LAYOUT_RIGHT_TO_LEFT private const val KEY_PAGE_SCALING = "page_scaling" private const val DEFAULT_PAGE_SCALING = GalleryView.SCALE_FIT private const val KEY_START_POSITION = "start_position" private const val DEFAULT_START_POSITION = GalleryView.START_POSITION_TOP_RIGHT private const val KEY_READ_THEME = "read_theme" private const val DEFAULT_READ_THEME = 1 private const val KEY_KEEP_SCREEN_ON = "keep_screen_on" private const val DEFAULT_KEEP_SCREEN_ON = false private const val KEY_SHOW_CLOCK = "gallery_show_clock" private const val DEFAULT_SHOW_CLOCK = true private const val KEY_SHOW_PROGRESS = "gallery_show_progress" private const val DEFAULT_SHOW_PROGRESS = true private const val KEY_SHOW_BATTERY = "gallery_show_battery" private const val DEFAULT_SHOW_BATTERY = true private const val KEY_SHOW_PAGE_INTERVAL = "gallery_show_page_interval" private const val DEFAULT_SHOW_PAGE_INTERVAL = false private const val KEY_TURN_PAGE_INTERVAL = "turn_page_interval" private const val DEFAULT_TURN_PAGE_INTERVAL = 5 private const val KEY_VOLUME_PAGE = "volume_page" private const val DEFAULT_VOLUME_PAGE = false private const val KEY_VOLUME_PAGE_INTERVAL = "volume_page_interval" private const val DEFAULT_VOLUME_PAGE_INTERVAL = 1 private const val KEY_REVERSE_VOLUME_PAGE = "reserve_volume_page" private const val DEFAULT_REVERSE_VOLUME_PAGE = false private const val KEY_READING_FULLSCREEN = "reading_fullscreen" private const val VALUE_READING_FULLSCREEN = true private const val KEY_CUSTOM_SCREEN_LIGHTNESS = "custom_screen_lightness" private const val DEFAULT_CUSTOM_SCREEN_LIGHTNESS = false private const val KEY_SCREEN_LIGHTNESS = "screen_lightness" private const val DEFAULT_SCREEN_LIGHTNESS = 50 /******************** ****** Download ********************/ const val KEY_DOWNLOAD_LOCATION = "download_location" private const val KEY_DOWNLOAD_SAVE_SCHEME = "image_scheme" private const val KEY_DOWNLOAD_SAVE_AUTHORITY = "image_authority" private const val KEY_DOWNLOAD_SAVE_PATH = "image_path" private const val KEY_DOWNLOAD_SAVE_QUERY = "image_query" private const val KEY_DOWNLOAD_SAVE_FRAGMENT = "image_fragment" const val KEY_MEDIA_SCAN = "media_scan" private const val DEFAULT_MEDIA_SCAN = false const val KEY_MULTI_THREAD_DOWNLOAD = "download_thread" private const val DEFAULT_MULTI_THREAD_DOWNLOAD = 3 const val KEY_DOWNLOAD_DELAY = "download_delay_2" private const val DEFAULT_DOWNLOAD_DELAY = 1000 private const val KEY_DOWNLOAD_TIMEOUT = "download_timeout" private const val DEFAULT_DOWNLOAD_TIMEOUT = 60 const val KEY_PRELOAD_IMAGE = "preload_image" private const val DEFAULT_PRELOAD_IMAGE = 5 const val KEY_DOWNLOAD_ORIGIN_IMAGE = "download_origin_image_" private const val DEFAULT_DOWNLOAD_ORIGIN_IMAGE = 0 /******************** ****** Privacy and Security ********************/ private const val KEY_SECURITY = "security" private const val DEFAULT_SECURITY = "" private const val KEY_ENABLE_FINGERPRINT = "enable_fingerprint" private const val DEFAULT_ENABLE_FINGERPRINT = false private const val KEY_SEC_SECURITY = "enable_secure" private const val DEFAULT_SEC_SECURITY = false /******************** ****** Advanced ********************/ private const val KEY_SAVE_PARSE_ERROR_BODY = "save_parse_error_body" private const val DEFAULT_SAVE_PARSE_ERROR_BODY = true private const val KEY_SAVE_CRASH_LOG = "save_crash_log" private const val DEFAULT_SAVE_CRASH_LOG = true private const val KEY_READ_CACHE_SIZE = "read_cache_size" private const val DEFAULT_READ_CACHE_SIZE = 320 const val KEY_APP_LANGUAGE = "app_language" private const val DEFAULT_APP_LANGUAGE = "system" private const val KEY_PROXY_TYPE = "proxy_type" private const val DEFAULT_PROXY_TYPE = EhProxySelector.TYPE_SYSTEM private const val KEY_PROXY_IP = "proxy_ip" private val DEFAULT_PROXY_IP: String? = null private const val KEY_PROXY_PORT = "proxy_port" private const val DEFAULT_PROXY_PORT = -1 private const val KEY_USER_AGENT = "user_agent" private const val KEY_APP_LINK_VERIFY_TIP = "app_link_verify_tip" private const val DEFAULT_APP_LINK_VERIFY_TIP = false /******************** ****** Favorites ********************/ private const val KEY_FAV_CAT_0 = "fav_cat_0" private const val KEY_FAV_CAT_1 = "fav_cat_1" private const val KEY_FAV_CAT_2 = "fav_cat_2" private const val KEY_FAV_CAT_3 = "fav_cat_3" private const val KEY_FAV_CAT_4 = "fav_cat_4" private const val KEY_FAV_CAT_5 = "fav_cat_5" private const val KEY_FAV_CAT_6 = "fav_cat_6" private const val KEY_FAV_CAT_7 = "fav_cat_7" private const val KEY_FAV_CAT_8 = "fav_cat_8" private const val KEY_FAV_CAT_9 = "fav_cat_9" private const val DEFAULT_FAV_CAT_0 = "Favorites 0" private const val DEFAULT_FAV_CAT_1 = "Favorites 1" private const val DEFAULT_FAV_CAT_2 = "Favorites 2" private const val DEFAULT_FAV_CAT_3 = "Favorites 3" private const val DEFAULT_FAV_CAT_4 = "Favorites 4" private const val DEFAULT_FAV_CAT_5 = "Favorites 5" private const val DEFAULT_FAV_CAT_6 = "Favorites 6" private const val DEFAULT_FAV_CAT_7 = "Favorites 7" private const val DEFAULT_FAV_CAT_8 = "Favorites 8" private const val DEFAULT_FAV_CAT_9 = "Favorites 9" private const val KEY_FAV_COUNT_0 = "fav_count_0" private const val KEY_FAV_COUNT_1 = "fav_count_1" private const val KEY_FAV_COUNT_2 = "fav_count_2" private const val KEY_FAV_COUNT_3 = "fav_count_3" private const val KEY_FAV_COUNT_4 = "fav_count_4" private const val KEY_FAV_COUNT_5 = "fav_count_5" private const val KEY_FAV_COUNT_6 = "fav_count_6" private const val KEY_FAV_COUNT_7 = "fav_count_7" private const val KEY_FAV_COUNT_8 = "fav_count_8" private const val KEY_FAV_COUNT_9 = "fav_count_9" private const val KEY_FAV_LOCAL = "fav_local" private const val KEY_FAV_CLOUD = "fav_cloud" private const val DEFAULT_FAV_COUNT = 0 private const val KEY_RECENT_FAV_CAT = "recent_fav_cat" private const val DEFAULT_RECENT_FAV_CAT = FavListUrlBuilder.FAV_CAT_ALL // -1 for local, 0 - 9 for cloud favorite, other for no default fav slot const val INVALID_DEFAULT_FAV_SLOT = -2 private const val KEY_DEFAULT_FAV_SLOT = "default_favorite_2" private const val DEFAULT_DEFAULT_FAV_SLOT = INVALID_DEFAULT_FAV_SLOT private const val KEY_NEVER_ADD_FAV_NOTES = "never_add_favorite_notes" private const val DEFAULT_NEVER_ADD_FAV_NOTES = false /******************** ****** Guide ********************/ private const val KEY_GUIDE_GALLERY = "guide_gallery" private const val DEFAULT_GUIDE_GALLERY = true /******************** ****** Others ********************/ private val TAG = Settings::class.java.simpleName private const val KEY_SELECT_SITE = "select_site" private const val DEFAULT_SELECT_SITE = true private const val KEY_NEED_SIGN_IN = "need_sign_in" private const val DEFAULT_NEED_SIGN_IN = true private const val KEY_DISPLAY_NAME = "display_name" private val DEFAULT_DISPLAY_NAME: String? = null private const val KEY_AVATAR = "avatar" private val DEFAULT_AVATAR: String? = null private const val KEY_QS_SAVE_PROGRESS = "qs_save_progress" private const val DEFAULT_QS_SAVE_PROGRESS = false private const val KEY_HAS_DEFAULT_DOWNLOAD_LABEL = "has_default_download_label" private const val DEFAULT_HAS_DOWNLOAD_LABEL = false private const val KEY_DEFAULT_DOWNLOAD_LABEL = "default_download_label" private val DEFAULT_DOWNLOAD_LABEL: String? = null private const val KEY_RECENT_DOWNLOAD_LABEL = "recent_download_label" private val DEFAULT_RECENT_DOWNLOAD_LABEL: String? = null private const val KEY_DEFAULT_SORTING_METHOD = "default_sorting_method" private const val DEFAULT_SORTING_METHOD = 0 private const val KEY_DEFAULT_TOP_LIST = "default_top_list" private const val DEFAULT_TOP_LIST = "15" private const val KEY_REMOVE_IMAGE_FILES = "include_pic" private const val DEFAULT_REMOVE_IMAGE_FILES = true private const val KEY_CLIPBOARD_TEXT_HASH_CODE = "clipboard_text_hash_code" private const val DEFAULT_CLIPBOARD_TEXT_HASH_CODE = 0 private const val KEY_ARCHIVE_PASSWDS = "archive_passwds" private const val KEY_NOTIFICATION_REQUIRED = "notification_required" private lateinit var sSettingsPre: SharedPreferences fun initialize() { sSettingsPre = PreferenceManager.getDefaultSharedPreferences(application) fixDefaultValue() } private fun fixDefaultValue() { if ("zh" == Locale.getDefault().language) { // Enable show tag translations if the language is zh if (!sSettingsPre.contains(KEY_SHOW_TAG_TRANSLATIONS)) { putShowTagTranslations(true) } } } private fun getBoolean(key: String, defValue: Boolean): Boolean = try { sSettingsPre.getBoolean(key, defValue) } catch (e: ClassCastException) { Log.d(TAG, "Get ClassCastException when get $key value", e) defValue } private fun putBoolean(key: String, value: Boolean) { sSettingsPre.edit { putBoolean(key, value) } } @JvmStatic fun getInt(key: String, defValue: Int): Int = try { sSettingsPre.getInt(key, defValue) } catch (e: ClassCastException) { Log.d(TAG, "Get ClassCastException when get $key value", e) defValue } @JvmStatic fun putInt(key: String, value: Int) { sSettingsPre.edit { putInt(key, value) } } private fun getLong(key: String, defValue: Long): Long = try { sSettingsPre.getLong(key, defValue) } catch (e: ClassCastException) { Log.d(TAG, "Get ClassCastException when get $key value", e) defValue } private fun putLong(key: String, value: Long) { sSettingsPre.edit { putLong(key, value) } } private fun getString(key: String, defValue: String?): String? = try { sSettingsPre.getString(key, defValue) } catch (e: ClassCastException) { Log.d(TAG, "Get ClassCastException when get $key value", e) defValue } private fun putString(key: String, value: String?) { sSettingsPre.edit { putString(key, value) } } private fun getStringSet(key: String): MutableSet? = sSettingsPre.getStringSet(key, null) private fun putStringToStringSet(key: String, value: String) { var set = getStringSet(key) if (set == null) { set = mutableSetOf(value) } else if (set.contains(value)) { return } else { set.add(value) } sSettingsPre.edit { putStringSet(key, set) } } private fun getIntFromStr(key: String, defValue: Int): Int = try { NumberUtils.parseIntSafely( sSettingsPre.getString(key, defValue.toString()), defValue, ) } catch (e: ClassCastException) { Log.d(TAG, "Get ClassCastException when get $key value", e) defValue } private fun putIntToStr(key: String, value: Int) { sSettingsPre.edit { putString(key, value.toString()) } } private fun dip2px(dpValue: Int): Int { val scale = application.resources.displayMetrics.density return (dpValue * scale + 0.5f).toInt() } val locale: Locale get() { return if (appLanguage != null && appLanguage != "system") { Locale.forLanguageTag(appLanguage!!) } else { Locale.getDefault() } } val gallerySite: Int get() = getIntFromStr(KEY_GALLERY_SITE, DEFAULT_GALLERY_SITE) fun putGallerySite(value: Int) { putIntToStr(KEY_GALLERY_SITE, value) } val theme: Int get() = getIntFromStr(KEY_THEME, DEFAULT_THEME) fun putTheme(theme: Int) { putIntToStr(KEY_THEME, theme) } val blackDarkTheme get() = getBoolean(KEY_BLACK_DARK_THEME, DEFAULT_BLACK_DARK_THEME) val launchPageGalleryListSceneAction: String get() { return when (getIntFromStr(KEY_LAUNCH_PAGE, DEFAULT_LAUNCH_PAGE)) { 3 -> GalleryListScene.ACTION_TOP_LIST 2 -> GalleryListScene.ACTION_WHATS_HOT 1 -> GalleryListScene.ACTION_SUBSCRIPTION else -> GalleryListScene.ACTION_HOMEPAGE } } val listMode: Int get() = getIntFromStr(KEY_LIST_MODE, DEFAULT_LIST_MODE) val detailSize: Int get() = dip2px(40 * getInt(KEY_DETAIL_SIZE, DEFAULT_DETAIL_SIZE)) val listThumbSize: Int get() = dip2px(2 * getInt(KEY_LIST_THUMB_SIZE, DEFAULT_LIST_THUMB_SIZE)) val listTitleSingleLine: Boolean get() = getInt(KEY_LIST_THUMB_SIZE, DEFAULT_LIST_THUMB_SIZE) < DEFAULT_LIST_THUMB_SIZE - 2 val thumbSize: Int get() = dip2px(40 * getInt(KEY_THUMB_SIZE, DEFAULT_THUMB_SIZE)) val thumbShowTitle: Boolean get() = getBoolean(KEY_THUMB_SHOW_TITLE, DEFAULT_THUMB_SHOW_TITLE) val showJpnTitle: Boolean get() = getBoolean(KEY_SHOW_JPN_TITLE, DEFAULT_SHOW_JPN_TITLE) val showGalleryPages: Boolean get() = getBoolean(KEY_SHOW_GALLERY_PAGES, DEFAULT_SHOW_GALLERY_PAGES) val showComments: Boolean get() = getBoolean(KEY_SHOW_COMMENTS, DEFAULT_SHOW_COMMENTS) val commentThreshold: Int get() = getInt(KEY_COMMENT_THRESHOLD, DEFAULT_COMMENT_THRESHOLD) val previewNum: Int get() = getInt(KEY_PREVIEW_NUM, DEFAULT_PREVIEW_NUM) val previewSize: Int get() = dip2px(40 * getInt(KEY_PREVIEW_SIZE, DEFAULT_PREVIEW_SIZE)) val showTagTranslations: Boolean get() = getBoolean(KEY_SHOW_TAG_TRANSLATIONS, DEFAULT_SHOW_TAG_TRANSLATIONS) private fun putShowTagTranslations(value: Boolean) { putBoolean(KEY_SHOW_TAG_TRANSLATIONS, value) } val translationsLastUpdate: Long get() = getLong(KEY_TRANSLATIONS_LAST_UPDATE, -1) fun putTranslationsLastUpdate(value: Long) { putLong(KEY_TRANSLATIONS_LAST_UPDATE, value) } val meteredNetworkWarning: Boolean get() = getBoolean(KEY_METERED_NETWORK_WARNING, DEFAULT_METERED_NETWORK_WARNING) val requestNews: Boolean get() = getBoolean(KEY_REQUEST_NEWS, DEFAULT_REQUEST_NEWS) val hideHvEvents: Boolean get() = getBoolean(KEY_HIDE_HV_EVENTS, DEFAULT_HIDE_HV_EVENTS) val screenRotation: Int get() = getIntFromStr(KEY_SCREEN_ROTATION, DEFAULT_SCREEN_ROTATION) fun putScreenRotation(value: Int) { putIntToStr(KEY_SCREEN_ROTATION, value) } @GalleryView.LayoutMode val readingDirection: Int get() = GalleryView.sanitizeLayoutMode(getIntFromStr(KEY_READING_DIRECTION, DEFAULT_READING_DIRECTION)) fun putReadingDirection(value: Int) { putIntToStr(KEY_READING_DIRECTION, value) } @GalleryView.ScaleMode val pageScaling: Int get() = GalleryView.sanitizeScaleMode(getIntFromStr(KEY_PAGE_SCALING, DEFAULT_PAGE_SCALING)) fun putPageScaling(value: Int) { putIntToStr(KEY_PAGE_SCALING, value) } @GalleryView.StartPosition val startPosition: Int get() = GalleryView.sanitizeStartPosition(getIntFromStr(KEY_START_POSITION, DEFAULT_START_POSITION)) fun putStartPosition(value: Int) { putIntToStr(KEY_START_POSITION, value) } val readTheme: Int get() = getIntFromStr(KEY_READ_THEME, DEFAULT_READ_THEME) fun putReadTheme(value: Int) { putIntToStr(KEY_READ_THEME, value) } val keepScreenOn: Boolean get() = getBoolean(KEY_KEEP_SCREEN_ON, DEFAULT_KEEP_SCREEN_ON) fun putKeepScreenOn(value: Boolean) { putBoolean(KEY_KEEP_SCREEN_ON, value) } val showClock: Boolean get() = getBoolean(KEY_SHOW_CLOCK, DEFAULT_SHOW_CLOCK) fun putShowClock(value: Boolean) { putBoolean(KEY_SHOW_CLOCK, value) } val showProgress: Boolean get() = getBoolean(KEY_SHOW_PROGRESS, DEFAULT_SHOW_PROGRESS) fun putShowProgress(value: Boolean) { putBoolean(KEY_SHOW_PROGRESS, value) } val showBattery: Boolean get() = getBoolean(KEY_SHOW_BATTERY, DEFAULT_SHOW_BATTERY) fun putShowBattery(value: Boolean) { putBoolean(KEY_SHOW_BATTERY, value) } val showPageInterval: Boolean get() = getBoolean(KEY_SHOW_PAGE_INTERVAL, DEFAULT_SHOW_PAGE_INTERVAL) fun putShowPageInterval(value: Boolean) { putBoolean(KEY_SHOW_PAGE_INTERVAL, value) } val turnPageInterval: Int get() = getInt(KEY_TURN_PAGE_INTERVAL, DEFAULT_TURN_PAGE_INTERVAL) fun putTurnPageInterval(value: Int) { putInt(KEY_TURN_PAGE_INTERVAL, value) } val volumePage: Boolean get() = getBoolean(KEY_VOLUME_PAGE, DEFAULT_VOLUME_PAGE) fun putVolumePage(value: Boolean) { putBoolean(KEY_VOLUME_PAGE, value) } val volumePageInterval: Int get() = getInt(KEY_VOLUME_PAGE_INTERVAL, DEFAULT_VOLUME_PAGE_INTERVAL) fun putVolumePageInterval(value: Int) { putInt(KEY_VOLUME_PAGE_INTERVAL, value) } val reverseVolumePage: Boolean get() = getBoolean(KEY_REVERSE_VOLUME_PAGE, DEFAULT_REVERSE_VOLUME_PAGE) fun putReverseVolumePage(value: Boolean) { putBoolean(KEY_REVERSE_VOLUME_PAGE, value) } val readingFullscreen: Boolean get() = getBoolean(KEY_READING_FULLSCREEN, VALUE_READING_FULLSCREEN) fun putReadingFullscreen(value: Boolean) { putBoolean(KEY_READING_FULLSCREEN, value) } val customScreenLightness: Boolean get() = getBoolean(KEY_CUSTOM_SCREEN_LIGHTNESS, DEFAULT_CUSTOM_SCREEN_LIGHTNESS) fun putCustomScreenLightness(value: Boolean) { putBoolean(KEY_CUSTOM_SCREEN_LIGHTNESS, value) } val screenLightness: Int get() = getInt(KEY_SCREEN_LIGHTNESS, DEFAULT_SCREEN_LIGHTNESS) fun putScreenLightness(value: Int) { putInt(KEY_SCREEN_LIGHTNESS, value) } val downloadLocation: UniFile? get() { val dir: UniFile? val builder = Uri.Builder() builder.scheme(getString(KEY_DOWNLOAD_SAVE_SCHEME, null)) builder.encodedAuthority(getString(KEY_DOWNLOAD_SAVE_AUTHORITY, null)) builder.encodedPath(getString(KEY_DOWNLOAD_SAVE_PATH, null)) builder.encodedQuery(getString(KEY_DOWNLOAD_SAVE_QUERY, null)) builder.encodedFragment(getString(KEY_DOWNLOAD_SAVE_FRAGMENT, null)) dir = UniFile.fromUri(application, builder.build()) return dir ?: UniFile.fromFile(AppConfig.getDefaultDownloadDir()) } fun putDownloadLocation(location: UniFile) { val uri = location.uri putString(KEY_DOWNLOAD_SAVE_SCHEME, uri.scheme) putString(KEY_DOWNLOAD_SAVE_AUTHORITY, uri.encodedAuthority) putString(KEY_DOWNLOAD_SAVE_PATH, uri.encodedPath) putString(KEY_DOWNLOAD_SAVE_QUERY, uri.encodedQuery) putString(KEY_DOWNLOAD_SAVE_FRAGMENT, uri.encodedFragment) } val mediaScan: Boolean get() = getBoolean(KEY_MEDIA_SCAN, DEFAULT_MEDIA_SCAN) val downloadThreadCount: Int get() = getIntFromStr(KEY_MULTI_THREAD_DOWNLOAD, DEFAULT_MULTI_THREAD_DOWNLOAD) val downloadDelay: Int get() = getIntFromStr(KEY_DOWNLOAD_DELAY, DEFAULT_DOWNLOAD_DELAY) val downloadTimeout: Int get() = getInt(KEY_DOWNLOAD_TIMEOUT, DEFAULT_DOWNLOAD_TIMEOUT) val preloadImage: Int get() = getIntFromStr(KEY_PRELOAD_IMAGE, DEFAULT_PRELOAD_IMAGE) fun getDownloadOriginImage(mode: Boolean): Boolean = when (getIntFromStr(KEY_DOWNLOAD_ORIGIN_IMAGE, DEFAULT_DOWNLOAD_ORIGIN_IMAGE)) { 2 -> mode 1 -> true else -> false } val skipCopyImage: Boolean get() = getIntFromStr(KEY_DOWNLOAD_ORIGIN_IMAGE, DEFAULT_DOWNLOAD_ORIGIN_IMAGE) == 2 val security: String? get() = getString(KEY_SECURITY, DEFAULT_SECURITY) fun putSecurity(value: String?) { putString(KEY_SECURITY, value) } val enableFingerprint: Boolean get() = getBoolean(KEY_ENABLE_FINGERPRINT, DEFAULT_ENABLE_FINGERPRINT) fun putEnableFingerprint(value: Boolean) { putBoolean(KEY_ENABLE_FINGERPRINT, value) } val enabledSecurity: Boolean get() = getBoolean(KEY_SEC_SECURITY, DEFAULT_SEC_SECURITY) val saveParseErrorBody: Boolean get() = getBoolean(KEY_SAVE_PARSE_ERROR_BODY, DEFAULT_SAVE_PARSE_ERROR_BODY) val saveCrashLog: Boolean get() = getBoolean(KEY_SAVE_CRASH_LOG, DEFAULT_SAVE_CRASH_LOG) val readCacheSize: Int get() = getIntFromStr(KEY_READ_CACHE_SIZE, DEFAULT_READ_CACHE_SIZE) val appLanguage: String? get() = getString(KEY_APP_LANGUAGE, DEFAULT_APP_LANGUAGE) val proxyType: Int get() = getInt(KEY_PROXY_TYPE, DEFAULT_PROXY_TYPE) fun putProxyType(value: Int) { putInt(KEY_PROXY_TYPE, value) } val proxyIp: String? get() = getString(KEY_PROXY_IP, DEFAULT_PROXY_IP) fun putProxyIp(value: String?) { putString(KEY_PROXY_IP, value) } val proxyPort: Int get() = getInt(KEY_PROXY_PORT, DEFAULT_PROXY_PORT) fun putProxyPort(value: Int) { putInt(KEY_PROXY_PORT, value) } val userAgent: String? get() = getString(KEY_USER_AGENT, CHROME_USER_AGENT) fun putUserAgent(value: String?) { putString(KEY_USER_AGENT, value) } val appLinkVerifyTip: Boolean get() = getBoolean(KEY_APP_LINK_VERIFY_TIP, DEFAULT_APP_LINK_VERIFY_TIP) fun putAppLinkVerifyTip(value: Boolean) { putBoolean(KEY_APP_LINK_VERIFY_TIP, value) } var favCat: Array get() = arrayOf( sSettingsPre.getString(KEY_FAV_CAT_0, DEFAULT_FAV_CAT_0)!!, sSettingsPre.getString(KEY_FAV_CAT_1, DEFAULT_FAV_CAT_1)!!, sSettingsPre.getString(KEY_FAV_CAT_2, DEFAULT_FAV_CAT_2)!!, sSettingsPre.getString(KEY_FAV_CAT_3, DEFAULT_FAV_CAT_3)!!, sSettingsPre.getString(KEY_FAV_CAT_4, DEFAULT_FAV_CAT_4)!!, sSettingsPre.getString(KEY_FAV_CAT_5, DEFAULT_FAV_CAT_5)!!, sSettingsPre.getString(KEY_FAV_CAT_6, DEFAULT_FAV_CAT_6)!!, sSettingsPre.getString(KEY_FAV_CAT_7, DEFAULT_FAV_CAT_7)!!, sSettingsPre.getString(KEY_FAV_CAT_8, DEFAULT_FAV_CAT_8)!!, sSettingsPre.getString(KEY_FAV_CAT_9, DEFAULT_FAV_CAT_9)!!, ) set(value) { check(value.size == 10) sSettingsPre.edit { putString(KEY_FAV_CAT_0, value[0]) .putString(KEY_FAV_CAT_1, value[1]) .putString(KEY_FAV_CAT_2, value[2]) .putString(KEY_FAV_CAT_3, value[3]) .putString(KEY_FAV_CAT_4, value[4]) .putString(KEY_FAV_CAT_5, value[5]) .putString(KEY_FAV_CAT_6, value[6]) .putString(KEY_FAV_CAT_7, value[7]) .putString(KEY_FAV_CAT_8, value[8]) .putString(KEY_FAV_CAT_9, value[9]) } } var favCount: IntArray get() = intArrayOf( sSettingsPre.getInt(KEY_FAV_COUNT_0, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_1, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_2, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_3, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_4, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_5, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_6, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_7, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_8, DEFAULT_FAV_COUNT), sSettingsPre.getInt(KEY_FAV_COUNT_9, DEFAULT_FAV_COUNT), ) set(count) { check(count.size == 10) sSettingsPre.edit { putInt(KEY_FAV_COUNT_0, count[0]) .putInt(KEY_FAV_COUNT_1, count[1]) .putInt(KEY_FAV_COUNT_2, count[2]) .putInt(KEY_FAV_COUNT_3, count[3]) .putInt(KEY_FAV_COUNT_4, count[4]) .putInt(KEY_FAV_COUNT_5, count[5]) .putInt(KEY_FAV_COUNT_6, count[6]) .putInt(KEY_FAV_COUNT_7, count[7]) .putInt(KEY_FAV_COUNT_8, count[8]) .putInt(KEY_FAV_COUNT_9, count[9]) } } val favLocalCount: Int get() = sSettingsPre.getInt(KEY_FAV_LOCAL, DEFAULT_FAV_COUNT) fun putFavLocalCount(count: Int) { sSettingsPre.edit { putInt(KEY_FAV_LOCAL, count) } } val favCloudCount: Int get() = sSettingsPre.getInt(KEY_FAV_CLOUD, DEFAULT_FAV_COUNT) fun putFavCloudCount(count: Int) { sSettingsPre.edit { putInt(KEY_FAV_CLOUD, count) } } val recentFavCat: Int get() = getInt(KEY_RECENT_FAV_CAT, DEFAULT_RECENT_FAV_CAT) fun putRecentFavCat(value: Int) { putInt(KEY_RECENT_FAV_CAT, value) } val defaultFavSlot: Int get() = getInt(KEY_DEFAULT_FAV_SLOT, DEFAULT_DEFAULT_FAV_SLOT) fun putDefaultFavSlot(value: Int) { putInt(KEY_DEFAULT_FAV_SLOT, value) } val neverAddFavNotes: Boolean get() = getBoolean(KEY_NEVER_ADD_FAV_NOTES, DEFAULT_NEVER_ADD_FAV_NOTES) fun putNeverAddFavNotes(value: Boolean) { putBoolean(KEY_NEVER_ADD_FAV_NOTES, value) } val guideGallery: Boolean get() = getBoolean(KEY_GUIDE_GALLERY, DEFAULT_GUIDE_GALLERY) fun putGuideGallery(value: Boolean) { putBoolean(KEY_GUIDE_GALLERY, value) } val selectSite: Boolean get() = getBoolean(KEY_SELECT_SITE, DEFAULT_SELECT_SITE) fun putSelectSite(value: Boolean) { putBoolean(KEY_SELECT_SITE, value) } val needSignIn: Boolean get() = getBoolean(KEY_NEED_SIGN_IN, DEFAULT_NEED_SIGN_IN) fun putNeedSignIn(value: Boolean) { putBoolean(KEY_NEED_SIGN_IN, value) } val displayName: String? get() = getString(KEY_DISPLAY_NAME, DEFAULT_DISPLAY_NAME) fun putDisplayName(value: String?) { putString(KEY_DISPLAY_NAME, value) } val avatar: String? get() = getString(KEY_AVATAR, DEFAULT_AVATAR) fun putAvatar(value: String?) { putString(KEY_AVATAR, value) } val qSSaveProgress: Boolean get() = getBoolean(KEY_QS_SAVE_PROGRESS, DEFAULT_QS_SAVE_PROGRESS) fun putQSSaveProgress(value: Boolean) { putBoolean(KEY_QS_SAVE_PROGRESS, value) } val hasDefaultDownloadLabel: Boolean get() = getBoolean(KEY_HAS_DEFAULT_DOWNLOAD_LABEL, DEFAULT_HAS_DOWNLOAD_LABEL) fun putHasDefaultDownloadLabel(has: Boolean) { putBoolean(KEY_HAS_DEFAULT_DOWNLOAD_LABEL, has) } val defaultDownloadLabel: String? get() = getString(KEY_DEFAULT_DOWNLOAD_LABEL, DEFAULT_DOWNLOAD_LABEL) fun putDefaultDownloadLabel(value: String?) { putString(KEY_DEFAULT_DOWNLOAD_LABEL, value) } val recentDownloadLabel: String? get() = getString(KEY_RECENT_DOWNLOAD_LABEL, DEFAULT_RECENT_DOWNLOAD_LABEL) fun putRecentDownloadLabel(value: String?) { putString(KEY_RECENT_DOWNLOAD_LABEL, value) } val defaultSortingMethod: Int get() = getInt(KEY_DEFAULT_SORTING_METHOD, DEFAULT_SORTING_METHOD) fun putDefaultSortingMethod(value: Int) { putInt(KEY_DEFAULT_SORTING_METHOD, value) } val defaultTopList: String? get() = getString(KEY_DEFAULT_TOP_LIST, DEFAULT_TOP_LIST) fun putDefaultTopList(value: String?) { putString(KEY_DEFAULT_TOP_LIST, value) } val removeImageFiles: Boolean get() = getBoolean(KEY_REMOVE_IMAGE_FILES, DEFAULT_REMOVE_IMAGE_FILES) fun putRemoveImageFiles(value: Boolean) { putBoolean(KEY_REMOVE_IMAGE_FILES, value) } val clipboardTextHashCode: Int get() = getInt(KEY_CLIPBOARD_TEXT_HASH_CODE, DEFAULT_CLIPBOARD_TEXT_HASH_CODE) fun putClipboardTextHashCode(value: Int) { putInt(KEY_CLIPBOARD_TEXT_HASH_CODE, value) } val archivePasswds: Set? get() = getStringSet(KEY_ARCHIVE_PASSWDS) fun putPasswdToArchivePasswds(value: String) { putStringToStringSet(KEY_ARCHIVE_PASSWDS, value) } val notificationRequired: Boolean get() = getBoolean(KEY_NOTIFICATION_REQUIRED, false) fun putNotificationRequired() { putBoolean(KEY_NOTIFICATION_REQUIRED, true) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/UrlOpener.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.res.Configuration import android.widget.Toast import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.core.net.toUri import com.hippo.ehviewer.client.EhUrlOpener.parseUrl import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.parser.GalleryPageUrlParser import com.hippo.ehviewer.ui.GalleryActivity import com.hippo.ehviewer.ui.MainActivity import com.hippo.scene.StageActivity import rikka.core.res.resolveColor object UrlOpener { fun openUrl( context: Context, url: String?, ehUrl: Boolean, galleryDetail: GalleryDetail? = null, ) { if (url.isNullOrEmpty()) { return } var intent: Intent val uri = url.toUri() if (ehUrl) { galleryDetail?.let { val result = GalleryPageUrlParser.parse(url) if (result != null) { if (result.gid == it.gid) { intent = Intent(context, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, it) intent.putExtra(GalleryActivity.KEY_PAGE, result.page) context.startActivity(intent) return } } else if (url.startsWith("#c")) { try { intent = Intent(context, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, it) intent.putExtra(GalleryActivity.KEY_PAGE, url.replace("#c", "").toInt() - 1) context.startActivity(intent) return } catch (_: NumberFormatException) { } } } parseUrl(url)?.let { intent = Intent(context, MainActivity::class.java) intent.action = StageActivity.ACTION_START_SCENE intent.putExtra(StageActivity.KEY_SCENE_NAME, it.clazz.name) intent.putExtra(StageActivity.KEY_SCENE_ARGS, it.args) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context.startActivity(intent) return } } val isNight = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_YES > 0 val customTabsIntent = CustomTabsIntent.Builder() customTabsIntent.setShowTitle(true) val params = CustomTabColorSchemeParams.Builder() .setToolbarColor(context.theme.resolveColor(R.attr.toolbarColor)) .build() customTabsIntent.setDefaultColorSchemeParams(params) customTabsIntent.setColorScheme(if (isNight) CustomTabsIntent.COLOR_SCHEME_DARK else CustomTabsIntent.COLOR_SCHEME_LIGHT) try { customTabsIntent.build().launchUrl(context, uri) } catch (_: ActivityNotFoundException) { Toast.makeText(context, R.string.no_browser_installed, Toast.LENGTH_LONG).show() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/WindowInsetsAnimationHelper.java ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer; import android.view.View; import androidx.annotation.NonNull; import androidx.core.view.WindowInsetsAnimationCompat; import androidx.core.view.WindowInsetsCompat; import com.hippo.yorozuya.MathUtils; import java.util.HashMap; import java.util.List; public class WindowInsetsAnimationHelper extends WindowInsetsAnimationCompat.Callback { private final View[] views; private final HashMap startPaddings = new HashMap<>(); private final HashMap endPaddings = new HashMap<>(); WindowInsetsAnimationCompat animation; public WindowInsetsAnimationHelper(int dispatchMode, View... views) { super(dispatchMode); this.views = views; } @Override public void onPrepare(@NonNull WindowInsetsAnimationCompat animation) { super.onPrepare(animation); this.animation = animation; for (View view : views) { if (view == null) { continue; } startPaddings.put(view, view.getPaddingBottom()); } } @NonNull @Override public WindowInsetsAnimationCompat.BoundsCompat onStart(@NonNull WindowInsetsAnimationCompat animation, @NonNull WindowInsetsAnimationCompat.BoundsCompat bounds) { this.animation = animation; for (View view : views) { if (view == null) { continue; } endPaddings.put(view, view.getPaddingBottom()); Integer padding = startPaddings.get(view); int startPadding = (padding != null) ? padding : 0; view.setTranslationY(-(startPadding - view.getPaddingBottom())); } return bounds; } @NonNull @Override public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets, @NonNull List runningAnimations) { if (animation == null) { return insets; } WindowInsetsAnimationCompat imeAnimation = null; for (WindowInsetsAnimationCompat animation : runningAnimations) { if ((animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0) { imeAnimation = animation; break; } } if (imeAnimation != null) { for (View view : views) { if (view == null) { continue; } Integer padding1 = startPaddings.get(view); int startPadding = (padding1 != null) ? padding1 : 0; Integer padding2 = endPaddings.get(view); int endPadding = (padding2 != null) ? padding2 : 0; view.setTranslationY(MathUtils.lerp(endPadding - startPadding, 0, animation.getInterpolatedFraction())); } } return insets; } @Override public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { super.onEnd(animation); startPaddings.clear(); endPaddings.clear(); this.animation = null; for (View view : views) { if (view == null) { continue; } view.setTranslationY(0); } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhCacheKeyFactory.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import com.hippo.ehviewer.client.data.AbstractGalleryInfo // E-Hentai Large Preview (v1 Cover): https://ehgt.org/**.jpg // ExHentai Large Preview (v1 Cover): https://s.exhentai.org/t/**.jpg // E-Hentai v2 Cover: https://ehgt.org/w/**.webp // ExHentai v2 Cover: https://s.exhentai.org/w/**.webp // Normal Preview (v1 v2): https://*.hath.network/c(m|1|2)/[timed token]/[gid]-[index].(jpg|webp) // Large Preview (v2): https://*.hath.network/[timed token]/**.webp const val URL_PREFIX_THUMB_E = "https://ehgt.org/" const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/" private const val URL_PREFIX_V1_THUMB_EX = URL_PREFIX_THUMB_EX + "t/" private const val URL_PREFIX_V1_THUMB_EX_OLD = "https://exhentai.org/t/" private val NormalPreviewKeyRegex = Regex("/(c[12m])/[^/]+/(\\d+-\\d+)") fun getImageKey(gid: Long, index: Int) = "image:$gid:$index" fun getThumbKey(gid: Long): String = "preview:large:$gid:0" fun getLargePreviewKey(gid: Long, index: Int) = "preview:large:$gid:$index" fun getNormalPreviewKey(url: String) = NormalPreviewKeyRegex.find(url)?.let { "preview:normal:${it.groupValues[1]}:${it.groupValues[2]}" } ?: url val String.isNormalPreviewKey get() = startsWith("preview:normal:") val String.thumbUrl get() = removePrefix(URL_PREFIX_THUMB_E) .removePrefix(URL_PREFIX_V1_THUMB_EX_OLD) .removePrefix(URL_PREFIX_V1_THUMB_EX) .removePrefix(URL_PREFIX_THUMB_EX).let { if (it.startsWith("https:")) { it } else { if (EhUtils.isExHentai) { if (it.endsWith("webp")) URL_PREFIX_THUMB_EX else URL_PREFIX_V1_THUMB_EX } else { URL_PREFIX_THUMB_E } + it } } val AbstractGalleryInfo.thumbUrl get() = thumb?.thumbUrl ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhClient.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import com.hippo.util.launchIO import com.hippo.util.withUIContext import java.io.File import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope object EhClient { internal fun enqueue(request: EhRequest, scope: CoroutineScope) { check(!request.isActive) // Abort if attempt to execute an active request request.job = scope.launchIO { val callback: Callback? = request.callback try { val result: Any? = request.run { if (args == null) { execute(method) } else { execute(method, *args!!) } } withUIContext { callback?.onSuccess(result) } } catch (e: Exception) { if (e is CancellationException) { throw e // Don't catch coroutine CancellationException } e.printStackTrace() withUIContext { callback?.onFailure(e) } } } } suspend fun execute(method: Int, vararg params: Any?): Any? = when (method) { METHOD_GET_GALLERY_LIST -> EhEngine.getGalleryList( params[0] as String, ) METHOD_GET_GALLERY_DETAIL -> EhEngine.getGalleryDetail( params[0] as String, ) METHOD_GET_PREVIEW_SET -> EhEngine.getPreviewSet( params[0] as String, ) METHOD_GET_RATE_GALLERY -> EhEngine.rateGallery( params[0] as Long, params[1] as String?, params[2] as Long, params[3] as String?, params[4] as Float, ) METHOD_GET_COMMENT_GALLERY -> EhEngine.commentGallery( params[0] as String?, params[1] as String, params[2] as String?, ) METHOD_GET_GALLERY_TOKEN -> EhEngine.getGalleryToken( params[0] as Long, params[1] as String?, params[2] as Int, ) METHOD_GET_FAVORITES -> EhEngine.getFavorites( params[0] as String, ) METHOD_ADD_FAVORITES -> EhEngine.addFavorites( params[0] as Long, params[1] as String?, params[2] as Int, params[3] as String?, ) METHOD_ADD_FAVORITES_RANGE -> @Suppress("UNCHECKED_CAST") EhEngine.addFavoritesRange( params[0] as LongArray, params[1] as Array, params[2] as Int, ) METHOD_MODIFY_FAVORITES -> EhEngine.modifyFavorites( params[0] as String, params[1] as LongArray, params[2] as Int, ) METHOD_GET_TORRENT_LIST -> EhEngine.getTorrentList( params[0] as String, params[1] as Long, params[2] as String?, ) METHOD_VOTE_COMMENT -> EhEngine.voteComment( params[0] as Long, params[1] as String?, params[2] as Long, params[3] as String?, params[4] as Long, params[5] as Int, ) METHOD_IMAGE_SEARCH -> EhEngine.imageSearch( params[0] as File, params[1] as Boolean, params[2] as Boolean, params[3] as Boolean, ) METHOD_ARCHIVE_LIST -> EhEngine.getArchiveList( params[0] as String, params[1] as Long, params[2] as String?, ) METHOD_DOWNLOAD_ARCHIVE -> EhEngine.downloadArchive( params[0] as Long, params[1] as String?, params[2] as String?, params[3] as Boolean, ) METHOD_VOTE_TAG -> EhEngine.voteTag( params[0] as Long, params[1] as String?, params[2] as Long, params[3] as String?, params[4] as String?, params[5] as Int, ) else -> throw IllegalStateException("Can't detect method $method") } interface Callback { fun onSuccess(result: E) fun onFailure(e: Exception) fun onCancel() } const val METHOD_GET_GALLERY_LIST = 1 const val METHOD_GET_GALLERY_DETAIL = 3 const val METHOD_GET_PREVIEW_SET = 4 const val METHOD_GET_RATE_GALLERY = 5 const val METHOD_GET_COMMENT_GALLERY = 6 const val METHOD_GET_GALLERY_TOKEN = 7 const val METHOD_GET_FAVORITES = 8 const val METHOD_ADD_FAVORITES = 9 const val METHOD_ADD_FAVORITES_RANGE = 10 const val METHOD_MODIFY_FAVORITES = 11 const val METHOD_GET_TORRENT_LIST = 12 const val METHOD_VOTE_COMMENT = 15 const val METHOD_IMAGE_SEARCH = 16 const val METHOD_ARCHIVE_LIST = 17 const val METHOD_DOWNLOAD_ARCHIVE = 18 const val METHOD_VOTE_TAG = 19 } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhCookieStore.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import android.webkit.CookieManager import com.hippo.ehviewer.EhApplication import com.hippo.network.CookieDatabase import com.hippo.network.CookieSet import com.hippo.util.launchIO import java.util.Collections import java.util.regex.Pattern import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @OptIn(DelicateCoroutinesApi::class) object EhCookieStore : CookieJar { private val cookieManager = CookieManager.getInstance() private val db: CookieDatabase = CookieDatabase(EhApplication.application, "okhttp3-cookie.db") private val map: MutableMap = db.allCookies private val updateLock = Mutex() const val KEY_CLOUDFLARE = "cf_clearance" const val KEY_HATH_PERKS = "hath_perks" const val KEY_IPB_MEMBER_ID = "ipb_member_id" const val KEY_IPB_PASS_HASH = "ipb_pass_hash" const val KEY_IGNEOUS = "igneous" const val KEY_QUOTA = "iq" const val KEY_SETTINGS_PROFILE = "sp" const val KEY_STAR = "star" private const val KEY_UTMP = "__utmp" private const val KEY_CONTENT_WARNING = "nw" private const val CONTENT_WARNING_NOT_SHOW = "1" private val sTipsCookie: Cookie = Cookie.Builder() .name(KEY_CONTENT_WARNING) .value(CONTENT_WARNING_NOT_SHOW) .domain(EhUrl.DOMAIN_E) .path("/") .expiresAt(Long.MAX_VALUE) .build() fun hasSignedIn(): Boolean { val url = EhUrl.HOST_E.toHttpUrl() return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH) } suspend fun copyCookie(domain: String, newDomain: String, name: String, path: String = "/") { val cookie = map[domain]?.get(name, domain, path) cookie?.let { addCookie(newCookie(it, newDomain)) } } suspend fun deleteCookie(url: HttpUrl, name: String) { val deletedCookie = Cookie.Builder() .name(name) .value("deleted") .domain(url.host) .expiresAt(0) .build() addCookie(deletedCookie) } suspend fun addCookie(cookie: Cookie) { updateLock.withLock { // For cookie database var toAdd: Cookie? = null var toUpdate: Cookie? = null var toRemove: Cookie? = null var set = map[cookie.domain] if (set == null) { set = CookieSet() map[cookie.domain] = set } if (cookie.expiresAt <= System.currentTimeMillis()) { toRemove = set.remove(cookie) // If the cookie is not persistent, it's not in database if (toRemove != null && !toRemove.persistent) { toRemove = null } } else { toAdd = cookie toUpdate = set.add(cookie) // If the cookie is not persistent, it's not in database if (!toAdd.persistent) toAdd = null if (toUpdate != null && !toUpdate.persistent) toUpdate = null // Remove the cookie if it updates to null if (toAdd == null && toUpdate != null) { toRemove = toUpdate toUpdate = null } } if (toRemove != null) { db.remove(toRemove) } if (toAdd != null) { if (toUpdate != null) { db.update(toUpdate, toAdd) } else { db.add(toAdd) } } } } fun getCookieHeader(url: HttpUrl): String { val cookies = getCookies(url) val cookieHeader = StringBuilder() for (i in cookies.indices) { if (i > 0) { cookieHeader.append("; ") } val cookie = cookies[i] cookieHeader.append(cookie.name).append('=').append(cookie.value) } return cookieHeader.toString() } fun getCookieValue(url: HttpUrl, name: String): String? { getCookies(url).forEach { if (it.name == name) return it.value } return null } @Synchronized fun getCookies(url: HttpUrl): List { val accepted: MutableList = ArrayList() val expired: MutableList = ArrayList() for ((domain, cookieSet) in map) { if (domainMatch(url, domain)) { cookieSet[url, accepted, expired] } } for (cookie in expired) { if (cookie.persistent) { launchIO { db.remove(cookie) } } } // RFC 6265 Section-5.4 step 2, sort the cookie-list // Cookies with longer paths are listed before cookies with shorter paths. // Ignore creation-time, we don't store them. accepted.sortWith { o1: Cookie, o2: Cookie -> o2.path.length - o1.path.length } return accepted } /** * Remove all cookies in this `CookieRepository`. */ suspend fun clear() { updateLock.withLock { map.clear() db.clear() } } fun newCookie( cookie: Cookie, newDomain: String, forcePersistent: Boolean = false, forceLongLive: Boolean = false, forceNotHostOnly: Boolean = false, ): Cookie { val builder = Cookie.Builder() builder.name(cookie.name) builder.value(cookie.value) if (forceLongLive) { builder.expiresAt(Long.MAX_VALUE) } else if (cookie.persistent) { builder.expiresAt(cookie.expiresAt) } else if (forcePersistent) { builder.expiresAt(Long.MAX_VALUE) } if (cookie.hostOnly && !forceNotHostOnly) { builder.hostOnlyDomain(newDomain) } else { builder.domain(newDomain) } builder.path(cookie.path) if (cookie.secure) { builder.secure() } if (cookie.httpOnly) { builder.httpOnly() } return builder.build() } /** * Quick and dirty pattern to differentiate IP addresses from hostnames. This is an approximation * of Android's private InetAddress#isNumeric API. * * * This matches IPv6 addresses as a hex string containing at least one colon, and possibly * including dots after the first colon. It matches IPv4 addresses as strings containing only * decimal digits and dots. This pattern matches strings like "a:.23" and "54" that are neither IP * addresses nor hostnames; they will be verified as IP addresses (which is a more strict * verification). */ private val VERIFY_AS_IP_ADDRESS = Pattern.compile("([0-9a-fA-F]*:[0-9a-fA-F:.]*)|([\\d.]+)") /** * Returns true if `host` is not a host name and might be an IP address. */ private fun verifyAsIpAddress(host: String): Boolean = VERIFY_AS_IP_ADDRESS.matcher(host).matches() // okhttp3.Cookie.domainMatch(HttpUrl, String) private fun domainMatch(url: HttpUrl, domain: String?): Boolean { val urlHost = url.host return if (urlHost == domain) { true // As in 'example.com' matching 'example.com'. } else { urlHost.endsWith(domain!!) && urlHost[urlHost.length - domain.length - 1] == '.' && !verifyAsIpAddress( urlHost, ) } // As in 'example.com' matching 'www.example.com'. } private fun contains(url: HttpUrl, name: String?): Boolean { for (cookie in getCookies(url)) { if (cookie.name == name) { return true } } return false } fun loadForWebView(url: String, filter: (Cookie) -> Boolean) { cookieManager.removeAllCookies(null) getCookies(url.toHttpUrl()).forEach { if (filter(it)) { cookieManager.setCookie(url, it.toString()) } } } fun saveFromWebView(url: String, filter: (Cookie) -> Boolean): Boolean { val cookies = cookieManager.getCookie(url) ?: return false var saved = false cookies.split(';').forEach { header -> Cookie.parse(url.toHttpUrl(), header.trim())?.let { if (filter(it)) { val persistentCookie = Cookie.Builder() .name(it.name) .value(it.value) .domain(it.domain) .path(it.path) .expiresAt(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000) .apply { if (it.secure) secure() if (it.httpOnly) httpOnly() if (it.hostOnly) hostOnlyDomain(it.domain) } .build() launchIO { addCookie(persistentCookie) } saved = true } } } return saved } override fun loadForRequest(url: HttpUrl): List { val cookies = getCookies(url) val checkTips = domainMatch(url, EhUrl.DOMAIN_E) return if (checkTips) { val result: MutableList = ArrayList(cookies.size + 1) // Add all but skip some for (cookie in cookies) { val name = cookie.name if (KEY_CONTENT_WARNING == name) { continue } result.add(cookie) } // Add some result.add(sTipsCookie) Collections.unmodifiableList(result) } else { cookies } } override fun saveFromResponse(url: HttpUrl, cookies: List) { for (cookie in cookies) { // See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873 if (cookie.name != KEY_UTMP) { launchIO { addCookie(cookie) } } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhEngine.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import android.util.Log import com.hippo.ehviewer.AppConfig import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.EhApplication.Companion.application import com.hippo.ehviewer.GetText import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.data.GalleryCommentList import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.data.GalleryTagGroup import com.hippo.ehviewer.client.data.PreviewSet import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.exception.InsufficientFundsException import com.hippo.ehviewer.client.exception.NotLoggedInException import com.hippo.ehviewer.client.exception.ParseException import com.hippo.ehviewer.client.parser.ArchiveParser import com.hippo.ehviewer.client.parser.EventPaneParser import com.hippo.ehviewer.client.parser.FavoritesParser import com.hippo.ehviewer.client.parser.ForumsParser import com.hippo.ehviewer.client.parser.GalleryApiParser import com.hippo.ehviewer.client.parser.GalleryDetailParser import com.hippo.ehviewer.client.parser.GalleryListParser import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerParser import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser import com.hippo.ehviewer.client.parser.GalleryPageApiParser import com.hippo.ehviewer.client.parser.GalleryPageParser import com.hippo.ehviewer.client.parser.GalleryTokenApiParser import com.hippo.ehviewer.client.parser.HomeParser import com.hippo.ehviewer.client.parser.ProfileParser import com.hippo.ehviewer.client.parser.RateGalleryParser import com.hippo.ehviewer.client.parser.SignInParser import com.hippo.ehviewer.client.parser.TorrentParser import com.hippo.ehviewer.client.parser.UserConfigParser import com.hippo.ehviewer.client.parser.VoteCommentParser import com.hippo.ehviewer.client.parser.VoteTagParser import com.hippo.network.StatusCodeException import java.io.File import kotlin.math.ceil import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import okhttp3.FormBody import okhttp3.Headers import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.coroutines.executeAsync import org.json.JSONArray import org.json.JSONObject import org.jsoup.Jsoup private val okHttpClient = EhApplication.okHttpClient private val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType() private const val TAG = "EhEngine" private const val MAX_REQUEST_SIZE = 25 private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType() private var sEhFilter = EhFilter private fun rethrowExactly(code: Int, body: String, e: Throwable) { // Check sad panda (without panda) if (body.isEmpty()) { if (EhUtils.isExHentai) { throw EhException("Sad Panda\n(without panda)") } else { throw EhException("IP banned") } } // Check 503 if (body.contains("Backend fetch failed")) { throw EhException("Error 503\nBackend fetch failed") } // Check Gallery Not Available if (body.contains("Gallery Not Available - ")) { val error = GalleryNotAvailableParser.parse(body) if (!error.isNullOrBlank()) { throw EhException(error) } } // Check bad response code if (code >= 400) { if (Settings.saveParseErrorBody && e is ParseException && body.isNotEmpty()) { AppConfig.saveParseErrorBody(e, body) } throw StatusCodeException(code) } if (e is ParseException) { if (!body.contains("<")) { throw EhException(body) } else { if (Settings.saveParseErrorBody) { AppConfig.saveParseErrorBody(e, body) } throw EhException(GetText.getString(R.string.error_parse_error), e) } } // We can't translate it, rethrow it anyway throw e } private suspend inline fun Request.Builder.executeAndParsingWith(block: String.() -> T): T = okHttpClient.newCall(this.build()).executeAsync().use { response -> val body = response.body.string() runCatching { block(body) }.onFailure { rethrowExactly(response.code, body, it) }.getOrThrow() } object EhEngine { suspend fun getOriginalImageUrl(url: String, referer: String?): String { Log.d(TAG, url) return EhRequestBuilder(url, referer).build().executeNoRedirect { header("Location")?.apply { if (contains("bounce_login")) { throw NotLoggedInException() } } ?: throw InsufficientFundsException() } } suspend fun signIn(username: String, password: String): String { val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00" val builder = FormBody.Builder() .add("referer", referer) .add("b", "") .add("bt", "") .add("UserName", username) .add("PassWord", password) .add("CookieDate", "1") val url = EhUrl.API_SIGN_IN val origin = "https://forums.e-hentai.org" Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(builder.build()) .executeAndParsingWith(SignInParser::parse) } private suspend fun fillGalleryList( list: MutableList, url: String, filter: Boolean, ) { // Filter title and uploader if (filter) { list.removeAll { !sEhFilter.filterTitle(it) || !sEhFilter.filterUploader(it) } } var hasTags = false var hasPages = false var hasRated = false for (gi in list) { if (gi.simpleTags != null) { hasTags = true } if (gi.pages != 0) { hasPages = true } if (gi.rated) { hasRated = true } } val needApi = filter && sEhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated if (needApi) { fillGalleryListByApi(list, url) } // Filter tag if (filter) { // Thumbnail mode need filter uploader again list.removeAll { !sEhFilter.filterUploader(it) || !sEhFilter.filterTag(it) || !sEhFilter.filterTagNamespace(it) } } } suspend fun getGalleryList(url: String): GalleryListParser.Result { val referer = EhUrl.referer Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith(GalleryListParser::parse) .apply { fillGalleryList(galleryInfoList, url, true) } } suspend fun fillGalleryListByApi( galleryInfoList: List, referer: String, ): List { val requestItems: MutableList = ArrayList(MAX_REQUEST_SIZE) var i = 0 val size = galleryInfoList.size while (i < size) { requestItems.add(galleryInfoList[i]) if (requestItems.size == MAX_REQUEST_SIZE || i == size - 1) { doFillGalleryListByApi(requestItems, referer) requestItems.clear() } i++ } return galleryInfoList } private suspend fun doFillGalleryListByApi( galleryInfoList: List, referer: String, ) { val json = JSONObject() json.put("method", "gdata") val ja = JSONArray() var i = 0 val size = galleryInfoList.size while (i < size) { val gi = galleryInfoList[i] val g = JSONArray() g.put(gi.gid) g.put(gi.token) ja.put(g) i++ } json.put("gidlist", ja) json.put("namespace", 1) val url = EhUrl.apiUrl val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(json.toString().toRequestBody(MEDIA_TYPE_JSON)) .executeAndParsingWith { GalleryApiParser.parse(this, galleryInfoList) } } suspend fun getGalleryDetail(url: String): GalleryDetail { val referer = EhUrl.referer Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith { EventPaneParser.parse(this)?.let { application.showEventPane(it) } GalleryDetailParser.parse(this) } } suspend fun getPreviewSet(url: String): Pair { val referer = EhUrl.referer Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith { GalleryDetailParser.parsePreviewSet(this) to GalleryDetailParser.parsePreviewPages(this) } } @Throws(Throwable::class) suspend fun rateGallery( apiUid: Long, apiKey: String?, gid: Long, token: String?, rating: Float, ): RateGalleryParser.Result { val json = JSONObject() json.put("method", "rategallery") json.put("apiuid", apiUid) json.put("apikey", apiKey) json.put("gid", gid) json.put("token", token) json.put("rating", ceil((rating * 2).toDouble()).toInt()) val requestBody: RequestBody = json.toString().toRequestBody(MEDIA_TYPE_JSON) val url = EhUrl.apiUrl val referer = EhUrl.getGalleryDetailUrl(gid, token) val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(requestBody) .executeAndParsingWith(RateGalleryParser::parse) } suspend fun commentGallery( url: String?, comment: String, id: String?, ): GalleryCommentList { val builder = FormBody.Builder() if (id == null) { builder.add("commenttext_new", comment) } else { builder.add("commenttext_edit", comment) builder.add("edit_comment", id) } val origin = EhUrl.origin Log.d(TAG, url!!) return EhRequestBuilder(url, url, origin) .post(builder.build()) .executeAndParsingWith { val document = Jsoup.parse(this) val elements = document.select("#chd + p") if (elements.isNotEmpty()) { throw EhException(elements[0].text()) } GalleryDetailParser.parseComments(document) } } suspend fun getGalleryToken( gid: Long, gtoken: String?, page: Int, ): String { val json = JSONObject() .put("method", "gtoken") .put( "pagelist", JSONArray().put( JSONArray().put(gid).put(gtoken).put(page + 1), ), ) val requestBody: RequestBody = json.toString().toRequestBody(MEDIA_TYPE_JSON) val url = EhUrl.apiUrl val referer = EhUrl.referer val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(requestBody) .executeAndParsingWith(GalleryTokenApiParser::parse) } suspend fun getFavorites( url: String, ): FavoritesParser.Result { val referer = EhUrl.referer Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith(FavoritesParser::parse) .apply { fillGalleryList(galleryInfoList, url, false) } } /** * @param dstCat -1 for delete, 0 - 9 for cloud favorite, others throw Exception * @param note max 250 characters */ suspend fun addFavorites( gid: Long, token: String?, dstCat: Int, note: String?, ) { val catStr: String = when (dstCat) { -1 -> { "favdel" } in 0..9 -> { dstCat.toString() } else -> { throw EhException("Invalid dstCat: $dstCat") } } val builder = FormBody.Builder() builder.add("favcat", catStr) builder.add("favnote", note ?: "") // submit=Add+to+Favorites is not necessary, just use submit=Apply+Changes all the time builder.add("submit", "Apply Changes") builder.add("update", "1") val url = EhUrl.getAddFavorites(gid, token) val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, url, origin) .post(builder.build()) .executeAndParsingWith { } } @Throws(Throwable::class) suspend fun addFavoritesRange( gidArray: LongArray, tokenArray: Array, dstCat: Int, ): Void? { check(gidArray.size == tokenArray.size) var i = 0 val n = gidArray.size while (i < n) { addFavorites(gidArray[i], tokenArray[i], dstCat, null) i++ } return null } suspend fun modifyFavorites( url: String, gidArray: LongArray, dstCat: Int, ): FavoritesParser.Result { val catStr: String = when (dstCat) { -1 -> { "delete" } in 0..9 -> { "fav$dstCat" } else -> { throw EhException("Invalid dstCat: $dstCat") } } val builder = FormBody.Builder() builder.add("ddact", catStr) for (gid in gidArray) { builder.add("modifygids[]", gid.toString()) } builder.add("apply", "Apply") val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, url, origin) .post(builder.build()) .executeAndParsingWith(FavoritesParser::parse) .apply { fillGalleryList(galleryInfoList, url, false) } } suspend fun getTorrentList( url: String, gid: Long, token: String?, ): List { val referer = EhUrl.getGalleryDetailUrl(gid, token) Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith(TorrentParser::parse) } suspend fun getArchiveList( url: String, gid: Long, token: String?, ): ArchiveParser.Result { val referer = EhUrl.getGalleryDetailUrl(gid, token) Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith(ArchiveParser::parse) .apply { funds = funds ?: getFunds() } } suspend fun downloadArchive( gid: Long, token: String?, res: String?, isHAtH: Boolean, ): String? { if (res.isNullOrEmpty()) { throw EhException("Invalid res: $res") } val builder = FormBody.Builder() if (isHAtH) { builder.add("hathdl_xres", res) } else { builder.add("dltype", res) if (res == "org") { builder.add("dlcheck", "Download Original Archive") } else { builder.add("dlcheck", "Download Resample Archive") } } val url = EhUrl.getDownloadArchive(gid, token) val referer = EhUrl.getGalleryDetailUrl(gid, token) val origin = EhUrl.origin Log.d(TAG, url) val request = EhRequestBuilder(url, referer, origin) .post(builder.build()) var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl) if (!isHAtH) { if (result == null) { // Wait for the server to prepare archives delay(1000) result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl) if (result == null) { throw EhException("Archive unavailable") } } return result } return null } private suspend fun getFunds(): HomeParser.Funds { val url = EhUrl.URL_FUNDS Log.d(TAG, url) return EhRequestBuilder(url).executeAndParsingWith(HomeParser::parseFunds) } private suspend fun getImageLimitsInternal(): HomeParser.Limits { val url = EhUrl.URL_HOME Log.d(TAG, url) return EhRequestBuilder(url).executeAndParsingWith(HomeParser::parse) } suspend fun getImageLimits(): HomeParser.Result = coroutineScope { val limitsDeferred = async { getImageLimitsInternal() } val fundsDeferred = async { getFunds() } HomeParser.Result(limitsDeferred.await(), fundsDeferred.await()) } suspend fun resetImageLimits(unlock: Boolean = false): HomeParser.Limits { val builder = FormBody.Builder() .add("reset_imagelimit", if (unlock) "Unlock Quota" else "Reset Quota") val url = EhUrl.URL_HOME Log.d(TAG, url) return EhRequestBuilder(url) .post(builder.build()) .executeAndParsingWith(HomeParser::parseResetLimits) } suspend fun getNews(parse: Boolean): String? { val url = EhUrl.URL_NEWS val referer = EhUrl.REFERER_E Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null } } private suspend fun getProfileInternal( url: String, referer: String, ): ProfileParser.Result { Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith(ProfileParser::parse) } suspend fun getProfile(): ProfileParser.Result { val url = EhUrl.URL_FORUMS Log.d(TAG, url) return getProfileInternal( EhRequestBuilder(url).executeAndParsingWith(ForumsParser::parse), url, ) } private suspend fun getUConfigInternal(url: String) { Log.d(TAG, url) EhRequestBuilder(url).executeAndParsingWith(UserConfigParser::parse) } suspend fun getUConfig(url: String = EhUrl.uConfigUrl) { runCatching { getUConfigInternal(url) }.onFailure { // It may get redirected when accessing ex for the first time if (url == EhUrl.URL_UCONFIG_EX) { it.printStackTrace() getUConfigInternal(url) } else { throw it } } } suspend fun voteComment( apiUid: Long, apiKey: String?, gid: Long, token: String?, commentId: Long, commentVote: Int, ): VoteCommentParser.Result { val json = JSONObject() json.put("method", "votecomment") json.put("apiuid", apiUid) json.put("apikey", apiKey) json.put("gid", gid) json.put("token", token) json.put("comment_id", commentId) json.put("comment_vote", commentVote) val requestBody: RequestBody = json.toString().toRequestBody(MEDIA_TYPE_JSON) val url = EhUrl.apiUrl val referer = EhUrl.referer val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(requestBody) .executeAndParsingWith { VoteCommentParser.parse(this, commentVote) } } suspend fun voteTag( apiUid: Long, apiKey: String?, gid: Long, token: String?, tags: String?, vote: Int, ): Pair?> { val json = JSONObject() json.put("method", "taggallery") json.put("apiuid", apiUid) json.put("apikey", apiKey) json.put("gid", gid) json.put("token", token) json.put("tags", tags) json.put("vote", vote) val requestBody: RequestBody = json.toString().toRequestBody(MEDIA_TYPE_JSON) val url = EhUrl.apiUrl val referer = EhUrl.referer val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(requestBody) .executeAndParsingWith(VoteTagParser::parse) } /** * @param image Must be jpeg */ suspend fun imageSearch( image: File, uss: Boolean, osc: Boolean, se: Boolean, ): GalleryListParser.Result { val builder = MultipartBody.Builder() builder.setType(MultipartBody.FORM) builder.addPart( Headers.headersOf( "Content-Disposition", "form-data; name=\"sfile\"; filename=\"a.jpg\"", ), image.asRequestBody(MEDIA_TYPE_JPEG), ) if (uss) { builder.addPart( Headers.headersOf("Content-Disposition", "form-data; name=\"fs_similar\""), "on".toRequestBody(), ) } if (osc) { builder.addPart( Headers.headersOf("Content-Disposition", "form-data; name=\"fs_covers\""), "on".toRequestBody(), ) } if (se) { builder.addPart( Headers.headersOf("Content-Disposition", "form-data; name=\"fs_exp\""), "on".toRequestBody(), ) } builder.addPart( Headers.headersOf("Content-Disposition", "form-data; name=\"f_sfile\""), "File Search".toRequestBody(), ) val url = EhUrl.imageSearchUrl val referer = EhUrl.referer val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(builder.build()) .executeAndParsingWith(GalleryListParser::parse) .apply { fillGalleryList(galleryInfoList, url, true) } } suspend fun getGalleryPage( url: String?, gid: Long, token: String?, ): GalleryPageParser.Result { val referer = EhUrl.getGalleryDetailUrl(gid, token) Log.d(TAG, url!!) return EhRequestBuilder(url, referer).executeAndParsingWith(GalleryPageParser::parse) } suspend fun getGalleryPageApi( gid: Long, index: Int, pToken: String?, showKey: String?, previousPToken: String?, ): GalleryPageApiParser.Result { val json = JSONObject() json.put("method", "showpage") json.put("gid", gid) json.put("page", index + 1) json.put("imgkey", pToken) json.put("showkey", showKey) val requestBody: RequestBody = json.toString().toRequestBody(MEDIA_TYPE_JSON) val url = EhUrl.apiUrl var referer: String? = null if (index > 0 && previousPToken != null) { referer = EhUrl.getPageUrl(gid, index - 1, previousPToken) } val origin = EhUrl.origin Log.d(TAG, url) return EhRequestBuilder(url, referer, origin) .post(requestBody) .executeAndParsingWith(GalleryPageApiParser::parse) } suspend fun getPTokenFromMultiPageViewer( gid: Long, token: String?, sha1: Boolean = false, ): List { val url = EhUrl.getGalleryMultiPageViewerUrl(gid, token!!, sha1) val referer = EhUrl.getGalleryDetailUrl(gid, token) val parser = if (sha1) GalleryMultiPageViewerParser::parseSha1 else GalleryMultiPageViewerParser::parsePToken Log.d(TAG, url) return EhRequestBuilder(url, referer).executeAndParsingWith(parser) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhFilter.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import android.util.Log import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.dao.Filter import java.util.Locale import java.util.regex.Pattern object EhFilter { private val mTitleFilterList: MutableList = ArrayList() private val mUploaderFilterList: MutableList = ArrayList() private val mTagFilterList: MutableList = ArrayList() private val mTagNamespaceFilterList: MutableList = ArrayList() private val mCommenterFilterList: MutableList = ArrayList() private val mCommentFilterList: MutableList = ArrayList() private const val MODE_TITLE = 0 const val MODE_UPLOADER = 1 const val MODE_TAG = 2 private const val MODE_TAG_NAMESPACE = 3 const val MODE_COMMENTER = 4 private const val MODE_COMMENT = 5 private val TAG = EhFilter::class.java.simpleName init { val list = EhDB.allFilter var i = 0 val n = list.size while (i < n) { val filter = list[i] when (filter.mode) { MODE_TITLE -> { filter.text = filter.text!!.lowercase(Locale.getDefault()) mTitleFilterList.add(filter) } MODE_TAG -> { filter.text = filter.text!!.lowercase(Locale.getDefault()) mTagFilterList.add(filter) } MODE_TAG_NAMESPACE -> { filter.text = filter.text!!.lowercase(Locale.getDefault()) mTagNamespaceFilterList.add(filter) } MODE_UPLOADER -> mUploaderFilterList.add(filter) MODE_COMMENTER -> mCommenterFilterList.add(filter) MODE_COMMENT -> mCommentFilterList.add(filter) else -> Log.d(TAG, "Unknown mode: " + filter.mode) } i++ } } val titleFilterList: List get() = mTitleFilterList val uploaderFilterList: List get() = mUploaderFilterList val tagFilterList: List get() = mTagFilterList val tagNamespaceFilterList: List get() = mTagNamespaceFilterList val commenterFilterList: List get() = mCommenterFilterList val commentFilterList: List get() = mCommentFilterList fun applyTranslation(filter: Filter): String? { var text = filter.text ?: return null if (EhTagDatabase.isInitialized()) { when (filter.mode) { MODE_TAG_NAMESPACE -> { EhTagDatabase.getTranslation(tag = text)?.let { text = "$text ($it)" } } MODE_TAG -> { val index = text.indexOf(':') if (index > 0) { EhTagDatabase.getTranslation( EhTagDatabase.namespaceToPrefix(text.substring(0, index)), text.substring(index + 1), )?.let { text = "$text ($it)" } } } } } return text } @Synchronized fun addFilter(filter: Filter): Boolean { // enable filter by default before it is added to database filter.enable = true if (!EhDB.addFilter(filter)) return false when (filter.mode) { MODE_TITLE -> { filter.text = filter.text!!.lowercase(Locale.getDefault()) mTitleFilterList.add(filter) } MODE_TAG -> { filter.text = filter.text!!.lowercase(Locale.getDefault()) mTagFilterList.add(filter) } MODE_TAG_NAMESPACE -> { filter.text = filter.text!!.lowercase(Locale.getDefault()) mTagNamespaceFilterList.add(filter) } MODE_UPLOADER -> mUploaderFilterList.add(filter) MODE_COMMENTER -> mCommenterFilterList.add(filter) MODE_COMMENT -> mCommentFilterList.add(filter) else -> Log.d(TAG, "Unknown mode: " + filter.mode) } return true } @Synchronized fun triggerFilter(filter: Filter) { EhDB.triggerFilter(filter) } @Synchronized fun deleteFilter(filter: Filter) { EhDB.deleteFilter(filter) when (filter.mode) { MODE_TITLE -> mTitleFilterList.remove(filter) MODE_TAG -> mTagFilterList.remove(filter) MODE_TAG_NAMESPACE -> mTagNamespaceFilterList.remove(filter) MODE_UPLOADER -> mUploaderFilterList.remove(filter) MODE_COMMENTER -> mCommenterFilterList.remove(filter) MODE_COMMENT -> mCommentFilterList.remove(filter) else -> Log.d(TAG, "Unknown mode: " + filter.mode) } } @Synchronized fun needTags(): Boolean = mTagFilterList.isNotEmpty() || mTagNamespaceFilterList.isNotEmpty() @Synchronized fun filterTitle(info: GalleryInfo?): Boolean { if (null == info) { return false } // Title val title = info.title val filters: List = mTitleFilterList if (null != title && filters.isNotEmpty()) { var i = 0 val n = filters.size while (i < n) { if (filters[i].enable!! && title.lowercase(Locale.getDefault()).contains( filters[i].text!!, ) ) { return false } i++ } } return true } @Synchronized fun filterUploader(info: GalleryInfo?): Boolean { if (null == info) { return false } // Uploader val uploader = info.uploader val filters: List = mUploaderFilterList if (null != uploader && filters.isNotEmpty()) { var i = 0 val n = filters.size while (i < n) { if (filters[i].enable!! && uploader == filters[i].text) { return false } i++ } } return true } private fun matchTag(tag: String?, filter: String?): Boolean { if (null == tag || null == filter) { return false } val tagNamespace: String? val tagName: String val filterNamespace: String? val filterName: String var index = tag.indexOf(':') if (index < 0) { tagNamespace = null tagName = tag } else { tagNamespace = tag.substring(0, index) tagName = tag.substring(index + 1) } index = filter.indexOf(':') if (index < 0) { filterNamespace = null filterName = filter } else { filterNamespace = filter.substring(0, index) filterName = filter.substring(index + 1) } return if (null != tagNamespace && null != filterNamespace && tagNamespace != filterNamespace ) { false } else { tagName == filterName } } @Synchronized fun filterTag(info: GalleryInfo?): Boolean { if (null == info) { return false } // Tag val tags = info.simpleTags val filters: List = mTagFilterList if (null != tags && filters.isNotEmpty()) { for (tag in tags) { var i = 0 val n = filters.size while (i < n) { if (filters[i].enable!! && matchTag(tag, filters[i].text)) { return false } i++ } } } return true } private fun matchTagNamespace(tag: String?, filter: String?): Boolean { if (null == tag || null == filter) { return false } val tagNamespace: String val index = tag.indexOf(':') return if (index >= 0) { tagNamespace = tag.substring(0, index) tagNamespace == filter } else { false } } @Synchronized fun filterTagNamespace(info: GalleryInfo?): Boolean { if (null == info) { return false } val tags = info.simpleTags val filters: List = mTagNamespaceFilterList if (null != tags && filters.isNotEmpty()) { for (tag in tags) { var i = 0 val n = filters.size while (i < n) { if (filters[i].enable!! && matchTagNamespace(tag, filters[i].text)) { return false } i++ } } } return true } @Synchronized fun filterCommenter(commenter: String?): Boolean { if (null == commenter) { return false } val filters: List = mCommenterFilterList if (filters.isNotEmpty()) { var i = 0 val n = filters.size while (i < n) { if (filters[i].enable!! && commenter == filters[i].text) { return false } i++ } } return true } @Synchronized fun filterComment(comment: String?): Boolean { if (null == comment) { return false } val filters: List = mCommentFilterList if (filters.isNotEmpty()) { var i = 0 val n = filters.size while (i < n) { if (filters[i].enable!!) { val p = Pattern.compile(filters[i].text!!) val m = p.matcher(comment) if (m.find()) return false } i++ } } return true } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhRequest.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import android.app.Activity import androidx.annotation.MainThread import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job class EhRequest { internal var job: Job? = null val isActive get() = job?.isActive == true var method = 0 private set var args: Array? = null private set var callback: EhClient.Callback? = null private set fun setMethod(method: Int): EhRequest { this.method = method return this } fun setArgs(vararg args: Any?): EhRequest { this.args = args return this } @Suppress("UNCHECKED_CAST") fun setCallback(callback: EhClient.Callback<*>?): EhRequest { this.callback = callback as EhClient.Callback? return this } fun enqueue(scope: CoroutineScope) { EhClient.enqueue(this, scope) } @DelicateCoroutinesApi fun enqueue() { EhClient.enqueue(this, GlobalScope) } fun enqueue(fragment: Fragment) { enqueue(fragment.viewLifecycleOwner.lifecycleScope) } fun enqueue(activity: Activity) { check(activity is FragmentActivity) enqueue(activity.lifecycleScope) } @MainThread fun cancel() { if (isActive) { job?.cancel() callback?.onCancel() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhRequestBuilder.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient import com.hippo.okhttp.ChromeRequestBuilder import okhttp3.Request import okhttp3.Response import okhttp3.coroutines.executeAsync class EhRequestBuilder( url: String, referer: String? = null, origin: String? = null, ) : ChromeRequestBuilder(url) { init { referer?.let { addHeader("Referer", it) } origin?.let { addHeader("Origin", it) } } } suspend inline fun Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhTagDatabase.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import android.content.Context import com.hippo.ehviewer.AppConfig import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.EhApplication.Companion.nonCacheOkHttpClient import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.unifile.UniFile import com.hippo.unifile.sha1 import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.copyToFile import java.io.File import java.io.IOException import java.nio.charset.StandardCharsets import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Request import okhttp3.coroutines.executeAsync import okio.BufferedSource import okio.buffer import okio.source import org.json.JSONException import org.json.JSONObject private typealias TagGroup = Map private typealias TagGroups = Map object EhTagDatabase { private const val NAMESPACE_PREFIX = "n" private const val UPDATE_INTERVAL = 3 * 24 * 3600 * 1000 const val TYPE_EQUAL = 0 const val TYPE_START = 1 const val TYPE_CONTAIN = 2 private lateinit var tagGroups: TagGroups private val dir = AppConfig.getFilesDir("tag-translations") private val urls = getMetadata(EhApplication.application) private val sha1Name = urls?.get(0)!! private val sha1Url = urls?.get(1)!! private val dataName = urls?.get(2)!! private val dataUrl = urls?.get(3)!! private val updateLock = Mutex() fun isInitialized(): Boolean = this::tagGroups.isInitialized private fun JSONObject.toTagGroups(): TagGroups = keys().asSequence().associateWith { getJSONObject(it).toTagGroup() } private fun JSONObject.toTagGroup(): TagGroup = keys().asSequence().associateWith { getString(it) } private fun updateData(source: BufferedSource) { try { tagGroups = JSONObject(source.readString(StandardCharsets.UTF_8)).toTagGroups() } catch (e: JSONException) { e.printStackTrace() } } fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.takeIf { it.isNotEmpty() } private fun internalSuggestFlow( tags: Map, keyword: String, translate: Boolean, type: Int, ): Flow> = flow { when (type) { TYPE_EQUAL -> { if (translate) { tags.forEach { (tag, hint) -> if (tag.equalsIgnoreSpace(keyword) || hint.equalsIgnoreSpace(keyword)) { emit(Pair(hint, tag)) } } } else { tags[keyword]?.let { emit(Pair(null, keyword)) } } } TYPE_START -> { if (translate) { tags.forEach { (tag, hint) -> if (!tag.equalsIgnoreSpace(keyword) && !hint.equalsIgnoreSpace(keyword) && (tag.startsWithIgnoreSpace(keyword) || hint.startsWithIgnoreSpace(keyword)) ) { emit(Pair(hint, tag)) } } } else { tags.keys.forEach { tag -> if (!tag.equalsIgnoreSpace(keyword) && tag.startsWithIgnoreSpace(keyword)) { emit(Pair(null, tag)) } } } } TYPE_CONTAIN -> { if (translate) { tags.forEach { (tag, hint) -> if (!tag.equalsIgnoreSpace(keyword) && !hint.equalsIgnoreSpace(keyword) && !tag.startsWithIgnoreSpace(keyword) && !hint.startsWithIgnoreSpace(keyword) && (tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword)) ) { emit(Pair(hint, tag)) } } } else { tags.keys.forEach { tag -> if (!tag.equalsIgnoreSpace(keyword) && !tag.startsWithIgnoreSpace(keyword) && tag.containsIgnoreSpace(keyword) ) { emit(Pair(null, tag)) } } } } } } /* Construct a cold flow for tag database suggestions */ fun suggestFlow( keyword: String, translate: Boolean, type: Int, ): Flow> = flow { val keywordPrefix = keyword.substringBefore(':') val keywordTag = keyword.drop(keywordPrefix.length + 1) val prefix = namespaceToPrefix(keywordPrefix) ?: keywordPrefix val tags = tagGroups[prefix.takeIf { keywordTag.isNotEmpty() && it != NAMESPACE_PREFIX }] tags?.let { internalSuggestFlow(it, keywordTag, translate, type).collect { (hint, tag) -> emit(Pair(hint, "$prefix:$tag")) } } ?: tagGroups.forEach { (prefix, tags) -> internalSuggestFlow(tags, keyword, translate, type).collect { (hint, tag) -> emit(Pair(hint, if (prefix == NAMESPACE_PREFIX) "$tag:" else "$prefix:$tag")) } } } private fun String.removeSpace(): String = replace(" ", "") private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().contains(other.removeSpace(), ignoreCase) private fun String.equalsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().equals(other.removeSpace(), ignoreCase) private fun String.startsWithIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().startsWith(other.removeSpace(), ignoreCase) private val NAMESPACE_TO_PREFIX = HashMap().also { it["artist"] = "a" it["character"] = "c" it["cosplayer"] = "cos" it["female"] = "f" it["group"] = "g" it["language"] = "l" it["location"] = "loc" it["male"] = "m" it["mixed"] = "x" it["other"] = "o" it["parody"] = "p" it["reclass"] = "r" } fun namespaceToPrefix(namespace: String): String? = NAMESPACE_TO_PREFIX[namespace] private fun getMetadata(context: Context): Array? = context.resources.getStringArray(R.array.tag_translation_metadata) .takeIf { it.size == 4 } fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable) private fun getFileContent(file: File): String? = runCatching { file.source().buffer().use { it.readString(StandardCharsets.UTF_8) } }.getOrNull() private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == UniFile.fromFile(data)?.sha1() private suspend fun save(url: String, file: File): Boolean { val request: Request = Request.Builder().url(url).build() val call = nonCacheOkHttpClient.newCall(request) runCatching { call.executeAsync().use { response -> if (!response.isSuccessful) { return false } response.body.use { it.copyToFile(file) } return true } }.onFailure { file.delete() it.printStackTrace() } return false } suspend fun read() { if (urls != null && !isInitialized()) { runCatching { checkNotNull(dir) val sha1File = File(dir, sha1Name) val dataFile = File(dir, dataName) // Check current sha1 and current data val sha1 = getFileContent(sha1File) if (!checkData(sha1, dataFile)) { FileUtils.delete(sha1File) FileUtils.delete(dataFile) Settings.putTranslationsLastUpdate(-1) } // Read current EhTagDatabase if (dataFile.exists()) { try { dataFile.source().buffer().use { updateData(it) } } catch (_: IOException) { FileUtils.delete(sha1File) FileUtils.delete(dataFile) Settings.putTranslationsLastUpdate(-1) } } }.onFailure { it.printStackTrace() } update() } } suspend fun update(force: Boolean = false) { val time = System.currentTimeMillis() if (urls != null && (force || time - Settings.translationsLastUpdate > UPDATE_INTERVAL)) { updateLock.withLock { runCatching { checkNotNull(dir) val sha1File = File(dir, sha1Name) val dataFile = File(dir, dataName) // Save new sha1 val tempSha1File = File(dir, "$sha1Name.tmp") check(save(sha1Url, tempSha1File)) val tempSha1 = getFileContent(tempSha1File) // Check new sha1 and current sha1 if (tempSha1 == getFileContent(sha1File)) { // The data is the same FileUtils.delete(tempSha1File) return@runCatching } // Save new data val tempDataFile = File(dir, "$dataName.tmp") check(save(dataUrl, tempDataFile)) // Check new sha1 and new data if (!checkData(tempSha1, tempDataFile)) { FileUtils.delete(tempSha1File) FileUtils.delete(tempDataFile) return@runCatching } // Replace current sha1 and current data with new sha1 and new data FileUtils.delete(sha1File) FileUtils.delete(dataFile) tempSha1File.renameTo(sha1File) tempDataFile.renameTo(dataFile) // Read new EhTagDatabase try { dataFile.source().buffer().use { updateData(it) } Settings.putTranslationsLastUpdate(time) } catch (_: IOException) { } }.onFailure { it.printStackTrace() } } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhUrl.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import com.hippo.ehviewer.Settings import com.hippo.network.UrlBuilder object EhUrl { const val SITE_E = 0 const val SITE_EX = 1 const val DOMAIN_E = "e-hentai.org" const val DOMAIN_EX = "exhentai.org" const val DOMAIN_LOFI = "lofi.e-hentai.org" const val HOST_E = "https://$DOMAIN_E/" const val HOST_EX = "https://$DOMAIN_EX/" private const val API_E = "https://api.e-hentai.org/api.php" private const val API_EX = "https://s.exhentai.org/api.php" private const val URL_FAVORITES_E = HOST_E + "favorites.php" private const val URL_FAVORITES_EX = HOST_EX + "favorites.php" private const val URL_IMAGE_SEARCH_E = "https://upload.e-hentai.org/image_lookup.php" private const val URL_IMAGE_SEARCH_EX = "https://exhentai.org/upload/image_lookup.php" private const val URL_MY_TAGS_E = HOST_E + "mytags" private const val URL_MY_TAGS_EX = HOST_EX + "mytags" private const val URL_POPULAR_E = HOST_E + "popular" private const val URL_POPULAR_EX = HOST_EX + "popular" private const val URL_WATCHED_E = HOST_E + "watched" private const val URL_WATCHED_EX = HOST_EX + "watched" const val URL_UCONFIG_E = HOST_E + "uconfig.php" const val URL_UCONFIG_EX = HOST_EX + "uconfig.php" const val URL_FORUMS = "https://forums.e-hentai.org/" const val URL_SIGN_IN = URL_FORUMS + "index.php?act=Login" const val URL_REGISTER = URL_FORUMS + "index.php?act=Reg&CODE=00" const val API_SIGN_IN = "$URL_SIGN_IN&CODE=01" const val URL_FUNDS = HOST_E + "exchange.php?t=gp" const val URL_HOME = HOST_E + "home.php" const val URL_NEWS = HOST_E + "news.php" const val REFERER_E = "https://$DOMAIN_E" private const val REFERER_EX = "https://$DOMAIN_EX" private const val ORIGIN_E = REFERER_E private const val ORIGIN_EX = REFERER_EX val host: String get() = when (Settings.gallerySite) { SITE_E -> HOST_E SITE_EX -> HOST_EX else -> HOST_E } val favoritesUrl: String get() = when (Settings.gallerySite) { SITE_E -> URL_FAVORITES_E SITE_EX -> URL_FAVORITES_EX else -> URL_FAVORITES_E } val apiUrl: String get() = when (Settings.gallerySite) { SITE_E -> API_E SITE_EX -> API_EX else -> API_E } val referer: String get() = when (Settings.gallerySite) { SITE_E -> REFERER_E SITE_EX -> REFERER_EX else -> REFERER_E } val origin: String get() = when (Settings.gallerySite) { SITE_E -> ORIGIN_E SITE_EX -> ORIGIN_EX else -> ORIGIN_E } val uConfigUrl: String get() = when (Settings.gallerySite) { SITE_E -> URL_UCONFIG_E SITE_EX -> URL_UCONFIG_EX else -> URL_UCONFIG_E } val myTagsUrl: String get() = when (Settings.gallerySite) { SITE_E -> URL_MY_TAGS_E SITE_EX -> URL_MY_TAGS_EX else -> URL_MY_TAGS_E } val popularUrl: String get() = when (Settings.gallerySite) { SITE_E -> URL_POPULAR_E SITE_EX -> URL_POPULAR_EX else -> URL_POPULAR_E } val imageSearchUrl: String get() = when (Settings.gallerySite) { SITE_E -> URL_IMAGE_SEARCH_E SITE_EX -> URL_IMAGE_SEARCH_EX else -> URL_IMAGE_SEARCH_E } val watchedUrl: String get() = when (Settings.gallerySite) { SITE_E -> URL_WATCHED_E SITE_EX -> URL_WATCHED_EX else -> URL_WATCHED_E } fun getGalleryDetailUrl( gid: Long, token: String?, index: Int = 0, allComment: Boolean = false, sha1: Boolean = false, ): String { val builder = UrlBuilder(host + "g/" + gid + '/' + token + '/') if (index > 0) builder.addQuery("p", index) if (allComment) builder.addQuery("hc", 1) if (sha1) builder.addQuery("datatags", 1) return builder.build() } fun getGalleryMultiPageViewerUrl(gid: Long, token: String, sha1: Boolean = false): String { val builder = UrlBuilder(host + "mpv/" + gid + '/' + token + '/') if (sha1) builder.addQuery("datatags", 1) return builder.build() } fun getPageUrl(gid: Long, index: Int, pToken: String): String = host + "s/" + pToken + '/' + gid + '-' + (index + 1) fun getAddFavorites(gid: Long, token: String?): String = host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav" fun getDownloadArchive(gid: Long, token: String?): String = host + "archiver.php?gid=" + gid + "&token=" + token fun getTagDefinitionUrl(tag: String): String = "https://ehwiki.org/wiki/" + tag.replace(' ', '_') } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhUrlOpener.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import android.os.Bundle import android.text.TextUtils import android.util.Log import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser import com.hippo.ehviewer.client.parser.GalleryListUrlParser import com.hippo.ehviewer.client.parser.GalleryPageUrlParser import com.hippo.ehviewer.ui.scene.GalleryDetailScene import com.hippo.ehviewer.ui.scene.GalleryListScene import com.hippo.ehviewer.ui.scene.ProgressScene import com.hippo.scene.Announcer object EhUrlOpener { private val TAG = EhUrlOpener::class.java.simpleName fun parseUrl(url: String): Announcer? { if (TextUtils.isEmpty(url)) { return null } val listUrlBuilder = GalleryListUrlParser.parse(url) if (listUrlBuilder != null) { val args = Bundle() args.putString(GalleryListScene.KEY_ACTION, GalleryListScene.ACTION_LIST_URL_BUILDER) args.putParcelable(GalleryListScene.KEY_LIST_URL_BUILDER, listUrlBuilder) return Announcer(GalleryListScene::class.java).setArgs(args) } val result1 = GalleryDetailUrlParser.parse(url) if (result1 != null) { val args = Bundle() args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GID_TOKEN) args.putLong(GalleryDetailScene.KEY_GID, result1.gid) args.putString(GalleryDetailScene.KEY_TOKEN, result1.token) return Announcer(GalleryDetailScene::class.java).setArgs(args) } val result2 = GalleryPageUrlParser.parse(url) if (result2 != null) { val args = Bundle() args.putString(ProgressScene.KEY_ACTION, ProgressScene.ACTION_GALLERY_TOKEN) args.putLong(ProgressScene.KEY_GID, result2.gid) args.putString(ProgressScene.KEY_PTOKEN, result2.pToken) args.putInt(ProgressScene.KEY_PAGE, result2.page) return Announcer(ProgressScene::class.java).setArgs(args) } Log.i(TAG, "Can't parse url: $url") return null } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/EhUtils.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client import android.text.TextUtils import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUrl.host import com.hippo.ehviewer.client.data.GalleryInfo import java.util.regex.Pattern import okhttp3.HttpUrl.Companion.toHttpUrl object EhUtils { const val NONE = -1 // Use it for homepage const val MISC = 0x1 const val DOUJINSHI = 0x2 const val MANGA = 0x4 const val ARTIST_CG = 0x8 const val GAME_CG = 0x10 const val IMAGE_SET = 0x20 const val COSPLAY = 0x40 const val ASIAN_PORN = 0x80 const val NON_H = 0x100 const val WESTERN = 0x200 const val ALL_CATEGORY = 0x3ff const val PRIVATE = 0x400 const val UNKNOWN = 0x800 // https://youtrack.jetbrains.com/issue/KT-4749 private const val BG_COLOR_DOUJINSHI = 0xfff44336u private const val BG_COLOR_MANGA = 0xffff9800u private const val BG_COLOR_ARTIST_CG = 0xfffbc02du private const val BG_COLOR_GAME_CG = 0xff4caf50u private const val BG_COLOR_WESTERN = 0xff8bc34au private const val BG_COLOR_NON_H = 0xff2196f3u private const val BG_COLOR_IMAGE_SET = 0xff3f51b5u private const val BG_COLOR_COSPLAY = 0xff9c27b0u private const val BG_COLOR_ASIAN_PORN = 0xff9575cdu private const val BG_COLOR_MISC = 0xfff06292u private const val BG_COLOR_UNKNOWN = 0xff000000u // Remove [XXX], (XXX), {XXX}, ~XXX~ stuff private val PATTERN_TITLE_PREFIX = Pattern.compile( "^(?:\\([^)]*\\)|\\[[^]]*]|\\{[^}]*\\}|~[^~]*~|\\s+)*", ) // Remove [XXX], (XXX), {XXX}, ~XXX~ stuff and something like ch. 1-23 private val PATTERN_TITLE_SUFFIX = Pattern.compile( "(?:\\s+ch.[\\s\\d-]+)?(?:\\([^)]*\\)|\\[[^]]*]|\\{[^}]*\\}|~[^~]*~|\\s+)*$", Pattern.CASE_INSENSITIVE, ) private val CATEGORY_VALUES = hashMapOf( MISC to arrayOf("misc"), DOUJINSHI to arrayOf("doujinshi"), MANGA to arrayOf("manga"), ARTIST_CG to arrayOf("artistcg", "Artist CG Sets", "Artist CG"), GAME_CG to arrayOf("gamecg", "Game CG Sets", "Game CG"), IMAGE_SET to arrayOf("imageset", "Image Sets", "Image Set"), COSPLAY to arrayOf("cosplay"), ASIAN_PORN to arrayOf("asianporn", "Asian Porn"), NON_H to arrayOf("non-h"), WESTERN to arrayOf("western"), PRIVATE to arrayOf("private"), UNKNOWN to arrayOf("unknown"), ) private val CATEGORY_STRINGS = CATEGORY_VALUES.entries.map { (k, v) -> v to k } val isExHentai: Boolean get() = Settings.gallerySite == EhUrl.SITE_EX val isMPVAvailable: Boolean get() = EhCookieStore.getCookieValue( host.toHttpUrl(), EhCookieStore.KEY_HATH_PERKS, )?.substringBefore("-")?.contains("q") == true fun getCategory(type: String?): Int { for (entry in CATEGORY_STRINGS) { for (str in entry.first) { if (str.equals(type, ignoreCase = true)) { return entry.second } } } return UNKNOWN } fun getCategory(type: Int): String = CATEGORY_VALUES[type]?.let { it[0] } ?: CATEGORY_VALUES[UNKNOWN]!![0] fun getCategoryColor(category: Int): Int = when (category) { DOUJINSHI -> BG_COLOR_DOUJINSHI MANGA -> BG_COLOR_MANGA ARTIST_CG -> BG_COLOR_ARTIST_CG GAME_CG -> BG_COLOR_GAME_CG WESTERN -> BG_COLOR_WESTERN NON_H -> BG_COLOR_NON_H IMAGE_SET -> BG_COLOR_IMAGE_SET COSPLAY -> BG_COLOR_COSPLAY ASIAN_PORN -> BG_COLOR_ASIAN_PORN MISC -> BG_COLOR_MISC else -> BG_COLOR_UNKNOWN }.toInt() suspend fun signOut() { EhCookieStore.clear() Settings.putAvatar(null) Settings.putDisplayName(null) Settings.putGallerySite(EhUrl.SITE_E) Settings.putNeedSignIn(true) Settings.putSelectSite(true) } fun needSignedIn(): Boolean = Settings.needSignIn fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) { if (TextUtils.isEmpty(gi.titleJpn)) gi.title else gi.titleJpn } else { if (TextUtils.isEmpty(gi.title)) gi.titleJpn else gi.title }.orEmpty() fun extractTitle(fullTitle: String?): String? { var title: String = fullTitle ?: return null title = PATTERN_TITLE_PREFIX.matcher(title).replaceFirst("") title = PATTERN_TITLE_SUFFIX.matcher(title).replaceFirst("") // Sometimes title is combined by romaji and english translation. // Only need romaji. // TODO But not sure every '|' means that val index = title.indexOf('|') if (index >= 0) { title = title.substring(0, index) } return title.ifEmpty { null } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/AbstractGalleryInfo.kt ================================================ /* * Copyright 2023 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.client.data interface AbstractGalleryInfo { var gid: Long var token: String? var title: String? var titleJpn: String? var thumb: String? var category: Int var posted: String? var uploader: String? var disowned: Boolean var rating: Float var rated: Boolean var simpleTags: Array? var pages: Int var thumbWidth: Int var thumbHeight: Int var spanSize: Int var spanIndex: Int var spanGroupIndex: Int var simpleLanguage: String? var favoriteSlot: Int var favoriteName: String? var favoriteNote: String? } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/BaseGalleryInfo.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import androidx.room.ColumnInfo import androidx.room.Ignore import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize @Parcelize open class BaseGalleryInfo( @PrimaryKey @ColumnInfo(name = "GID") override var gid: Long = 0, @ColumnInfo(name = "TOKEN") override var token: String? = null, @ColumnInfo(name = "TITLE") override var title: String? = null, @ColumnInfo(name = "TITLE_JPN") override var titleJpn: String? = null, @ColumnInfo(name = "THUMB") override var thumb: String? = null, @ColumnInfo(name = "CATEGORY") override var category: Int = 0, @ColumnInfo(name = "POSTED") override var posted: String? = null, @ColumnInfo(name = "UPLOADER") override var uploader: String? = null, @Ignore override var disowned: Boolean = false, @ColumnInfo(name = "RATING") override var rating: Float = 0f, @Ignore override var rated: Boolean = false, @Ignore override var simpleTags: Array? = null, @Ignore override var pages: Int = 0, @Ignore override var thumbWidth: Int = 0, @Ignore override var thumbHeight: Int = 0, @Ignore override var spanSize: Int = 0, @Ignore override var spanIndex: Int = 0, @Ignore override var spanGroupIndex: Int = 0, @ColumnInfo(name = "SIMPLE_LANGUAGE") override var simpleLanguage: String? = null, @Ignore override var favoriteSlot: Int = -2, @Ignore override var favoriteName: String? = null, @Ignore override var favoriteNote: String? = null, ) : GalleryInfo ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import android.os.Parcelable import com.hippo.ehviewer.client.EhUrl import com.hippo.network.UrlBuilder import com.hippo.util.encodeUTF8 import kotlinx.parcelize.Parcelize @Parcelize class FavListUrlBuilder( private var mPrev: String? = null, private var mNext: String? = null, var jumpTo: String? = null, var keyword: String? = null, var favCat: Int = FAV_CAT_ALL, ) : Parcelable { fun setIndex(index: String?, isNext: Boolean) { mNext = index.takeIf { isNext } mPrev = index.takeUnless { isNext } } fun build(): String { val ub = UrlBuilder(EhUrl.favoritesUrl) if (isValidFavCat(favCat)) { ub.addQuery("favcat", favCat.toString()) } else if (favCat == FAV_CAT_ALL) { ub.addQuery("favcat", "all") } keyword?.takeIf { it.isNotBlank() }?.let { ub.addQuery("f_search", encodeUTF8(it)) } mPrev?.takeIf { it.isNotEmpty() }?.let { ub.addQuery("prev", it) } mNext?.takeIf { it.isNotEmpty() }?.let { ub.addQuery("next", it) } jumpTo?.takeIf { it.isNotEmpty() }?.let { ub.addQuery("seek", it) } return ub.build() } companion object { const val FAV_CAT_ALL = -1 const val FAV_CAT_LOCAL = -2 fun isValidFavCat(favCat: Int): Boolean = favCat in 0..9 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/GalleryComment.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize class GalleryComment( // 0 for uploader comment. can't vote var id: Long = 0, var score: Int = 0, var editable: Boolean = false, var voteUpAble: Boolean = false, var voteUpEd: Boolean = false, var voteDownAble: Boolean = false, var voteDownEd: Boolean = false, var uploader: Boolean = false, var voteState: String? = null, var time: Long = 0, var user: String? = null, var comment: String? = null, var lastEdited: Long = 0, ) : Parcelable ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/GalleryCommentList.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize class GalleryCommentList( var comments: Array?, var hasMore: Boolean, ) : Parcelable ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/GalleryDetail.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANGS import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize class GalleryDetail( val galleryInfo: GalleryInfo = BaseGalleryInfo(), var apiUid: Long = -1L, var apiKey: String? = null, var torrentCount: Int = 0, var torrentUrl: String? = null, var archiveUrl: String? = null, var parent: String? = null, var newerVersions: ArrayList = arrayListOf(), var visible: String? = null, var language: String? = null, var size: String? = null, var favoriteCount: Int = 0, var isFavorited: Boolean = false, var ratingCount: Int = 0, var tags: Array? = null, var comments: GalleryCommentList? = null, var previewPages: Int = 0, @IgnoredOnParcel var previewSet: PreviewSet? = null, ) : AbstractGalleryInfo by galleryInfo, GalleryInfo { override fun generateSLang() { val index = LANGUAGES.indexOf(language) if (index != -1) { simpleLanguage = S_LANGS[index] } } companion object { private val LANGUAGES = arrayOf( "English", "Chinese", "Spanish", "Korean", "Russian", "French", "Portuguese", "Thai", "German", "Italian", "Vietnamese", "Polish", "Hungarian", "Dutch", ) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/GalleryInfo.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import android.os.Parcelable import java.util.regex.Pattern interface GalleryInfo : AbstractGalleryInfo, Parcelable { fun generateSLang() { simpleLanguage = simpleTags?.let { generateSLangFromTags(it) } ?: title?.let { generateSLangFromTitle(it) } } private fun generateSLangFromTags(simpleTags: Array): String? { for (tag in simpleTags) { for (i in S_LANGS.indices) { if (S_LANG_TAGS[i] == tag) { return S_LANGS[i] } } } return null } private fun generateSLangFromTitle(title: String): String? { for (i in S_LANGS.indices) { if (S_LANG_PATTERNS[i].matcher(title).find()) { return S_LANGS[i] } } return null } companion object { /** * ISO 639-1 */ private const val S_LANG_JA = "JA" private const val S_LANG_EN = "EN" private const val S_LANG_ZH = "ZH" private const val S_LANG_NL = "NL" private const val S_LANG_FR = "FR" private const val S_LANG_DE = "DE" private const val S_LANG_HU = "HU" private const val S_LANG_IT = "IT" private const val S_LANG_KO = "KO" private const val S_LANG_PL = "PL" private const val S_LANG_PT = "PT" private const val S_LANG_RU = "RU" private const val S_LANG_ES = "ES" private const val S_LANG_TH = "TH" private const val S_LANG_VI = "VI" val S_LANGS = arrayOf( S_LANG_EN, S_LANG_ZH, S_LANG_ES, S_LANG_KO, S_LANG_RU, S_LANG_FR, S_LANG_PT, S_LANG_TH, S_LANG_DE, S_LANG_IT, S_LANG_VI, S_LANG_PL, S_LANG_HU, S_LANG_NL, ) val S_LANG_PATTERNS = arrayOf( Pattern.compile( "[(\\[]eng(?:lish)?[)\\]]|英訳", Pattern.CASE_INSENSITIVE, ), // [((\[]ch(?:inese)?[))\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳 Pattern.compile( "[(\uFF08\\[]ch(?:inese)?[)\uFF09\\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳", Pattern.CASE_INSENSITIVE, ), Pattern.compile( "[(\\[]spanish[)\\]]|[(\\[]Español[)\\]]|スペイン翻訳", Pattern.CASE_INSENSITIVE, ), Pattern.compile("[(\\[]korean?[)\\]]|韓国翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile("[(\\[]rus(?:sian)?[)\\]]|ロシア翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile("[(\\[]fr(?:ench)?[)\\]]|フランス翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile("[(\\[]portuguese|ポルトガル翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile( "[(\\[]thai(?: ภาษาไทย)?[)\\]]|แปลไทย|タイ翻訳", Pattern.CASE_INSENSITIVE, ), Pattern.compile("[(\\[]german[)\\]]|ドイツ翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile("[(\\[]italiano?[)\\]]|イタリア翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile( "[(\\[]vietnamese(?: Tiếng Việt)?[)\\]]|ベトナム翻訳", Pattern.CASE_INSENSITIVE, ), Pattern.compile("[(\\[]polish[)\\]]|ポーランド翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE), Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE), ) val S_LANG_TAGS = arrayOf( "language:english", "language:chinese", "language:spanish", "language:korean", "language:russian", "language:french", "language:portuguese", "language:thai", "language:german", "language:italian", "language:vietnamese", "language:polish", "language:hungarian", "language:dutch", ) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/GalleryPreview.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import android.os.Parcelable import com.hippo.util.isAtLeastQ import com.hippo.widget.LoadImageView import kotlinx.parcelize.Parcelize @Parcelize class GalleryPreview( var imageKey: String? = null, var imageUrl: String? = null, var pageUrl: String? = null, var position: Int = 0, var offsetX: Int = Int.MIN_VALUE, var offsetY: Int = Int.MIN_VALUE, var clipWidth: Int = Int.MIN_VALUE, var clipHeight: Int = Int.MIN_VALUE, ) : Parcelable { fun load(view: LoadImageView) { view.setClip(offsetX, offsetY, clipWidth, clipHeight) view.load(imageKey!!, imageUrl!!, offsetY == Int.MIN_VALUE || isAtLeastQ) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/GalleryTagGroup.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import android.os.Parcelable import kotlinx.parcelize.Parcelize @Suppress("JavaDefaultMethodsNotOverriddenByDelegation") @Parcelize class GalleryTagGroup( private val mTagList: ArrayList = arrayListOf(), var groupName: String? = null, ) : Parcelable, MutableList by mTagList ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/LargePreviewSet.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import com.hippo.ehviewer.client.getLargePreviewKey import com.hippo.ehviewer.client.thumbUrl import com.hippo.widget.LoadImageView import com.hippo.yorozuya.collect.IntList class LargePreviewSet( private val mPositionList: IntList = IntList(), private val mImageUrlList: ArrayList = arrayListOf(), private val mPageUrlList: ArrayList = arrayListOf(), private val mSha1List: ArrayList = arrayListOf(), ) : PreviewSet() { fun addItem(index: Int, imageUrl: String, pageUrl: String, sha1: String) { mPositionList.add(index) mImageUrlList.add(imageUrl) mPageUrlList.add(pageUrl) mSha1List.add(sha1) } override fun size(): Int = mImageUrlList.size override fun getPosition(index: Int): Int = mPositionList[index] override fun getPageUrlAt(index: Int): String = mPageUrlList[index] override fun getSha1At(index: Int): String = mSha1List[index] override fun getGalleryPreview(gid: Long, index: Int): GalleryPreview { val galleryPreview = GalleryPreview() galleryPreview.position = mPositionList[index] galleryPreview.imageKey = getLargePreviewKey(gid, galleryPreview.position) galleryPreview.imageUrl = mImageUrlList[index] galleryPreview.pageUrl = mPageUrlList[index] return galleryPreview } override fun load(view: LoadImageView, gid: Long, index: Int) { view.resetClip() view.load( getLargePreviewKey(gid, mPositionList[index]), mImageUrlList[index].thumbUrl, ) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/ListUrlBuilder.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import android.os.Parcelable import android.text.TextUtils import androidx.annotation.IntDef import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.dao.QuickSearch import com.hippo.ehviewer.widget.AdvanceSearchTable import com.hippo.network.UrlBuilder import com.hippo.util.encodeUTF8 import com.hippo.yorozuya.NumberUtils import com.hippo.yorozuya.StringUtils import java.io.UnsupportedEncodingException import java.net.URLDecoder import kotlinx.parcelize.Parcelize @Parcelize data class ListUrlBuilder( @get:Mode @param:Mode var mode: Int = MODE_NORMAL, private var mPrev: String? = null, private var mNext: String? = null, private var mJumpTo: String? = null, var category: Int = EhUtils.NONE, private var mKeyword: String? = null, var hash: String? = null, var advanceSearch: Int = -1, var minRating: Int = -1, var pageFrom: Int = -1, var pageTo: Int = -1, var isShowExpunged: Boolean = false, ) : Parcelable { fun reset() { mode = MODE_NORMAL mPrev = null mNext = null mJumpTo = null this.category = EhUtils.NONE mKeyword = null advanceSearch = -1 minRating = -1 pageFrom = -1 pageTo = -1 isShowExpunged = false hash = null } fun setIndex(index: String?, isNext: Boolean = true) { mNext = index?.takeIf { isNext } mPrev = index?.takeUnless { isNext } } fun setJumpTo(jumpTo: String?) { mJumpTo = jumpTo } var keyword: String? get() = if (MODE_UPLOADER == mode) "uploader:$mKeyword" else mKeyword set(keyword) { mKeyword = keyword } fun set(q: QuickSearch) { mode = q.mode this.category = q.category mKeyword = q.keyword advanceSearch = q.advanceSearch minRating = q.minRating pageFrom = q.pageFrom pageTo = q.pageTo isShowExpunged = false } fun toQuickSearch(): QuickSearch { val q = QuickSearch() q.mode = mode q.category = this.category q.keyword = mKeyword q.advanceSearch = advanceSearch q.minRating = minRating q.pageFrom = pageFrom q.pageTo = pageTo return q } fun equalsQuickSearch(q: QuickSearch?): Boolean { if (null == q) { return false } if (q.mode != mode) { return false } if (q.category != this.category) { return false } if (!StringUtils.equals(q.keyword, mKeyword)) { return false } if (q.advanceSearch != advanceSearch) { return false } if (q.minRating != minRating) { return false } return if (q.pageFrom != pageFrom) { false } else { q.pageTo == pageTo } } /** * @param query xxx=yyy&mmm=nnn */ // TODO page fun setQuery(query: String?) { reset() if (TextUtils.isEmpty(query)) { return } val queries = StringUtils.split(query, '&') var category = 0 var keyword: String? = null var enableAdvanceSearch = false var advanceSearch = 0 var enableMinRating = false var minRating = -1 var enablePage = false var pageFrom = -1 var pageTo = -1 for (str in queries) { val index = str.indexOf('=') if (index < 0) { continue } val key = str.substring(0, index) val value = str.substring(index + 1) when (key) { "f_cats" -> { val cats = NumberUtils.parseIntSafely(value, EhUtils.ALL_CATEGORY) category = category or (cats.inv() and EhUtils.ALL_CATEGORY) } "f_doujinshi" -> if ("1" == value) { category = category or EhUtils.DOUJINSHI } "f_manga" -> if ("1" == value) { category = category or EhUtils.MANGA } "f_artistcg" -> if ("1" == value) { category = category or EhUtils.ARTIST_CG } "f_gamecg" -> if ("1" == value) { category = category or EhUtils.GAME_CG } "f_western" -> if ("1" == value) { category = category or EhUtils.WESTERN } "f_non-h" -> if ("1" == value) { category = category or EhUtils.NON_H } "f_imageset" -> if ("1" == value) { category = category or EhUtils.IMAGE_SET } "f_cosplay" -> if ("1" == value) { category = category or EhUtils.COSPLAY } "f_asianporn" -> if ("1" == value) { category = category or EhUtils.ASIAN_PORN } "f_misc" -> if ("1" == value) { category = category or EhUtils.MISC } "f_search" -> try { keyword = URLDecoder.decode(value, "utf-8") } catch (_: UnsupportedEncodingException) { // Ignore } catch (_: IllegalArgumentException) { } "advsearch" -> if ("1" == value) { enableAdvanceSearch = true } "f_sh" -> if ("on" == value) { advanceSearch = advanceSearch or AdvanceSearchTable.SH } "f_sto" -> if ("on" == value) { advanceSearch = advanceSearch or AdvanceSearchTable.STO } "f_sfl" -> if ("on" == value) { advanceSearch = advanceSearch or AdvanceSearchTable.SFL } "f_sfu" -> if ("on" == value) { advanceSearch = advanceSearch or AdvanceSearchTable.SFU } "f_sft" -> if ("on" == value) { advanceSearch = advanceSearch or AdvanceSearchTable.SFT } "f_sr" -> if ("on" == value) { enableMinRating = true } "f_srdd" -> minRating = NumberUtils.parseIntSafely(value, -1) "f_sp" -> if ("on" == value) { enablePage = true } "f_spf" -> pageFrom = NumberUtils.parseIntSafely(value, -1) "f_spt" -> pageTo = NumberUtils.parseIntSafely(value, -1) "f_shash" -> hash = value } } this.category = category mKeyword = keyword if (enableAdvanceSearch) { this.advanceSearch = advanceSearch if (enableMinRating) { this.minRating = minRating } else { this.minRating = -1 } if (enablePage) { this.pageFrom = pageFrom this.pageTo = pageTo } else { this.pageFrom = -1 this.pageTo = -1 } } else { this.advanceSearch = -1 } } fun build(): String = when (mode) { MODE_NORMAL, MODE_SUBSCRIPTION -> { val url: String = if (mode == MODE_NORMAL) { EhUrl.host } else { EhUrl.watchedUrl } val ub = UrlBuilder(url) if (this.category != EhUtils.NONE) { ub.addQuery("f_cats", category.inv() and EhUtils.ALL_CATEGORY) } // Search key mKeyword?.run { val keyword = trim { it <= ' ' } if (keyword.isNotEmpty()) { ub.addQuery("f_search", encodeUTF8(this)) } } hash?.let { ub.addQuery("f_shash", it) } mJumpTo?.let { ub.addQuery("seek", it) } mPrev?.let { ub.addQuery("prev", it) } mNext?.let { ub.addQuery("next", it) } // Advance search if (advanceSearch != -1) { ub.addQuery("advsearch", "1") if (advanceSearch and AdvanceSearchTable.SH != 0) ub.addQuery("f_sh", "on") if (advanceSearch and AdvanceSearchTable.STO != 0) ub.addQuery("f_sto", "on") if (advanceSearch and AdvanceSearchTable.SFL != 0) ub.addQuery("f_sfl", "on") if (advanceSearch and AdvanceSearchTable.SFU != 0) ub.addQuery("f_sfu", "on") if (advanceSearch and AdvanceSearchTable.SFT != 0) ub.addQuery("f_sft", "on") // Set min star if (minRating != -1) { ub.addQuery("f_sr", "on") ub.addQuery("f_srdd", minRating) } // Pages if (pageFrom != -1 || pageTo != -1) { ub.addQuery("f_sp", "on") ub.addQuery("f_spf", if (pageFrom != -1) pageFrom.toString() else "") ub.addQuery("f_spt", if (pageTo != -1) pageTo.toString() else "") } } ub.build() } MODE_UPLOADER -> { val sb = StringBuilder(EhUrl.host) mKeyword?.let { sb.append("uploader/") sb.append(encodeUTF8(it)) } mPrev?.let { sb.append("?prev=").append(it) } mNext?.let { sb.append("?next=").append(it) } mJumpTo?.let { sb.append("&seek=").append(it) } sb.toString() } MODE_TAG -> { val sb = StringBuilder(EhUrl.host) mKeyword?.let { sb.append("tag/") sb.append(encodeUTF8(it)) } mPrev?.let { sb.append("?prev=").append(it) } mNext?.let { sb.append("?next=").append(it) } mJumpTo?.let { sb.append("&seek=").append(it) } sb.toString() } MODE_WHATS_HOT -> EhUrl.popularUrl MODE_IMAGE_SEARCH -> { val ub = UrlBuilder(EhUrl.host) hash?.let { ub.addQuery("f_shash", it) } ub.build() } MODE_TOPLIST -> { val sb = StringBuilder(EhUrl.HOST_E) sb.append("toplist.php?tl=") mKeyword.orEmpty().let { sb.append(encodeUTF8(it)) } mJumpTo?.let { sb.append("&p=").append(it) } sb.toString() } else -> throw IllegalStateException("Unexpected value: $mode") } @IntDef( MODE_NORMAL, MODE_UPLOADER, MODE_TAG, MODE_WHATS_HOT, MODE_IMAGE_SEARCH, MODE_SUBSCRIPTION, MODE_TOPLIST, ) @Retention(AnnotationRetention.SOURCE) private annotation class Mode companion object { const val MODE_NORMAL = 0x0 const val MODE_UPLOADER = 0x1 const val MODE_TAG = 0x2 const val MODE_WHATS_HOT = 0x3 const val MODE_IMAGE_SEARCH = 0x4 const val MODE_SUBSCRIPTION = 0x5 const val MODE_TOPLIST = 0x6 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/NormalPreviewSet.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import com.hippo.ehviewer.client.getNormalPreviewKey import com.hippo.util.isAtLeastQ import com.hippo.widget.LoadImageView import com.hippo.yorozuya.collect.IntList class NormalPreviewSet( private var mPositionList: IntList = IntList(), private var mImageKeyList: ArrayList = arrayListOf(), private var mImageUrlList: ArrayList = arrayListOf(), private var mOffsetXList: IntList = IntList(), private var mOffsetYList: IntList = IntList(), private var mClipWidthList: IntList = IntList(), private var mClipHeightList: IntList = IntList(), private var mPageUrlList: ArrayList = arrayListOf(), private val mSha1List: ArrayList = arrayListOf(), ) : PreviewSet() { fun addItem( position: Int, imageUrl: String, xOffset: Int, yOffset: Int, width: Int, height: Int, pageUrl: String, sha1: String, ) { mPositionList.add(position) mImageKeyList.add(getNormalPreviewKey(imageUrl)) mImageUrlList.add(imageUrl) mOffsetXList.add(xOffset) mOffsetYList.add(yOffset) mClipWidthList.add(width) mClipHeightList.add(height) mPageUrlList.add(pageUrl) mSha1List.add(sha1) } override fun size(): Int = mPositionList.size override fun getPosition(index: Int): Int = mPositionList[index] override fun getPageUrlAt(index: Int): String = mPageUrlList[index] override fun getSha1At(index: Int): String = mSha1List[index] override fun getGalleryPreview(gid: Long, index: Int): GalleryPreview { val galleryPreview = GalleryPreview() galleryPreview.position = mPositionList[index] galleryPreview.imageKey = mImageKeyList[index] galleryPreview.imageUrl = mImageUrlList[index] galleryPreview.pageUrl = mPageUrlList[index] galleryPreview.offsetX = mOffsetXList[index] galleryPreview.offsetY = mOffsetYList[index] galleryPreview.clipWidth = mClipWidthList[index] galleryPreview.clipHeight = mClipHeightList[index] return galleryPreview } override fun load(view: LoadImageView, gid: Long, index: Int) { view.setClip( mOffsetXList[index], mOffsetYList[index], mClipWidthList[index], mClipHeightList[index], ) view.load(mImageKeyList[index], mImageUrlList[index], isAtLeastQ) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/data/PreviewSet.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.data import com.hippo.widget.LoadImageView abstract class PreviewSet { abstract fun size(): Int abstract fun getPosition(index: Int): Int abstract fun getPageUrlAt(index: Int): String abstract fun getSha1At(index: Int): String abstract fun getGalleryPreview(gid: Long, index: Int): GalleryPreview abstract fun load(view: LoadImageView, gid: Long, index: Int) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/CloudflareBypassException.kt ================================================ package com.hippo.ehviewer.client.exception import com.hippo.ehviewer.R class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/EhException.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.exception import androidx.annotation.StringRes import com.hippo.ehviewer.GetText open class EhException : Exception { constructor(detailMessage: String?) : super(detailMessage) constructor(detailMessage: String?, cause: Throwable?) : super(detailMessage, cause) constructor(@StringRes message: Int) : super(GetText.getString(message)) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/InsufficientFundsException.kt ================================================ package com.hippo.ehviewer.client.exception import com.hippo.ehviewer.R class InsufficientFundsException : EhException(R.string.insufficient_funds) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/NoHAtHClientException.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.exception import com.hippo.ehviewer.R class NoHAtHClientException : EhException(R.string.download_archive_failure_no_hath) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/NotLoggedInException.kt ================================================ /* * Copyright 2023 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.client.exception import com.hippo.ehviewer.R class NotLoggedInException : EhException(R.string.need_sign_in) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/OffensiveException.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.exception /** * It is an exception for get offensive tip for g.e-hentai.org */ class OffensiveException : EhException("OFFENSIVE") ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/ParseException.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.exception class ParseException : EhException { constructor(detailMessage: String?) : super(detailMessage) constructor(detailMessage: String?, cause: Throwable?) : super(detailMessage, cause) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/PiningException.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.exception class PiningException : EhException("pining for the fjords") ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/exception/QuotaExceededException.kt ================================================ /* * Copyright 2023 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.client.exception import com.hippo.ehviewer.R class QuotaExceededException : EhException(R.string.error_509) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/ArchiveParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.InsufficientFundsException import com.hippo.ehviewer.client.exception.NoHAtHClientException import org.jsoup.Jsoup object ArchiveParser { private val PATTERN_CURRENT_FUNDS = Regex("

([\\d,]+) GP \\[[^]]*]   ([\\d,]+) Credits \\[[^]]*]

") private val PATTERN_HATH_ARCHIVE = Regex("

([^<]+)

\\s*

([\\w. ]+)

\\s*

([\\w. ]+)

") private const val ERROR_NEED_HATH_CLIENT = "You must have a H@H client assigned to your account to use this feature." private const val ERROR_INSUFFICIENT_FUNDS = "You do not have enough funds to download this archive." fun parse(body: String): Result { val archiveList = ArrayList() Jsoup.parse(body).select("#db>div>div").forEach { element -> if (element.childrenSize() > 0 && !element.attr("style").contains("color:#CCCCCC")) { runCatching { val res = element.selectFirst("form>input")!!.attr("value") val name = element.selectFirst("form>div>input")!!.attr("value") val size = element.selectFirst("p>strong")!!.text() val cost = element.selectFirst("div>strong")!!.text().replace(",", "") Archive(res, name, size, cost, false) }.onSuccess { archiveList.add(it) }.onFailure { it.printStackTrace() } } } PATTERN_HATH_ARCHIVE.findAll(body).forEach { matchResult -> val (res, name, size, cost) = matchResult.groupValues.slice(1..4) .map { ParserUtils.trim(it) } val item = Archive(res, name, size, cost, true) archiveList.add(item) } val result = Result(archiveList, null) PATTERN_CURRENT_FUNDS.find(body)?.groupValues?.run { val fundsGP = ParserUtils.parseInt(get(1), 0) val fundsC = ParserUtils.parseInt(get(2), 0) val funds = HomeParser.Funds(fundsGP, fundsC) result.funds = funds } return result } fun parseArchiveUrl(body: String): String? { if (body.contains(ERROR_NEED_HATH_CLIENT)) { throw NoHAtHClientException() } else if (body.contains(ERROR_INSUFFICIENT_FUNDS)) { throw InsufficientFundsException() } return Jsoup.parse(body).selectFirst("#continue>a[href]") ?.let { it.attr("href") + "?start=1" } // TODO: Check more errors } class Archive( val res: String, val name: String, val size: String, val cost: String, val isHAtH: Boolean, ) class Result(val archiveList: List, var funds: HomeParser.Funds?) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/EventPaneParser.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.client.parser import org.jsoup.Jsoup object EventPaneParser { fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html() } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/FavoritesParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.NotLoggedInException import com.hippo.ehviewer.client.exception.ParseException import com.hippo.util.ExceptionUtils import com.hippo.util.JsoupUtils import org.jsoup.Jsoup object FavoritesParser { fun parse(body: String): Result { if (body.contains("This page requires you to log on.

")) { throw NotLoggedInException() } val catArray = arrayOfNulls(10) val countArray = IntArray(10) val d = Jsoup.parse(body) runCatching { val ido = JsoupUtils.getElementByClass(d, "ido") val fps = ido!!.getElementsByClass("fp") // Last one is "fp fps" check(fps.size == 11) for (i in 0..9) { val fp = fps[i] countArray[i] = ParserUtils.parseInt(fp.child(0).text(), 0) catArray[i] = ParserUtils.trim(fp.child(2).text()) } }.onFailure { ExceptionUtils.throwIfFatal(it) it.printStackTrace() throw ParseException("Parse favorites error") } val result = GalleryListParser.parse(d, body) return Result(catArray.requireNoNulls(), countArray, result) } class Result( val catArray: Array, val countArray: IntArray, galleryListResult: GalleryListParser.Result, ) { val prev = galleryListResult.prev val next = galleryListResult.next val galleryInfoList = galleryListResult.galleryInfoList } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/ForumsParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.exception.NotLoggedInException import com.hippo.util.ExceptionUtils import org.jsoup.Jsoup object ForumsParser { fun parse(body: String): String = runCatching { val d = Jsoup.parse(body, EhUrl.URL_FORUMS) val userlinks = d.getElementById("userlinks") val child = userlinks!!.child(0).child(0).child(0) child.attr("href") }.getOrElse { ExceptionUtils.throwIfFatal(it) throw NotLoggedInException() } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryApiParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.EhUtils.getCategory import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.yorozuya.NumberUtils import org.json.JSONObject object GalleryApiParser { fun parse(body: String, galleryInfoList: List) { val jo = JSONObject(body) val ja = jo.getJSONArray("gmetadata") for (i in 0 until ja.length()) { val g = ja.getJSONObject(i) val gid = g.getLong("gid") val gi = galleryInfoList.find { it.gid == gid } ?: continue gi.title = ParserUtils.trim(g.getString("title")) gi.titleJpn = ParserUtils.trim(g.getString("title_jpn")) gi.category = getCategory(g.getString("category")) gi.thumb = g.getString("thumb") gi.uploader = g.getString("uploader") gi.posted = ParserUtils.formatDate(ParserUtils.parseLong(g.getString("posted"), 0) * 1000) gi.rating = NumberUtils.parseFloatSafely(g.getString("rating"), 0.0f) // tags val tagJa = g.getJSONArray("tags") gi.simpleTags = (0 until tagJa.length()).map { tagJa.getString(it) }.toTypedArray() gi.pages = NumberUtils.parseIntSafely(g.getString("filecount"), 0) gi.generateSLang() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryDetailParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhFilter import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.EhUtils.getCategory import com.hippo.ehviewer.client.data.BaseGalleryInfo import com.hippo.ehviewer.client.data.GalleryComment import com.hippo.ehviewer.client.data.GalleryCommentList import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.data.GalleryTagGroup import com.hippo.ehviewer.client.data.LargePreviewSet import com.hippo.ehviewer.client.data.NormalPreviewSet import com.hippo.ehviewer.client.data.PreviewSet import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.exception.OffensiveException import com.hippo.ehviewer.client.exception.ParseException import com.hippo.ehviewer.client.exception.PiningException import com.hippo.util.ExceptionUtils import com.hippo.util.JsoupUtils import com.hippo.util.toEpochMillis import com.hippo.yorozuya.NumberUtils import com.hippo.yorozuya.StringUtils import com.hippo.yorozuya.trimAnd import com.hippo.yorozuya.unescapeXml import kotlinx.datetime.LocalDateTime import kotlinx.datetime.format.MonthNames import kotlinx.datetime.format.char import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.nodes.Node import org.jsoup.select.Elements import org.jsoup.select.NodeTraversor import org.jsoup.select.NodeVisitor object GalleryDetailParser { private val PATTERN_ERROR = Regex("
\n

([^<]+)

") private val PATTERN_DETAIL = Regex( "var gid = (\\d+);.+?var token = \"([a-f0-9]+)\";.+?var apiuid = ([\\-\\d]+);.+?var apikey = \"([a-f0-9]+)\";", RegexOption.DOT_MATCHES_ALL, ) private val PATTERN_TORRENT = Regex("]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download \\((\\d+)\\)") private val PATTERN_ARCHIVE = Regex("]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download") private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)") private val PATTERN_PAGES = Regex("]*>Length:]*>([\\d,]+) pages") private val PATTERN_PREVIEW_PAGES = Regex("]+>]+>([\\d,]+)]+>(?:]+>)?>(?:)?") private val PATTERN_PREVIEW = Regex("(?:
)?
") private val PATTERN_FAVORITE_SLOT = Regex("/fav.png\\); background-position:0px -(\\d+)px") private val EMPTY_GALLERY_TAG_GROUP_ARRAY = arrayOf() private val EMPTY_GALLERY_COMMENT_ARRAY = GalleryCommentList(arrayOf(), false) // dd MMMM yyyy, HH:mm private val WEB_COMMENT_DATE_FORMAT = LocalDateTime.Format { day() char(' ') monthName(MonthNames.ENGLISH_FULL) char(' ') year() chars(", ") hour() char(':') minute() } private const val OFFENSIVE_STRING = "

(And if you choose to ignore this warning, you lose all rights to complain about it in the future.)

" private const val PINING_STRING = "

This gallery is pining for the fjords.

" @Throws(EhException::class) fun parse(body: String): GalleryDetail { if (body.contains(OFFENSIVE_STRING)) { throw OffensiveException() } if (body.contains(PINING_STRING)) { throw PiningException() } // Error info PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) } val galleryDetail = GalleryDetail() val document = Jsoup.parse(body) parseDetail(galleryDetail, document, body) galleryDetail.tags = parseTagGroups(document) galleryDetail.comments = parseComments(document) galleryDetail.previewPages = parsePreviewPages(document) galleryDetail.previewSet = parsePreviewSet(body) // Generate simpleLanguage for local favorites galleryDetail.generateSLang() return galleryDetail } @Throws(ParseException::class) private fun parseDetail(gd: GalleryDetail, d: Document, body: String) { PATTERN_DETAIL.find(body)?.apply { gd.gid = groupValues[1].toLongOrNull() ?: -1L gd.token = groupValues[2] gd.apiUid = groupValues[3].toLongOrNull() ?: -1L gd.apiKey = groupValues[4] } ?: throw ParseException("Can't parse gallery detail") if (gd.gid == -1L) { throw ParseException("Can't parse gallery detail") } PATTERN_TORRENT.find(body)?.run { gd.torrentUrl = groupValues[1].trim().unescapeXml() gd.torrentCount = groupValues[2].toIntOrNull() ?: 0 } PATTERN_ARCHIVE.find(body)?.run { gd.archiveUrl = groupValues[1].trim().unescapeXml() } try { val gm = d.getElementsByClass("gm")[0] // Thumb url gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let { gd.thumb = PATTERN_COVER.find(it)?.run { groupValues[3] } } // Title gd.title = gm.getElementById("gn")?.text()?.trim() // Jpn title gd.titleJpn = gm.getElementById("gj")?.text()?.trim() // Category val gdc = gm.getElementById("gdc") try { var ce = JsoupUtils.getElementByClass(gdc, "cn") if (ce == null) { ce = JsoupUtils.getElementByClass(gdc, "cs") } gd.category = getCategory(ce!!.text()) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) gd.category = EhUtils.UNKNOWN } // Uploader val gdn = gm.getElementById("gdn") if (null != gdn) { gd.disowned = gdn.attr("style").contains("opacity:0.5") gd.uploader = StringUtils.trim(gdn.text()) } else { gd.uploader = "" } val gdd = gm.getElementById("gdd") gd.posted = "" gd.parent = "" gd.visible = "" gd.size = "" gd.pages = 0 gd.favoriteCount = 0 val es = gdd!!.child(0).child(0).children() es.forEach { parseDetailInfo(gd, it) } // Rating count val ratingCount = gm.getElementById("rating_count") if (null != ratingCount) { gd.ratingCount = NumberUtils.parseIntSafely( StringUtils.trim(ratingCount.text()), 0, ) } else { gd.ratingCount = 0 } // Rating val ratingLabel = gm.getElementById("rating_label") if (null != ratingLabel) { val ratingStr = StringUtils.trim(ratingLabel.text()) if ("Not Yet Rated" == ratingStr) { gd.rating = -1.0f } else { val index = ratingStr.indexOf(' ') if (index == -1 || index >= ratingStr.length) { gd.rating = 0f } else { gd.rating = NumberUtils.parseFloatSafely(ratingStr.substring(index + 1), 0f) } } } else { gd.rating = -1.0f } // Is favorited val gdf = gm.getElementById("gdf") gd.isFavorited = false if (gdf != null) { val favoriteName = StringUtils.trim(gdf.text()) if (favoriteName == "Add to Favorites") { gd.favoriteName = null } else { gd.isFavorited = true gd.favoriteName = StringUtils.trim(gdf.text()) PATTERN_FAVORITE_SLOT.find(body)?.run { gd.favoriteSlot = ((groupValues[1].toIntOrNull() ?: 2) - 2) / 19 } } } if (gd.favoriteSlot == -2 && EhDB.containLocalFavorites(gd.gid)) { gd.favoriteSlot = -1 } } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) throw ParseException("Can't parse gallery detail") } // Newer version d.getElementById("gnd")?.run { val dates = PATTERN_NEWER_DATE.findAll(body).map { it.groupValues[1] }.toList() select("a").forEachIndexed { index, element -> val gi = BaseGalleryInfo() val result = GalleryDetailUrlParser.parse(element.attr("href")) if (result != null) { gi.gid = result.gid gi.token = result.token gi.title = StringUtils.trim(element.text()) gi.posted = dates[index] gd.newerVersions.add(gi) } } } } private fun parseDetailInfo(gd: GalleryDetail, e: Element) { val es = e.children() if (es.size < 2) { return } val key = StringUtils.trim(es[0].text()) val value = StringUtils.trim(es[1].ownText()) if (key.startsWith("Posted")) { gd.posted = value } else if (key.startsWith("Parent")) { val a = es[1].children().first() if (a != null) { gd.parent = a.attr("href") } } else if (key.startsWith("Visible")) { gd.visible = value } else if (key.startsWith("Language")) { gd.language = value } else if (key.startsWith("File Size")) { gd.size = value } else if (key.startsWith("Length")) { val index = value.indexOf(' ') if (index >= 0) { gd.pages = NumberUtils.parseIntSafely(value.substring(0, index), 1) } else { gd.pages = 1 } } else if (key.startsWith("Favorited")) { when (value) { "Never" -> gd.favoriteCount = 0 "Once" -> gd.favoriteCount = 1 else -> { val index = value.indexOf(' ') if (index == -1) { gd.favoriteCount = 0 } else { gd.favoriteCount = NumberUtils.parseIntSafely(value.substring(0, index), 0) } } } } } private fun parseTagGroup(element: Element): GalleryTagGroup? = try { val group = GalleryTagGroup() var nameSpace = element.child(0).text() // Remove last ':' nameSpace = nameSpace.substring(0, nameSpace.length - 1) group.groupName = nameSpace val tags = element.child(1).children() tags.forEach { var tag = it.text() // Sometimes parody tag is followed with '|' and english translate, just remove them val index = tag.indexOf('|') if (index >= 0) { tag = tag.substring(0, index).trim() } // Vote status if (it.child(0).hasClass("tup")) { tag = "_U$tag" } else if (it.child(0).hasClass("tdn")) { tag = "_D$tag" } // Weak tag if (it.hasClass("gtw")) { tag = "_W$tag" } // Active tag if (it.hasClass("gtl")) { tag = "_L$tag" } group.add(tag) } group.ifEmpty { null } } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() null } /** * Parse tag groups with html parser */ fun parseTagGroups(document: Document): Array? { return try { val taglist = document.getElementById("taglist")!! if (taglist.children().isEmpty()) return null val tagGroups = taglist.child(0).child(0).children() parseTagGroups(tagGroups) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() EMPTY_GALLERY_TAG_GROUP_ARRAY } } private fun parseTagGroups(trs: Elements): Array = try { trs.mapNotNull { parseTagGroup(it) }.toTypedArray() } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() EMPTY_GALLERY_TAG_GROUP_ARRAY } private fun parseComment(element: Element): GalleryComment? { return try { val comment = GalleryComment() // Id val a = element.previousElementSibling() val name = a!!.attr("name") comment.id = name trimAnd { substring(1).toInt().toLong() } // Editable, vote up and vote down val c4 = JsoupUtils.getElementByClass(element, "c4") if (null != c4) { if ("Uploader Comment" == c4.text()) { comment.uploader = true } for (e in c4.children()) { when (e.text()) { "Vote+" -> { comment.voteUpAble = true comment.voteUpEd = StringUtils.trim(e.attr("style")).isNotEmpty() } "Vote-" -> { comment.voteDownAble = true comment.voteDownEd = StringUtils.trim(e.attr("style")).isNotEmpty() } "Edit" -> comment.editable = true } } } // Vote state val c7 = JsoupUtils.getElementByClass(element, "c7") if (null != c7) { comment.voteState = StringUtils.trim(c7.text()) } // Score val c5 = JsoupUtils.getElementByClass(element, "c5") if (null != c5) { val es = c5.children() if (!es.isEmpty()) { comment.score = NumberUtils.parseIntSafely( StringUtils.trim(es[0].text()), 0, ) } } // Time val c3 = JsoupUtils.getElementByClass(element, "c3") val temp = c3!!.ownText() val hasUserName = temp.endsWith(":") val time = if (hasUserName) temp.substring("Posted on ".length, temp.length - " by:".length) else temp.substring("Posted on ".length) comment.time = WEB_COMMENT_DATE_FORMAT.parse(time).toEpochMillis() // User comment.user = if (hasUserName) c3.child(0).text() else "Anonymous" // Comment val c6 = JsoupUtils.getElementByClass(element, "c6") for (e in c6!!.children()) { val tagName = e.tagName() // Fix underline support if ("span" == tagName && "text-decoration:underline;" == e.attr("style")) { e.tagName("u") // Temporary workaround, see https://github.com/jhy/jsoup/issues/1850 } else if ("del" == tagName) { e.tagName("s") } } comment.comment = c6.html() // Filter comment if (!comment.uploader) { val sEhFilter = EhFilter if (comment.score <= Settings.commentThreshold || !sEhFilter.filterCommenter(comment.user) || !sEhFilter.filterComment(comment.comment)) { return null } } // Last edited val c8 = JsoupUtils.getElementByClass(element, "c8") if (c8 != null) { val e = c8.children().first() if (e != null) { comment.lastEdited = WEB_COMMENT_DATE_FORMAT.parse(e.text()).toEpochMillis() } } comment } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() null } } /** * Parse comments with html parser */ fun parseComments(document: Document): GalleryCommentList = try { val cdiv = document.getElementById("cdiv")!! val c1s = cdiv.getElementsByClass("c1") val list = c1s.mapNotNull { parseComment(it) } val chd = cdiv.getElementById("chd") var hasMore = false NodeTraversor.traverse( object : NodeVisitor { override fun head(node: Node, depth: Int) { if (node is Element && node.text() == "click to show all") { hasMore = true } } override fun tail(node: Node, depth: Int) {} }, chd!!, ) GalleryCommentList(list.toTypedArray(), hasMore) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() EMPTY_GALLERY_COMMENT_ARRAY } /** * Parse preview pages with html parser */ @Throws(ParseException::class) fun parsePreviewPages(document: Document): Int = try { val ptt = document.getElementsByClass("ptt").first()!! val elements = ptt.child(0).child(0).children() elements[elements.size - 2].text().toInt() } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() throw ParseException("Can't parse preview pages") } /** * Parse preview pages with regular expressions */ @Throws(ParseException::class) fun parsePreviewPages(body: String): Int = PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull() ?: throw ParseException("Parse preview page count error") /** * Parse pages with regular expressions */ @Throws(ParseException::class) fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull() ?: throw ParseException("Parse pages error") /** * Parse previews with regular expressions */ @Throws(ParseException::class) fun parsePreviewSet(body: String): PreviewSet { val largePreviewSet = LargePreviewSet() val normalPreviewSet = NormalPreviewSet() PATTERN_PREVIEW.findAll(body).forEach { val pageUrl = it.groupValues[1] val position = it.groupValues[2].toInt() - 1 val url = it.groupValues[5] val offset = it.groupValues[6] val sha1 = it.groupValues[7] if (offset.isEmpty()) { largePreviewSet.addItem(position, url, pageUrl, sha1) } else { val width = it.groupValues[3].toInt() val height = it.groupValues[4].toInt() normalPreviewSet.addItem(position, url, offset.toInt(), 0, width, height, pageUrl, sha1) } } if (largePreviewSet.size() > 0) return largePreviewSet if (normalPreviewSet.size() > 0) return normalPreviewSet throw ParseException("Can't parse preview") } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryDetailUrlParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.EhUrl import com.hippo.yorozuya.NumberUtils import java.util.regex.Pattern /** * Like http://exhentai.org/g/1234567/a1b2c3d4e5

*/ object GalleryDetailUrlParser { private val URL_STRICT_PATTERN = Pattern.compile( "https?://(?:" + EhUrl.DOMAIN_EX + "|" + EhUrl.DOMAIN_E + "|" + EhUrl.DOMAIN_LOFI + ")/(?:g|mpv)/(\\d+)/([0-9a-f]{10})", ) private val URL_PATTERN = Pattern.compile("(\\d+)/([0-9a-f]{10})(?:[^0-9a-f]|$)") fun parse(url: String?, strict: Boolean = true): Result? { url ?: return null val pattern = if (strict) URL_STRICT_PATTERN else URL_PATTERN val m = pattern.matcher(url) return if (m.find()) { val gid = NumberUtils.parseLongSafely(m.group(1), 0).takeIf { it > 0 } gid?.let { Result(it, m.group(2)!!) } } else { null } } class Result(val gid: Long, val token: String) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryListParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import android.util.Log import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.BaseGalleryInfo import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.exception.ParseException import com.hippo.util.ExceptionUtils import com.hippo.util.JsoupUtils import com.hippo.yorozuya.NumberUtils import java.util.regex.Pattern import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element object GalleryListParser { private val TAG = GalleryListParser::class.java.simpleName private const val NO_UNFILTERED_TEXT = "No unfiltered results in this page range. You either requested an invalid page or used too aggressive filters." private val PATTERN_RATING = Pattern.compile("\\d+px") private val PATTERN_THUMB_SIZE = Pattern.compile("height:(\\d+)px;width:(\\d+)px") private val PATTERN_FAVORITE_SLOT = Pattern.compile("background-color:rgba\\((\\d+),(\\d+),(\\d+),") private val PATTERN_PAGES = Pattern.compile("(\\d+) page") private val PATTERN_NEXT_PAGE = Pattern.compile("p=(\\d+)") private val PATTERN_PREV = Pattern.compile("prev=(\\d+(-\\d+)?)") private val PATTERN_NEXT = Pattern.compile("next=(\\d+(-\\d+)?)") private val FAVORITE_SLOT_RGB = arrayOf( Triple("0", "0", "0"), Triple("240", "0", "0"), Triple("240", "160", "0"), Triple("208", "208", "0"), Triple("0", "128", "0"), Triple("144", "240", "64"), Triple("64", "176", "240"), Triple("0", "0", "240"), Triple("80", "0", "128"), Triple("224", "128", "224"), ) private fun parseRating(ratingStyle: String): String? { val m = PATTERN_RATING.matcher(ratingStyle) var num1 = Int.MIN_VALUE var num2 = Int.MIN_VALUE var rate = 5 if (m.find()) { num1 = ParserUtils.parseInt(m.group().replace("px", ""), Int.MIN_VALUE) } if (m.find()) { num2 = ParserUtils.parseInt(m.group().replace("px", ""), Int.MIN_VALUE) } if (num1 == Int.MIN_VALUE || num2 == Int.MIN_VALUE) { return null } rate -= num1 / 16 return if (num2 == 21) { "${rate - 1}.5" } else { rate.toString() } } private fun parseFavoriteSlot(style: String): Int { val m = PATTERN_FAVORITE_SLOT.matcher(style) if (m.find()) { val r = m.group(1)!! val g = m.group(2)!! val b = m.group(3)!! return FAVORITE_SLOT_RGB.indexOf(Triple(r, g, b)) } return -2 } private fun parseGalleryInfo(e: Element): GalleryInfo? { val gi: GalleryInfo = BaseGalleryInfo() // Title, gid, token (required) val glname = JsoupUtils.getElementByClass(e, "glname") if (glname != null) { var a = JsoupUtils.getElementByTag(glname, "a") if (a == null) { val parent = glname.parent() if (parent != null && "a" == parent.tagName()) { a = parent } } if (a != null) { val result = GalleryDetailUrlParser.parse(a.attr("href")) if (result != null) { gi.gid = result.gid gi.token = result.token } } var child: Element = glname var children = glname.children() while (children.isNotEmpty()) { child = children[0] children = child.children() } gi.title = child.text().trim { it <= ' ' } } if (gi.title == null) { return null } // Tags val gts = e.select(".gt, .gtl") if (gts.isNotEmpty()) { val tags = ArrayList() for (gt in gts) { tags.add(gt.attr("title")) } gi.simpleTags = tags.toTypedArray() } // Category gi.category = EhUtils.UNKNOWN var ce = JsoupUtils.getElementByClass(e, "cn") if (ce == null) { ce = JsoupUtils.getElementByClass(e, "cs") } if (ce != null) { gi.category = EhUtils.getCategory(ce.text()) } // Thumb and pages val glthumb = JsoupUtils.getElementByClass(e, "glthumb") if (glthumb != null) { val img = glthumb.select("div:nth-child(1)>img").first() if (img != null) { // Thumb size val m = PATTERN_THUMB_SIZE.matcher(img.attr("style")) if (m.find()) { gi.thumbWidth = NumberUtils.parseIntSafely(m.group(2), 0) gi.thumbHeight = NumberUtils.parseIntSafely(m.group(1), 0) } else { Log.w(TAG, "Can't parse gallery info thumb size") gi.thumbWidth = 0 gi.thumbHeight = 0 } // Thumb url var url = img.attr("data-src") if (url.isEmpty()) { url = img.attr("src") } if (url.isNotEmpty()) { gi.thumb = url } } // Pages val div = glthumb.select("div:nth-child(2)>div:nth-child(2)>div:nth-child(2)").first() if (div != null) { val matcher = PATTERN_PAGES.matcher(div.text()) if (matcher.find()) { gi.pages = NumberUtils.parseIntSafely(matcher.group(1), 0) } } } // Try extended and thumbnail version if (gi.thumb == null) { var gl = JsoupUtils.getElementByClass(e, "gl1e") if (gl == null) { gl = JsoupUtils.getElementByClass(e, "gl3t") } if (gl != null) { val img = JsoupUtils.getElementByTag(gl, "img") if (img != null) { // Thumb size val m = PATTERN_THUMB_SIZE.matcher(img.attr("style")) if (m.find()) { gi.thumbWidth = NumberUtils.parseIntSafely(m.group(2), 0) gi.thumbHeight = NumberUtils.parseIntSafely(m.group(1), 0) } else { Log.w(TAG, "Can't parse gallery info thumb size") gi.thumbWidth = 0 gi.thumbHeight = 0 } gi.thumb = img.attr("src") } } } // Posted val posted = e.getElementById("posted_" + gi.gid) if (posted != null) { gi.posted = posted.text().trim { it <= ' ' } gi.favoriteSlot = parseFavoriteSlot(posted.attr("style")) } if (gi.favoriteSlot < 0) { gi.favoriteSlot = if (EhDB.containLocalFavorites(gi.gid)) -1 else -2 } // Rating val ir = JsoupUtils.getElementByClass(e, "ir") if (ir != null) { gi.rating = NumberUtils.parseFloatSafely(parseRating(ir.attr("style")), -1.0f) // TODO The gallery may be rated even if it doesn't has one of these classes gi.rated = ir.hasClass("irr") || ir.hasClass("irg") || ir.hasClass("irb") } // Uploader and pages var gl = JsoupUtils.getElementByClass(e, "glhide") var uploaderIndex = 0 var pagesIndex = 1 if (gl == null) { // For extended gl = JsoupUtils.getElementByClass(e, "gl3e") uploaderIndex = 3 pagesIndex = 4 } if (gl != null) { val children = gl.children() if (children.size > uploaderIndex) { val div = children[uploaderIndex] gi.disowned = div.attr("style").contains("opacity:0.5") val a = div.children().first() gi.uploader = a?.text()?.trim { it <= ' ' } ?: div.text().trim { it <= ' ' } } if (children.size > pagesIndex) { val matcher = PATTERN_PAGES.matcher(children[pagesIndex].text()) if (matcher.find()) { gi.pages = NumberUtils.parseIntSafely(matcher.group(1), 0) } } } // For thumbnail val gl5t = JsoupUtils.getElementByClass(e, "gl5t") if (gl5t != null) { val div = gl5t.select("div:nth-child(2)>div:nth-child(2)").first() if (div != null) { val matcher = PATTERN_PAGES.matcher(div.text()) if (matcher.find()) { gi.pages = NumberUtils.parseIntSafely(matcher.group(1), 0) } } } // Favorite note val glfnote = JsoupUtils.getElementByClass(e, "glfnote") if (glfnote != null) { val favoriteNote = glfnote.text().trim { it <= ' ' } if (favoriteNote.isNotEmpty()) { gi.favoriteNote = favoriteNote } } gi.generateSLang() return gi } fun parse(body: String): Result { val d = Jsoup.parse(body) return parse(d, body) } fun parse(d: Document, body: String): Result { val result = Result() try { val prev = d.getElementById("uprev") val next = d.getElementById("unext") assert(prev != null) assert(next != null) val matcherPrev = PATTERN_PREV.matcher(prev!!.attr("href")) val matcherNext = PATTERN_NEXT.matcher(next!!.attr("href")) if (matcherPrev.find()) result.prev = matcherPrev.group(1) if (matcherNext.find()) result.next = matcherNext.group(1) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) result.noWatchedTags = body.contains("

You do not have any watched tags") if (body.contains("No hits found

")) { val warn = d.getElementsByClass("searchwarn").text() if (warn.isEmpty()) { return result } else { throw EhException(warn) } } } try { // For toplists val ptt = d.getElementsByClass("ptt").first() if (ptt != null) { val es = ptt.child(0).child(0).children() result.pages = es[es.size - 2].text().trim { it <= ' ' }.toInt() var e = es[es.size - 1] e = e.children().first() as Element val href = e.attr("href") val matcher = PATTERN_NEXT_PAGE.matcher(href) if (matcher.find()) { result.nextPage = NumberUtils.parseIntSafely(matcher.group(1), 0) } } } catch (e: Throwable) { e.printStackTrace() } try { val itg = d.getElementsByClass("itg").first() val es = if ("table".equals(itg!!.tagName(), ignoreCase = true)) { itg.child(0).children() } else { itg.children() } val list = result.galleryInfoList // First one is table header, skip it for (i in es.indices) { val gi = parseGalleryInfo(es[i]) if (null != gi) { list.add(gi) } } if (list.isEmpty()) { if (es.size < 2 || NO_UNFILTERED_TEXT != es[1].text()) { Log.d(TAG, "No gallery found") } } } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() throw ParseException("Can't parse gallery list") } return result } class Result { var pages = 0 var nextPage = 0 var prev: String? = null var next: String? = null var noWatchedTags = false val galleryInfoList = mutableListOf() } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryListUrlParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import android.text.TextUtils import androidx.core.net.toUri import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.data.ListUrlBuilder import com.hippo.util.isAtLeastT import com.hippo.yorozuya.Utilities import java.io.UnsupportedEncodingException import java.net.MalformedURLException import java.net.URL import java.net.URLDecoder import java.nio.charset.StandardCharsets object GalleryListUrlParser { private val VALID_HOSTS = arrayOf(EhUrl.DOMAIN_EX, EhUrl.DOMAIN_E, EhUrl.DOMAIN_LOFI) private const val PATH_NORMAL = "/" private const val PATH_UPLOADER = "/uploader/" private const val PATH_TAG = "/tag/" private const val PATH_TOPLIST = "/toplist.php" fun parse(urlStr: String): ListUrlBuilder? { val url = try { URL(urlStr) } catch (_: MalformedURLException) { return null } if (!Utilities.contain(VALID_HOSTS, url.host)) { return null } val path = url.path ?: return null return if (PATH_NORMAL == path || path.isEmpty()) { val builder = ListUrlBuilder() builder.setQuery(url.query) builder } else if (path.startsWith(PATH_UPLOADER)) { parseUploader(path) } else if (path.startsWith(PATH_TAG)) { parseTag(path) } else if (path.startsWith(PATH_TOPLIST)) { parseToplist(urlStr) } else if (path.startsWith("/")) { val category = try { path.substring(1).toInt() } catch (_: NumberFormatException) { return null } val builder = ListUrlBuilder() builder.setQuery(url.query) builder.category = category builder } else { null } } // TODO get page private fun parseUploader(path: String): ListUrlBuilder? { var uploader: String? val prefixLength = PATH_UPLOADER.length val index = path.indexOf('/', prefixLength) uploader = if (index < 0) { path.substring(prefixLength) } else { path.substring(prefixLength, index) } uploader = if (isAtLeastT) { URLDecoder.decode(uploader, StandardCharsets.UTF_8) } else { try { URLDecoder.decode(uploader, StandardCharsets.UTF_8.displayName()) } catch (e: UnsupportedEncodingException) { e.printStackTrace() return null } } if (TextUtils.isEmpty(uploader)) { return null } val builder = ListUrlBuilder() builder.mode = ListUrlBuilder.MODE_UPLOADER builder.keyword = uploader return builder } // TODO get page private fun parseTag(path: String): ListUrlBuilder? { var tag: String? val prefixLength = PATH_TAG.length val index = path.indexOf('/', prefixLength) tag = if (index < 0) { path.substring(prefixLength) } else { path.substring(prefixLength, index) } tag = if (isAtLeastT) { URLDecoder.decode(tag, StandardCharsets.UTF_8) } else { try { URLDecoder.decode(tag, StandardCharsets.UTF_8.displayName()) } catch (e: UnsupportedEncodingException) { e.printStackTrace() return null } } if (TextUtils.isEmpty(tag)) { return null } val builder = ListUrlBuilder() builder.mode = ListUrlBuilder.MODE_TAG builder.keyword = tag return builder } // TODO get page private fun parseToplist(path: String): ListUrlBuilder? { val uri = path.toUri() if (TextUtils.isEmpty(uri.getQueryParameter("tl"))) { return null } val tl = uri.getQueryParameter("tl")!!.toInt() if (tl > 15 || tl < 11) { return null } val builder = ListUrlBuilder() builder.mode = ListUrlBuilder.MODE_TOPLIST builder.keyword = tl.toString() return builder } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryMultiPageViewerParser.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.ParseException import org.json.JSONArray object GalleryMultiPageViewerParser { private const val IMAGE_LIST_STRING = "var imagelist = " private val PATTERN_SHA1 = Regex("data-orghash=\"([^\"]+)\"") fun parsePToken(body: String): List = runCatching { val index = body.indexOf(IMAGE_LIST_STRING) val imageList = body.substring(index + IMAGE_LIST_STRING.length, body.indexOf(";", index)) val ja = JSONArray(imageList) (0 until ja.length()).map { ja.getJSONObject(it).getString("k") } }.getOrElse { throw ParseException("Parse pToken from MPV error", it) } fun parseSha1(body: String): List = runCatching { PATTERN_SHA1.findAll(body).map { it.groupValues[1] }.toList() }.getOrElse { throw ParseException("Parse sha1 from MPV error", it) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryNotAvailableParser.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.client.parser import com.hippo.util.ExceptionUtils import com.hippo.util.JsoupUtils import org.jsoup.Jsoup object GalleryNotAvailableParser { fun parse(body: String): String? = runCatching { val document = Jsoup.parse(body) val d = JsoupUtils.getElementByClass(document, "d") d!!.child(0).html().replace("
", "\n") }.getOrElse { ExceptionUtils.throwIfFatal(it) it.printStackTrace() null } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryPageApiParser.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.ParseException import com.hippo.yorozuya.StringUtils import java.util.regex.Matcher import java.util.regex.Pattern import org.json.JSONException import org.json.JSONObject object GalleryPageApiParser { private val PATTERN_IMAGE_URL = Pattern.compile("]*src=\"([^\"]+)\" style") private val PATTERN_SKIP_HATH_KEY = Pattern.compile("onclick=\"return nl\\('([^)]+)'\\)") private val PATTERN_ORIGIN_IMAGE_URL = Pattern.compile("
") fun parse(body: String): Result = try { var m: Matcher val jo = JSONObject(body) if (jo.has("error")) { throw ParseException(jo.getString("error")) } val i3 = jo.getString("i3") m = PATTERN_IMAGE_URL.matcher(i3) val imageUrl = if (m.find()) { StringUtils.unescapeXml(StringUtils.trim(m.group(1))) } else { null } val i6 = jo.getString("i6") m = PATTERN_SKIP_HATH_KEY.matcher(i6) val skipHathKey = if (m.find()) { StringUtils.unescapeXml(StringUtils.trim(m.group(1))) } else { null } m = PATTERN_ORIGIN_IMAGE_URL.matcher(i6) val originImageUrl = if (m.find()) { StringUtils.unescapeXml(m.group(1)) + "fullimg" + StringUtils.unescapeXml(m.group(2)) } else { null } if (!imageUrl.isNullOrEmpty()) { Result(imageUrl, skipHathKey, originImageUrl) } else { throw ParseException("Parse image url and skip hath key error") } } catch (e: JSONException) { throw ParseException("Can't parse json", e) } class Result(val imageUrl: String, val skipHathKey: String?, val originImageUrl: String?) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryPageParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.ParseException import com.hippo.yorozuya.StringUtils import java.util.regex.Pattern object GalleryPageParser { private val PATTERN_IMAGE_URL = Pattern.compile("]*src=\"([^\"]+)\" style") private val PATTERN_SKIP_HATH_KEY = Pattern.compile("onclick=\"return nl\\('([^)]+)'\\)") private val PATTERN_ORIGIN_IMAGE_URL = Pattern.compile("") // TODO Not sure about the size of show keys private val PATTERN_SHOW_KEY = Pattern.compile("var showkey=\"([0-9a-z]+)\";") fun parse(body: String): Result { var m = PATTERN_IMAGE_URL.matcher(body) val imageUrl = if (m.find()) { StringUtils.unescapeXml(StringUtils.trim(m.group(1))) } else { null } m = PATTERN_SKIP_HATH_KEY.matcher(body) val skipHathKey = if (m.find()) { StringUtils.unescapeXml(StringUtils.trim(m.group(1))) } else { null } m = PATTERN_ORIGIN_IMAGE_URL.matcher(body) val originImageUrl = if (m.find()) { StringUtils.unescapeXml(m.group(1)) + "fullimg" + StringUtils.unescapeXml(m.group(2)) } else { null } m = PATTERN_SHOW_KEY.matcher(body) val showKey = if (m.find()) { m.group(1) } else { null } return if (!imageUrl.isNullOrEmpty() && !showKey.isNullOrEmpty()) { Result(imageUrl, skipHathKey, originImageUrl, showKey) } else { throw ParseException("Parse image url and show error") } } class Result( val imageUrl: String, val skipHathKey: String?, val originImageUrl: String?, val showKey: String, ) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryPageUrlParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.EhUrl import com.hippo.yorozuya.NumberUtils import java.util.regex.Pattern /** * Like http://exhentai.org/s/91ea4b6d89/901103-12 */ object GalleryPageUrlParser { private val URL_STRICT_PATTERN = Pattern.compile( "https?://(?:" + EhUrl.DOMAIN_EX + "|" + EhUrl.DOMAIN_E + "|" + EhUrl.DOMAIN_LOFI + ")/s/([0-9a-f]{10}|[0-9a-f]{40})/(\\d+)-(\\d+)", ) private val URL_PATTERN = Pattern.compile("([0-9a-f]{10})/(\\d+)-(\\d+)") fun parse(url: String?, strict: Boolean = true): Result? { url ?: return null val pattern = if (strict) URL_STRICT_PATTERN else URL_PATTERN val m = pattern.matcher(url) return if (m.find()) { val gid = NumberUtils.parseLongSafely(m.group(2), -1L) val pToken = m.group(1)!! val page = NumberUtils.parseIntSafely(m.group(3), 0) - 1 if (gid < 0 || page < 0) { null } else { Result(gid, pToken, page) } } else { null } } class Result(val gid: Long, val pToken: String, val page: Int) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/GalleryTokenApiParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.EhException import com.hippo.util.ExceptionUtils import org.json.JSONObject object GalleryTokenApiParser { /** * { * "tokenlist": [ * { * "gid":618395, * "token":"0439fa3666" * } * ] * } */ fun parse(body: String): String { val jo = JSONObject(body).getJSONArray("tokenlist").getJSONObject(0) return runCatching { jo.getString("token") }.getOrElse { ExceptionUtils.throwIfFatal(it) throw EhException(jo.getString("error")) } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/HomeParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.InsufficientFundsException import com.hippo.ehviewer.client.exception.ParseException import org.jsoup.Jsoup object HomeParser { const val IP_NORMAL = -1 const val IP_RESTRICTED = -2 private val PATTERN_FUNDS = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL) private const val INSUFFICIENT_FUNDS = "Insufficient funds." private const val IP_RESTRICTED_STR = "Due to a high request rate, your IP is currently restricted to lower-resolution images." fun parse(body: String): Limits { Jsoup.parse(body).selectFirst("div.homebox")?.let { val es = it.select("p > strong") if (es.size == 3) { val current = ParserUtils.parseInt(es[0].text(), 0) val maximum = ParserUtils.parseInt(es[1].text(), 0) val resetCost = ParserUtils.parseInt(es[2].text(), 0) return Limits(current, maximum, resetCost) } else if (es.size == 1) { val maximum = if (body.contains(IP_RESTRICTED_STR)) IP_RESTRICTED else IP_NORMAL val resetCost = ParserUtils.parseInt(es[0].text(), 0) return Limits(0, maximum, resetCost) } } throw ParseException("Parse image limits error") } fun parseResetLimits(body: String): Limits { if (body.contains(INSUFFICIENT_FUNDS)) { throw InsufficientFundsException() } return parse(body) } fun parseFunds(body: String): Funds { PATTERN_FUNDS.find(body)?.groupValues?.run { val fundsC = ParserUtils.parseInt(get(1), 0) val fundsGP = ParserUtils.parseInt(get(2), 0) * 1000 return Funds(fundsGP, fundsC) } throw ParseException("Parse funds error") } data class Limits(val current: Int = 0, val maximum: Int, val resetCost: Int = 0) data class Funds(val fundsGP: Int, val fundsC: Int) class Result(val limits: Limits, val funds: Funds) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/ParserUtils.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.util.toLocalDateTime import com.hippo.yorozuya.NumberUtils import com.hippo.yorozuya.StringUtils import kotlinx.datetime.LocalDateTime import kotlinx.datetime.format.char object ParserUtils { // yyyy-MM-dd HH:mm private val formatter = LocalDateTime.Format { year() char('-') monthNumber() char('-') day() char(' ') hour() char(':') minute() } fun formatDate(time: Long): String = formatter.format(time.toLocalDateTime()) fun trim(str: String?): String = str?.let { StringUtils.unescapeXml(it).trim() } ?: "" fun parseInt(str: String?, defValue: Int): Int = NumberUtils.parseIntSafely(trim(str).replace(",", ""), defValue) fun parseLong(str: String?, defValue: Long): Long = NumberUtils.parseLongSafely(trim(str).replace(",", ""), defValue) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/ProfileParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import android.util.Log import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.exception.ParseException import com.hippo.ehviewer.client.parser.SignInParser.ERROR_PATTERN import com.hippo.util.ExceptionUtils import org.jsoup.Jsoup object ProfileParser { private val TAG = ProfileParser::class.java.simpleName fun parse(body: String): Result = runCatching { val d = Jsoup.parse(body) val profilename = d.getElementById("profilename") val displayName = profilename!!.child(0).text() val avatar = runCatching { val avatar = profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src") if (avatar.isEmpty()) { null } else if (!avatar.startsWith("http")) { EhUrl.URL_FORUMS + avatar } else { avatar } }.getOrElse { ExceptionUtils.throwIfFatal(it) Log.i(TAG, "No avatar") null } Result(displayName, avatar) }.getOrElse { val m = ERROR_PATTERN.matcher(body) if (m.find()) { throw EhException(m.group(1) ?: m.group(2)) } else { ExceptionUtils.throwIfFatal(it) throw ParseException("Parse forums error") } } class Result(val displayName: String?, val avatar: String?) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/RateGalleryParser.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.ParseException import org.json.JSONException import org.json.JSONObject object RateGalleryParser { fun parse(body: String): Result = try { val jsonObject = JSONObject(body) val rating = jsonObject.getDouble("rating_avg").toFloat() val ratingCount = jsonObject.getInt("rating_cnt") Result(rating, ratingCount) } catch (e: JSONException) { throw ParseException("Can't parse rate gallery", e) } class Result(val rating: Float, val ratingCount: Int) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/SignInParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.exception.ParseException import java.util.regex.Pattern object SignInParser { private val NAME_PATTERN = Pattern.compile("

You are now logged in as: (.+?)<") val ERROR_PATTERN: Pattern = Pattern.compile( "

The error returned was:

\\s*

(.+?)

" + "|(.+?)", ) fun parse(body: String): String { var m = NAME_PATTERN.matcher(body) return if (m.find()) { m.group(1)!! } else { m = ERROR_PATTERN.matcher(body) if (m.find()) { throw EhException(m.group(1) ?: m.group(2)) } else { throw ParseException("Can't parse sign in") } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/TorrentParser.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.exception.ParseException import java.util.regex.Pattern import org.jsoup.Jsoup object TorrentParser { private val PATTERN_TORRENT = Pattern.compile(">\\s?([0-9-]+) [0-9:]+\\s?([0-9.]+ [KMGT]iB)\\s?([0-9]+)\\s?([0-9]+)\\s?([0-9]+)]+>\\s?([^<]+)([^<]+)
") fun parse(body: String): List { val torrentList = ArrayList() val d = Jsoup.parse(body) val es = d.select("form>div>table") for (e in es) { val html = e.html() if (!html.contains("Expunged")) { val m = PATTERN_TORRENT.matcher(html) if (m.find()) { val posted = m.group(1)!! val size = m.group(2)!! val seeds = m.group(3)!!.toInt() val peers = m.group(4)!!.toInt() val downloads = m.group(5)!!.toInt() val url = ParserUtils.trim(m.group(7)) val name = ParserUtils.trim(m.group(8)) torrentList.add(Result(posted, size, seeds, peers, downloads, url, name)) } else { throw ParseException("Can't parse torrent list") } } } return torrentList } class Result( private val posted: String, private val size: String, private val seeds: Int, private val peers: Int, private val downloads: Int, val url: String, val name: String, ) { fun format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/client/parser/UserConfigParser.kt ================================================ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.Settings import com.hippo.yorozuya.unescapeXml object UserConfigParser { private const val U_CONFIG_TEXT = "Selected Profile" private val FAV_CAT_PATTERN = Regex(". */ package com.hippo.ehviewer.client.parser import com.hippo.ehviewer.client.data.GalleryTagGroup import com.hippo.ehviewer.client.parser.GalleryDetailParser.parseTagGroups import org.json.JSONObject import org.jsoup.Jsoup object VoteTagParser { // {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"} fun parse(body: String): Pair?> { val obj = JSONObject(body) val tags = Jsoup.parse("
${obj.optString("tagpane")}
") return obj.optString("error") to parseTagGroups(tags) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/coil/DiskCache.kt ================================================ /* * Copyright 2023 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.coil import coil3.disk.DiskCache inline fun DiskCache.edit(key: String, block: DiskCache.Editor.() -> Unit): Boolean { val editor = openEditor(key) ?: return false editor.runCatching { block(this) }.onFailure { editor.abort() throw it }.onSuccess { editor.commit() } return true } inline fun DiskCache.read(key: String, block: DiskCache.Snapshot.() -> Unit): Boolean { (openSnapshot(key) ?: return false).use { block(it) } return true } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/coil/DownloadThumbInterceptor.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.coil import coil3.intercept.Interceptor import coil3.request.ImageResult import coil3.request.SuccessResult import com.hippo.ehviewer.EhApplication.Companion.thumbCache import com.hippo.ehviewer.Settings.downloadLocation import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl import com.hippo.unifile.UniFile import com.hippo.util.sendTo import com.hippo.util.withIOContext object DownloadThumbInterceptor : Interceptor { const val THUMB_FILE = ".thumb" override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val magicOrUrl = chain.request.data as? String if (magicOrUrl != null) { val (url, location) = decodeMagicRequestOrUrl(magicOrUrl) if (location != null) { return withIOContext { val thumb = downloadLocation?.subFile(location)?.subFile(THUMB_FILE) if (thumb?.isFile == true) { val new = chain.request.newBuilder().data(thumb.uri).build() val result = chain.withRequest(new).proceed() if (result is SuccessResult) return@withIOContext result } val new = chain.request.newBuilder().data(url).build() val result = chain.withRequest(new).proceed() if (result is SuccessResult && thumb?.parentFile?.isDirectory == true) { // Accessing the recreated file immediately after deleting it throws // FileNotFoundException, so we just overwrite the existing file. chain.request.memoryCacheKey?.let { if (!thumb.exists()) thumb.ensureFile() thumbCache.read(it) { UniFile.fromFile(data.toFile())!! sendTo thumb } } } result } } } return chain.proceed() } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/coil/LockPool.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.coil import kotlin.contracts.InvocationKind import kotlin.contracts.contract interface LockPool { fun acquire(key: K): Lock fun release(key: K, lock: Lock) suspend fun Lock.lock() fun Lock.tryLock(): Boolean fun Lock.unlock() } @Suppress("KotlinUnreachableCode") suspend inline fun LockPool.withLock(key: K, action: () -> R): R { contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) } val lock = acquire(key) return try { lock.lock() return try { action() } finally { lock.unlock() } } finally { release(key, lock) } } @Suppress("KotlinUnreachableCode") suspend inline fun LockPool.withLockNeedSuspend(key: K, action: () -> R): Pair { contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) } val lock = acquire(key) return try { val mustSuspend = !lock.tryLock() if (mustSuspend) lock.lock() return try { action() to mustSuspend } finally { lock.unlock() } } finally { release(key, lock) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/coil/MergeInterceptor.kt ================================================ /* * Copyright 2023 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.coil import coil3.decode.DataSource import coil3.intercept.Interceptor import coil3.request.ImageResult import coil3.request.SuccessResult import com.hippo.ehviewer.client.isNormalPreviewKey object MergeInterceptor : Interceptor { private val mutex = NamedMutex() override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val req = chain.request val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey } return if (key != null) { val (result, suspended) = mutex.withLockNeedSuspend(key) { chain.proceed() } when (result) { is SuccessResult if (suspended) -> result.copy(dataSource = DataSource.MEMORY) else -> result } } else { chain.proceed() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/coil/NamedMutex.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.coil import androidx.collection.MutableScatterMap import androidx.collection.mutableScatterMapOf import io.ktor.utils.io.pool.DefaultPool import kotlinx.coroutines.sync.Mutex class MutexTracker(mutex: Mutex = Mutex(), private var count: Int = 0) : Mutex by mutex { operator fun inc() = apply { count++ } operator fun dec() = apply { count-- } val isFree get() = count == 0 } object MutexPool : DefaultPool(capacity = 32) { override fun produceInstance() = MutexTracker() override fun validateInstance(instance: MutexTracker) { check(!instance.isLocked) check(instance.isFree) } } class NamedMutex(val active: MutableScatterMap = mutableScatterMapOf()) : LockPool { override fun acquire(key: K) = synchronized(active) { active.getOrPut(key) { MutexPool.borrow() }.inc() } override fun release(key: K, lock: MutexTracker) = synchronized(active) { lock.dec() if (lock.isFree) { active.remove(key) MutexPool.recycle(lock) } } override suspend fun MutexTracker.lock() = lock() override fun MutexTracker.tryLock() = tryLock() override fun MutexTracker.unlock() = unlock() } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/BasicDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao interface BasicDao { fun list(): List fun insert(t: T): Long } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/BookmarkInfo.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import android.annotation.SuppressLint import androidx.room.ColumnInfo import androidx.room.Entity import com.hippo.ehviewer.client.data.BaseGalleryInfo @SuppressLint("ParcelCreator") @Entity(tableName = "BOOKMARKS") class BookmarkInfo : BaseGalleryInfo() { @ColumnInfo(name = "PAGE") var page = 0 @ColumnInfo(name = "TIME") var time: Long = 0 } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/BookmarksDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query @Dao interface BookmarksDao : BasicDao { @Insert override fun insert(t: BookmarkInfo): Long @Delete fun delete(bookmark: BookmarkInfo) @Query("SELECT * FROM BOOKMARKS ORDER BY TIME DESC") override fun list(): List } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/DownloadDirname.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "DOWNLOAD_DIRNAME") data class DownloadDirname( @PrimaryKey @ColumnInfo(name = "GID") var gid: Long = 0, @ColumnInfo(name = "DIRNAME") var dirname: String? = null, ) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/DownloadDirnameDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import androidx.room.Update @Dao interface DownloadDirnameDao : BasicDao { @Query("SELECT * FROM DOWNLOAD_DIRNAME WHERE GID = :gid") fun load(gid: Long): DownloadDirname? @Update fun update(downloadDirname: DownloadDirname) @Insert override fun insert(t: DownloadDirname): Long @Query("DELETE FROM DOWNLOAD_DIRNAME WHERE GID = :gid") fun deleteByKey(gid: Long) @Query("DELETE FROM DOWNLOAD_DIRNAME") fun deleteAll() @Query("SELECT * FROM DOWNLOAD_DIRNAME") override fun list(): List } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/DownloadInfo.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import android.annotation.SuppressLint import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import com.hippo.ehviewer.client.data.BaseGalleryInfo import com.hippo.ehviewer.client.data.GalleryInfo @SuppressLint("ParcelCreator") @Entity(tableName = "DOWNLOADS") class DownloadInfo() : BaseGalleryInfo() { @ColumnInfo(name = "STATE") var state = 0 @ColumnInfo(name = "LEGACY") var legacy = 0 @ColumnInfo(name = "TIME") var time: Long = 0 @ColumnInfo(name = "LABEL") var label: String? = null @Ignore var speed: Long = 0 @Ignore var remaining: Long = 0 @Ignore var finished = 0 @Ignore var downloaded = 0 @Ignore var total = 0 constructor(galleryInfo: GalleryInfo) : this() { gid = galleryInfo.gid token = galleryInfo.token title = galleryInfo.title titleJpn = galleryInfo.titleJpn thumb = galleryInfo.thumb this.category = galleryInfo.category posted = galleryInfo.posted uploader = galleryInfo.uploader rating = galleryInfo.rating simpleTags = galleryInfo.simpleTags simpleLanguage = galleryInfo.simpleLanguage } companion object { const val STATE_INVALID = -1 const val STATE_NONE = 0 const val STATE_WAIT = 1 const val STATE_DOWNLOAD = 2 const val STATE_FINISH = 3 const val STATE_FAILED = 4 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/DownloadLabel.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "DOWNLOAD_LABELS") data class DownloadLabel( @PrimaryKey @ColumnInfo(name = "_id") var id: Long? = null, @ColumnInfo(name = "LABEL") var label: String? = null, @ColumnInfo(name = "TIME") var time: Long = 0, ) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/DownloadLabelDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update @Dao interface DownloadLabelDao : BasicDao { @Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY TIME ASC") override fun list(): List @Query("SELECT * FROM DOWNLOAD_LABELS ORDER BY TIME ASC LIMIT :limit OFFSET :offset") fun list(offset: Int, limit: Int): List @Update fun update(downloadLabels: List) @Update fun update(downloadLabel: DownloadLabel) @Insert override fun insert(t: DownloadLabel): Long @Delete fun delete(downloadLabel: DownloadLabel) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/DownloadsDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update @Dao interface DownloadsDao : BasicDao { @Query("SELECT * FROM DOWNLOADS ORDER BY TIME DESC") override fun list(): List @Query("SELECT * FROM DOWNLOADS WHERE GID = :gid") fun load(gid: Long): DownloadInfo? @Update fun update(downloadInfos: List) @Update fun update(downloadInfo: DownloadInfo) @Insert override fun insert(t: DownloadInfo): Long @Delete fun delete(downloadInfo: DownloadInfo) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/EhDatabase.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase @Database( entities = [BookmarkInfo::class, DownloadInfo::class, DownloadLabel::class, DownloadDirname::class, Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, QuickSearch::class], version = 4, exportSchema = false, ) abstract class EhDatabase : RoomDatabase() { abstract fun bookmarksBao(): BookmarksDao abstract fun downloadDirnameDao(): DownloadDirnameDao abstract fun downloadLabelDao(): DownloadLabelDao abstract fun downloadsDao(): DownloadsDao abstract fun filterDao(): FilterDao abstract fun historyDao(): HistoryDao abstract fun localFavoritesDao(): LocalFavoritesDao abstract fun quickSearchDao(): QuickSearchDao } fun buildMainDB(context: Context): EhDatabase { // TODO: Remove allowMainThreadQueries return Room.databaseBuilder(context, EhDatabase::class.java, "eh.db").allowMainThreadQueries() .build() } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/Filter.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "FILTER") data class Filter( @ColumnInfo(name = "MODE") var mode: Int = 0, @ColumnInfo(name = "TEXT") var text: String? = null, @ColumnInfo(name = "ENABLE") var enable: Boolean? = null, @PrimaryKey @ColumnInfo(name = "_id") var id: Long? = null, ) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/FilterDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update @Dao interface FilterDao : BasicDao { @Query("SELECT * FROM `FILTER`") override fun list(): List @Update fun update(filter: Filter) @Insert override fun insert(t: Filter): Long @Delete fun delete(filter: Filter) @Query("SELECT * FROM `FILTER` WHERE TEXT = :text AND MODE = :mode") fun load(text: String, mode: Int): Filter? } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/HistoryDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update @Dao interface HistoryDao : BasicDao { @Query("SELECT * FROM HISTORY WHERE GID = :gid") fun load(gid: Long): HistoryInfo? @Query("SELECT * FROM HISTORY ORDER BY TIME DESC") override fun list(): List @Query("SELECT * FROM HISTORY ORDER BY TIME DESC LIMIT :limit OFFSET :offset") fun list(offset: Int, limit: Int): List @Query("SELECT * FROM HISTORY ORDER BY TIME DESC") fun listLazy(): PagingSource @Update fun update(historyInfo: HistoryInfo) @Insert override fun insert(t: HistoryInfo): Long @Delete fun delete(historyInfo: HistoryInfo) @Delete fun delete(historyInfo: List) @Query("DELETE FROM HISTORY") fun deleteAll() } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/HistoryInfo.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import android.annotation.SuppressLint import androidx.room.ColumnInfo import androidx.room.Entity import com.hippo.ehviewer.client.data.BaseGalleryInfo import com.hippo.ehviewer.client.data.GalleryInfo @SuppressLint("ParcelCreator") @Entity(tableName = "HISTORY") class HistoryInfo() : BaseGalleryInfo() { @ColumnInfo(name = "TIME") var time: Long = 0 // Trick: Use MODE for favoriteSlot @ColumnInfo(name = "MODE") var favoriteSlotBackingField: Int = 0 override var favoriteSlot: Int get() = favoriteSlotBackingField - 2 set(value) { favoriteSlotBackingField = value + 2 } // Trick end constructor(galleryInfo: GalleryInfo) : this() { gid = galleryInfo.gid token = galleryInfo.token title = galleryInfo.title titleJpn = galleryInfo.titleJpn thumb = galleryInfo.thumb this.category = galleryInfo.category posted = galleryInfo.posted uploader = galleryInfo.uploader rating = galleryInfo.rating simpleTags = galleryInfo.simpleTags simpleLanguage = galleryInfo.simpleLanguage favoriteSlot = galleryInfo.favoriteSlot } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/LocalFavoriteInfo.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import android.annotation.SuppressLint import androidx.room.ColumnInfo import androidx.room.Entity import com.hippo.ehviewer.client.data.BaseGalleryInfo import com.hippo.ehviewer.client.data.GalleryInfo @SuppressLint("ParcelCreator") @Entity(tableName = "LOCAL_FAVORITES") class LocalFavoriteInfo() : BaseGalleryInfo() { @ColumnInfo(name = "TIME") var time: Long = 0 constructor(galleryInfo: GalleryInfo) : this() { gid = galleryInfo.gid token = galleryInfo.token title = galleryInfo.title titleJpn = galleryInfo.titleJpn thumb = galleryInfo.thumb this.category = galleryInfo.category posted = galleryInfo.posted uploader = galleryInfo.uploader rating = galleryInfo.rating simpleTags = galleryInfo.simpleTags simpleLanguage = galleryInfo.simpleLanguage } init { favoriteSlot = -1 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/LocalFavoritesDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query @Dao interface LocalFavoritesDao : BasicDao { @Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME DESC") override fun list(): List @Query("SELECT * FROM LOCAL_FAVORITES WHERE TITLE LIKE :title ORDER BY TIME DESC") fun list(title: String): List @Query("SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid") fun load(gid: Long): LocalFavoriteInfo? @Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)") fun contains(gid: Long): Boolean @Insert override fun insert(t: LocalFavoriteInfo): Long @Delete fun delete(localFavoriteInfo: LocalFavoriteInfo) @Query("DELETE FROM LOCAL_FAVORITES WHERE GID = :gid") fun deleteByKey(gid: Long) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/QuickSearch.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "QUICK_SEARCH") data class QuickSearch( @PrimaryKey @ColumnInfo(name = "_id") var id: Long? = null, @ColumnInfo(name = "NAME") var name: String? = null, @ColumnInfo(name = "MODE") var mode: Int = 0, @ColumnInfo(name = "CATEGORY") var category: Int = 0, @ColumnInfo(name = "KEYWORD") var keyword: String? = null, @ColumnInfo(name = "ADVANCE_SEARCH") var advanceSearch: Int = 0, @ColumnInfo(name = "MIN_RATING") var minRating: Int = 0, @ColumnInfo(name = "PAGE_FROM") var pageFrom: Int = 0, @ColumnInfo(name = "PAGE_TO") var pageTo: Int = 0, @ColumnInfo(name = "TIME") var time: Long = 0, ) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/dao/QuickSearchDao.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update @Dao interface QuickSearchDao : BasicDao { @Query("SELECT * FROM QUICK_SEARCH ORDER BY TIME ASC") override fun list(): List @Query("SELECT * FROM QUICK_SEARCH ORDER BY TIME ASC LIMIT :limit OFFSET :offset") fun list(offset: Int, limit: Int): List @Update fun update(downloadLabels: List) @Update fun update(quickSearch: QuickSearch) @Insert override fun insert(t: QuickSearch): Long @Delete fun delete(quickSearch: QuickSearch) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/download/DownloadManager.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.download import android.util.Log import android.util.SparseLongArray import androidx.collection.LongSparseArray import androidx.collection.keyIterator import androidx.core.util.size import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.dao.DownloadLabel import com.hippo.ehviewer.spider.SpiderDen import com.hippo.ehviewer.spider.SpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener import com.hippo.ehviewer.spider.readFromUniFile import com.hippo.ehviewer.spider.saveToUniFile import com.hippo.image.Image import com.hippo.yorozuya.ConcurrentPool import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.ObjectUtils import com.hippo.yorozuya.SimpleHandler import com.hippo.yorozuya.collect.LongList import java.util.LinkedList import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch object DownloadManager : OnSpiderListener { // All download info list private val mAllInfoList: LinkedList // All download info map private val mAllInfoMap: LongSparseArray // label and info list map, without default label info list private val mMap: MutableMap> // All download dirname private val mAllDownloadDirname = mutableMapOf() // All labels without default label private val mLabelList: MutableList // Store download info with default label private val mDefaultInfoList: LinkedList // Store download info wait to start private val mWaitList: LinkedList private val mSpeedReminder: SpeedReminder private val mDownloadInfoListeners: MutableList private val mNotifyTaskPool = ConcurrentPool(5) private var mDownloadListener: DownloadListener? = null private var mCurrentTask: DownloadInfo? = null private var mCurrentSpider: SpiderQueen? = null init { // Get all download dirname val allDownloadDirname = EhDB.allDownloadDirname for ((gid, dirname) in allDownloadDirname) { if (dirname != null) { mAllDownloadDirname.put(gid, dirname) } } // Get all labels val labels = EhDB.allDownloadLabelList.toMutableList() mLabelList = labels // Create list for each label val map = HashMap>() mMap = map for ((_, label1) in labels) { map[label1] = LinkedList() } // Create default for non tag mDefaultInfoList = LinkedList() // Get all info val allInfoList = EhDB.allDownloadInfo mAllInfoList = LinkedList(allInfoList) // Create all info map val allInfoMap = LongSparseArray(allInfoList.size + 10) mAllInfoMap = allInfoMap for (info in allInfoList) { // Add to all info map allInfoMap.put(info.gid, info) // Add to each label list val label = info.label var list = getInfoListForLabel(label) if (list == null) { // Can't find the label in label list list = LinkedList() map[label] = list if (!containLabel(label)) { // Add label to DB and list labels.add(EhDB.addDownloadLabel(label!!)) } } list.add(info) } mWaitList = LinkedList() mSpeedReminder = SpeedReminder() mDownloadInfoListeners = ArrayList() } fun getDownloadDirname(gid: Long): String? = mAllDownloadDirname[gid] fun putDownloadDirname(gid: Long, dirname: String) { mAllDownloadDirname.put(gid, dirname) EhDB.putDownloadDirname(gid, dirname) } fun removeDownloadDirname(gid: Long) { mAllDownloadDirname.remove(gid) EhDB.removeDownloadDirname(gid) } private fun getInfoListForLabel(label: String?): LinkedList? = if (label == null) { mDefaultInfoList } else { mMap[label] } fun containLabel(label: String?): Boolean { if (label == null) { return false } for ((_, label1) in mLabelList) { if (label == label1) { return true } } return false } fun containDownloadInfo(gid: Long): Boolean = mAllInfoMap.indexOfKey(gid) >= 0 val labelList: List get() = mLabelList val allDownloadInfoList: MutableList get() = mAllInfoList val defaultDownloadInfoList: MutableList get() = mDefaultInfoList fun getLabelDownloadInfoList(label: String?): MutableList? = mMap[label] fun getDownloadInfo(gid: Long): DownloadInfo? = mAllInfoMap[gid] fun getDownloadState(gid: Long): Int { val info = mAllInfoMap[gid] return info?.state ?: DownloadInfo.STATE_INVALID } fun addDownloadInfoListener(downloadInfoListener: DownloadInfoListener?) { mDownloadInfoListeners.add(downloadInfoListener) } fun removeDownloadInfoListener(downloadInfoListener: DownloadInfoListener?) { mDownloadInfoListeners.remove(downloadInfoListener) } fun setDownloadListener(listener: DownloadListener?) { mDownloadListener = listener } private fun ensureDownload() { if (mCurrentTask != null) { // Only one download return } // Get download from wait list if (!mWaitList.isEmpty()) { val info = mWaitList.removeFirst() val spider = SpiderQueen.obtainSpiderQueen(info, SpiderQueen.MODE_DOWNLOAD) mCurrentTask = info mCurrentSpider = spider spider.addOnSpiderListener(this) info.state = DownloadInfo.STATE_DOWNLOAD info.speed = -1 info.remaining = -1 info.total = -1 info.finished = 0 info.downloaded = 0 info.legacy = -1 // Update in DB EhDB.putDownloadInfo(info) // Start speed count mSpeedReminder.start() // Notify start downloading if (mDownloadListener != null) { mDownloadListener!!.onStart(info) } // Notify state update val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } } } fun startDownload(galleryInfo: GalleryInfo, label: String?) { if (mCurrentTask != null && mCurrentTask!!.gid == galleryInfo.gid) { // It is current task return } // Check in download list var info = mAllInfoMap[galleryInfo.gid] if (info != null) { // Get it in download list if (info.state != DownloadInfo.STATE_WAIT) { // Set state DownloadInfo.STATE_WAIT info.state = DownloadInfo.STATE_WAIT // Add to wait list mWaitList.add(info) // Update in DB EhDB.putDownloadInfo(info) // Notify state update val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } // Make sure download is running ensureDownload() } } else { // It is new download info info = DownloadInfo(galleryInfo) info.label = label info.state = DownloadInfo.STATE_WAIT info.time = System.currentTimeMillis() // Add to label download list val list = getInfoListForLabel(info.label) if (list == null) { Log.e(TAG, "Can't find download info list with label: $label") return } list.addFirst(info) // Add to all download list and map mAllInfoList.addFirst(info) mAllInfoMap.put(galleryInfo.gid, info) // Add to wait list mWaitList.add(info) // Save to EhDB.putDownloadInfo(info) // Notify for (l in mDownloadInfoListeners) { l!!.onAdd(info, list, list.size - 1) } // Make sure download is running ensureDownload() // Add it to history EhDB.putHistoryInfo(info) } } fun startRangeDownload(gidList: LongList) { var update = false for (i in 0 until gidList.size) { val gid = gidList[i] val info = mAllInfoMap[gid] if (null == info) { Log.d(TAG, "Can't get download info with gid: $gid") continue } if (info.state == DownloadInfo.STATE_NONE || info.state == DownloadInfo.STATE_FAILED || info.state == DownloadInfo.STATE_FINISH) { update = true // Set state DownloadInfo.STATE_WAIT info.state = DownloadInfo.STATE_WAIT // Add to wait list mWaitList.add(info) // Update in DB EhDB.putDownloadInfo(info) } } if (update) { // Notify Listener for (l in mDownloadInfoListeners) { l!!.onUpdateAll() } // Ensure download ensureDownload() } } fun startAllDownload() { var update = false // Start all STATE_NONE and STATE_FAILED item for (info in mAllInfoList) { if (info.state == DownloadInfo.STATE_NONE || info.state == DownloadInfo.STATE_FAILED) { update = true // Set state DownloadInfo.STATE_WAIT info.state = DownloadInfo.STATE_WAIT // Add to wait list mWaitList.add(info) // Update in DB EhDB.putDownloadInfo(info) } } if (update) { // Notify Listener for (l in mDownloadInfoListeners) { l!!.onUpdateAll() } // Ensure download ensureDownload() } } fun addDownload(downloadInfoList: List, notify: Boolean = true) { for (info in downloadInfoList) { if (containDownloadInfo(info.gid)) { // Contain return } // Ensure download state if (DownloadInfo.STATE_WAIT == info.state || DownloadInfo.STATE_DOWNLOAD == info.state ) { info.state = DownloadInfo.STATE_NONE } // Add to label download list var list = getInfoListForLabel(info.label) if (null == list) { // Can't find the label in label list list = LinkedList() mMap[info.label] = list if (!containLabel(info.label)) { // Add label to DB and list mLabelList.add(EhDB.addDownloadLabel(info.label!!)) } } list.add(info) // Sort list.sortByDateDescending() // Add to all download list and map mAllInfoList.add(info) mAllInfoMap.put(info.gid, info) // Save to EhDB.putDownloadInfo(info) } // Sort all download list mAllInfoList.sortByDateDescending() // Notify if (notify) { for (l in mDownloadInfoListeners) { l!!.onReload() } } } fun addDownloadLabel(downloadLabelList: List) { for (label in downloadLabelList) { val labelString = label.label if (!containLabel(labelString)) { mMap[labelString] = LinkedList() mLabelList.add(EhDB.addDownloadLabel(label)) } } } fun addDownload(galleryInfo: GalleryInfo, label: String?) { if (containDownloadInfo(galleryInfo.gid)) { // Contain return } // It is new download info val info = DownloadInfo(galleryInfo) info.label = label info.state = DownloadInfo.STATE_NONE info.time = System.currentTimeMillis() // Add to label download list val list = getInfoListForLabel(info.label) if (list == null) { Log.e(TAG, "Can't find download info list with label: $label") return } list.addFirst(info) // Add to all download list and map mAllInfoList.addFirst(info) mAllInfoMap.put(galleryInfo.gid, info) // Save to EhDB.putDownloadInfo(info) // Notify for (l in mDownloadInfoListeners) { l!!.onAdd(info, list, list.size - 1) } } fun stopDownload(gid: Long) { val info = stopDownloadInternal(gid) if (info != null) { // Update listener val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } // Ensure download ensureDownload() } } fun stopCurrentDownload() { val info = stopCurrentDownloadInternal() if (info != null) { // Update listener val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } // Ensure download ensureDownload() } } fun stopRangeDownload(gidList: LongList) { stopRangeDownloadInternal(gidList) // Update listener for (l in mDownloadInfoListeners) { l!!.onUpdateAll() } // Ensure download ensureDownload() } fun stopAllDownload() { // Stop all in wait list for (info in mWaitList) { info.state = DownloadInfo.STATE_NONE // Update in DB EhDB.putDownloadInfo(info) } mWaitList.clear() // Stop current stopCurrentDownloadInternal() // Notify mDownloadInfoListener for (l in mDownloadInfoListeners) { l!!.onUpdateAll() } } fun deleteDownload(gid: Long) { stopDownloadInternal(gid) val info = mAllInfoMap[gid] if (info != null) { // Remove from DB EhDB.removeDownloadInfo(info) // Remove all list and map mAllInfoList.remove(info) mAllInfoMap.remove(info.gid) // Remove label list val list = getInfoListForLabel(info.label) if (list != null) { val index = list.indexOf(info) if (index >= 0) { list.remove(info) // Update listener for (l in mDownloadInfoListeners) { l!!.onRemove(info, list, index) } } } // Ensure download ensureDownload() } } fun deleteRangeDownload(gidList: LongList) { stopRangeDownloadInternal(gidList) for (i in 0 until gidList.size) { val gid = gidList[i] val info = mAllInfoMap[gid] if (null == info) { Log.d(TAG, "Can't get download info with gid: $gid") continue } // Remove from DB EhDB.removeDownloadInfo(info) // Remove from all info map mAllInfoList.remove(info) mAllInfoMap.remove(info.gid) // Remove from label list val list = getInfoListForLabel(info.label) list?.remove(info) } // Update listener for (l in mDownloadInfoListeners) { l!!.onReload() } // Ensure download ensureDownload() } fun moveDownload(fromPosition: Int, toPosition: Int) { if (fromPosition > toPosition) { val time = mAllInfoList[toPosition].time for (i in toPosition until fromPosition) { val aTime = mAllInfoList[i].time val bTime = mAllInfoList[i + 1].time mAllInfoList[i].time = if (aTime == bTime) bTime + 1 else bTime } mAllInfoList[fromPosition].time = time EhDB.updateDownloadInfo(mAllInfoList.slice(toPosition..fromPosition)) } else { val time = mAllInfoList[fromPosition].time for (i in fromPosition until toPosition) { val aTime = mAllInfoList[i].time val bTime = mAllInfoList[i + 1].time mAllInfoList[i].time = if (aTime == bTime) bTime - 1 else bTime } mAllInfoList[toPosition].time = time EhDB.updateDownloadInfo(mAllInfoList.slice(fromPosition..toPosition)) } val label = mAllInfoList[fromPosition].label mAllInfoList.sortByDateDescending() val list = getInfoListForLabel(label)!! list.sortByDateDescending() } fun moveDownload(label: String?, fromPosition: Int, toPosition: Int) { val list = getInfoListForLabel(label) list?.let { val absFromPosition = mAllInfoList.indexOf(it[fromPosition]) val absToPosition = mAllInfoList.indexOf(it[toPosition]) moveDownload(absFromPosition, absToPosition) } } suspend fun resetAllReadingProgress() = coroutineScope { mAllInfoMap.keyIterator().forEach { gid -> launch { runCatching { resetReadingProgress(gid) }.onFailure { Log.e(TAG, "Can't write SpiderInfo", it) } } } } private fun resetReadingProgress(gid: Long) { val downloadDir = SpiderDen.getGalleryDownloadDir(gid) ?: return val file = downloadDir.findFile(SpiderQueen.SPIDER_INFO_FILENAME) ?: return val spiderInfo = readFromUniFile(file) ?: return spiderInfo.startPage = 0 spiderInfo.saveToUniFile(file) } // Update in DB // Update listener // No ensureDownload private fun stopDownloadInternal(gid: Long): DownloadInfo? { // Check current task if (mCurrentTask != null && mCurrentTask!!.gid == gid) { // Stop current return stopCurrentDownloadInternal() } val iterator = mWaitList.iterator() while (iterator.hasNext()) { val info = iterator.next() if (info.gid == gid) { // Remove from wait list iterator.remove() // Update state info.state = DownloadInfo.STATE_NONE // Update in DB EhDB.putDownloadInfo(info) return info } } return null } // Update in DB // Update mDownloadListener private fun stopCurrentDownloadInternal(): DownloadInfo? { val info = mCurrentTask val spider = mCurrentSpider // Release spider if (spider != null) { spider.removeOnSpiderListener(this@DownloadManager) SpiderQueen.releaseSpiderQueen(spider, SpiderQueen.MODE_DOWNLOAD) } mCurrentTask = null mCurrentSpider = null // Stop speed reminder mSpeedReminder.stop() if (info == null) { return null } // Update state info.state = DownloadInfo.STATE_NONE // Update in DB EhDB.putDownloadInfo(info) // Listener if (mDownloadListener != null) { mDownloadListener!!.onCancel(info) } return info } // Update in DB // Update mDownloadListener private fun stopRangeDownloadInternal(gidList: LongList) { // Two way if (gidList.size < mWaitList.size) { for (i in 0 until gidList.size) { stopDownloadInternal(gidList[i]) } } else { // Check current task if (mCurrentTask != null && gidList.contains(mCurrentTask!!.gid)) { // Stop current stopCurrentDownloadInternal() } // Check all in wait list val iterator = mWaitList.iterator() while (iterator.hasNext()) { val info = iterator.next() if (gidList.contains(info.gid)) { // Remove from wait list iterator.remove() // Update state info.state = DownloadInfo.STATE_NONE // Update in DB EhDB.putDownloadInfo(info) } } } } /** * @param label Not allow new label */ fun changeLabel(list: List, label: String?) { if (null != label && !containLabel(label)) { Log.e(TAG, "Not exits label: $label") return } val dstList: MutableList? = getInfoListForLabel(label) if (dstList == null) { Log.e(TAG, "Can't find label with label: $label") return } for (info in list) { if (ObjectUtils.equal(info.label, label)) { continue } val srcList: MutableList? = getInfoListForLabel(info.label) if (srcList == null) { Log.e(TAG, "Can't find label with label: " + info.label) continue } srcList.remove(info) dstList.add(info) info.label = label dstList.sortByDateDescending() // Save to DB EhDB.putDownloadInfo(info) } for (l in mDownloadInfoListeners) { l!!.onReload() } } fun addLabel(label: String?) { if (label == null || containLabel(label)) { return } mLabelList.add(EhDB.addDownloadLabel(label)) mMap[label] = LinkedList() for (l in mDownloadInfoListeners) { l!!.onUpdateLabels() } } fun moveLabel(fromPosition: Int, toPosition: Int) { val item = mLabelList.removeAt(fromPosition) mLabelList.add(toPosition, item) EhDB.moveDownloadLabel(fromPosition, toPosition) for (l in mDownloadInfoListeners) { l!!.onUpdateLabels() } } fun renameLabel(from: String, to: String) { // Find in label list var found = false for (raw in mLabelList) { if (from == raw.label) { found = true raw.label = to // Update in DB EhDB.updateDownloadLabel(raw) break } } if (!found) { return } val list = mMap.remove(from) ?: return // Update info label for (info in list) { info.label = to // Update in DB EhDB.putDownloadInfo(info) } // Put list back with new label mMap[to] = list // Notify listener for (l in mDownloadInfoListeners) { l!!.onRenameLabel(from, to) } } fun deleteLabel(label: String) { // Find in label list and remove var found = false val iterator = mLabelList.iterator() while (iterator.hasNext()) { val raw = iterator.next() if (label == raw.label) { found = true iterator.remove() EhDB.removeDownloadLabel(raw) break } } if (!found) { return } val list = mMap.remove(label) ?: return // Update info label for (info in list) { info.label = null // Update in DB EhDB.putDownloadInfo(info) mDefaultInfoList.add(info) } // Sort mDefaultInfoList.sortByDateDescending() // Notify listener for (l in mDownloadInfoListeners) { l!!.onChange() } } val isIdle: Boolean get() = mCurrentTask == null && mWaitList.isEmpty() override fun onGetPages(pages: Int) { var task = mNotifyTaskPool.pop() if (task == null) { task = NotifyTask() } task.setOnGetPagesData(pages) SimpleHandler.getInstance().post(task) } override fun onGet509(index: Int) { var task = mNotifyTaskPool.pop() if (task == null) { task = NotifyTask() } task.setOnGet509Data(index) SimpleHandler.getInstance().post(task) } override fun onPageDownload( index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int, ) { var task = mNotifyTaskPool.pop() if (task == null) { task = NotifyTask() } task.setOnPageDownloadData(index, contentLength, receivedSize, bytesRead) SimpleHandler.getInstance().post(task) } override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) { var task = mNotifyTaskPool.pop() if (task == null) { task = NotifyTask() } task.setOnPageSuccessData(index, finished, downloaded, total) SimpleHandler.getInstance().post(task) } override fun onPageFailure( index: Int, error: String?, finished: Int, downloaded: Int, total: Int, ) { var task = mNotifyTaskPool.pop() if (task == null) { task = NotifyTask() } task.setOnPageFailureDate(index, error, finished, downloaded, total) SimpleHandler.getInstance().post(task) } override fun onFinish(finished: Int, downloaded: Int, total: Int) { var task = mNotifyTaskPool.pop() if (task == null) { task = NotifyTask() } task.setOnFinishDate(finished, downloaded, total) SimpleHandler.getInstance().post(task) } override fun onGetImageSuccess(index: Int, image: Image?) { // Ignore } override fun onGetImageFailure(index: Int, error: String?) { // Ignore } interface DownloadInfoListener { /** * Add the special info to the special position */ fun onAdd(info: DownloadInfo, list: List, position: Int) /** * The special info is changed */ fun onUpdate(info: DownloadInfo, list: List) /** * Maybe all data is changed, but size is the same */ fun onUpdateAll() /** * Maybe all data is changed, maybe list is changed */ fun onReload() /** * The list is gone, use default list please */ fun onChange() /** * Rename label */ fun onRenameLabel(from: String, to: String) /** * Remove the special info from the special position */ fun onRemove(info: DownloadInfo, list: List, position: Int) fun onUpdateLabels() } interface DownloadListener { /** * Get 509 error */ fun onGet509() /** * Start download */ fun onStart(info: DownloadInfo) /** * Update download speed */ fun onDownload(info: DownloadInfo) /** * Update page downloaded */ fun onGetPage(info: DownloadInfo) /** * Download done */ fun onFinish(info: DownloadInfo) /** * Download done */ fun onCancel(info: DownloadInfo) } private class NotifyTask : Runnable { private var mType = 0 private var mPages = 0 private var mIndex = 0 private var mContentLength: Long = 0 private var mReceivedSize: Long = 0 private var mBytesRead = 0 private var mError: String? = null private var mFinished = 0 private var mDownloaded = 0 private var mTotal = 0 fun setOnGetPagesData(pages: Int) { mType = TYPE_ON_GET_PAGES mPages = pages } fun setOnGet509Data(index: Int) { mType = TYPE_ON_GET_509 mIndex = index } fun setOnPageDownloadData( index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int, ) { mType = TYPE_ON_PAGE_DOWNLOAD mIndex = index mContentLength = contentLength mReceivedSize = receivedSize mBytesRead = bytesRead } fun setOnPageSuccessData(index: Int, finished: Int, downloaded: Int, total: Int) { mType = TYPE_ON_PAGE_SUCCESS mIndex = index mFinished = finished mDownloaded = downloaded mTotal = total } fun setOnPageFailureDate( index: Int, error: String?, finished: Int, downloaded: Int, total: Int, ) { mType = TYPE_ON_PAGE_FAILURE mIndex = index mError = error mFinished = finished mDownloaded = downloaded mTotal = total } fun setOnFinishDate(finished: Int, downloaded: Int, total: Int) { mType = TYPE_ON_FINISH mFinished = finished mDownloaded = downloaded mTotal = total } override fun run() { when (mType) { TYPE_ON_GET_PAGES -> { val info = mCurrentTask if (info == null) { Log.e(TAG, "Current task is null, but it should not be") } else { info.total = mPages val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } } } TYPE_ON_GET_509 -> { if (mDownloadListener != null) { mDownloadListener!!.onGet509() } } TYPE_ON_PAGE_DOWNLOAD -> mSpeedReminder.onDownload( mIndex, mContentLength, mReceivedSize, mBytesRead, ) TYPE_ON_PAGE_SUCCESS -> { mSpeedReminder.onDone(mIndex) val info = mCurrentTask if (info == null) { Log.e(TAG, "Current task is null, but it should not be") } else { info.finished = mFinished info.downloaded = mDownloaded info.total = mTotal if (mDownloadListener != null) { mDownloadListener!!.onGetPage(info) } val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } } } TYPE_ON_PAGE_FAILURE -> { mSpeedReminder.onDone(mIndex) val info = mCurrentTask if (info == null) { Log.e(TAG, "Current task is null, but it should not be") } else { info.finished = mFinished info.downloaded = mDownloaded info.total = mTotal val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } } } TYPE_ON_FINISH -> { mSpeedReminder.onFinish() // Download done val info = mCurrentTask mCurrentTask = null val spider = mCurrentSpider mCurrentSpider = null // Release spider if (spider != null) { spider.removeOnSpiderListener(DownloadManager) SpiderQueen.releaseSpiderQueen(spider, SpiderQueen.MODE_DOWNLOAD) } // Check null if (info == null || spider == null) { Log.e(TAG, "Current stuff is null, but it should not be") } else { // Stop speed count mSpeedReminder.stop() // Update state info.finished = mFinished info.downloaded = mDownloaded info.total = mTotal info.legacy = mTotal - mFinished if (info.legacy == 0) { info.state = DownloadInfo.STATE_FINISH } else { info.state = DownloadInfo.STATE_FAILED } // Update in DB EhDB.putDownloadInfo(info) // Notify if (mDownloadListener != null) { mDownloadListener!!.onFinish(info) } val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } // Start next download ensureDownload() } } } mNotifyTaskPool.push(this) } } internal class SpeedReminder : Runnable { private val mContentLengthMap = SparseLongArray() private val mReceivedSizeMap = SparseLongArray() private var mStop = true private var mBytesRead: Long = 0 private var oldSpeed: Long = -1 fun start() { if (mStop) { mStop = false SimpleHandler.getInstance().post(this) } } fun stop() { if (!mStop) { mStop = true mBytesRead = 0 oldSpeed = -1 mContentLengthMap.clear() mReceivedSizeMap.clear() SimpleHandler.getInstance().removeCallbacks(this) } } fun onDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) { mContentLengthMap.put(index, contentLength) mReceivedSizeMap.put(index, receivedSize) mBytesRead += bytesRead.toLong() } fun onDone(index: Int) { mContentLengthMap.delete(index) mReceivedSizeMap.delete(index) } fun onFinish() { mContentLengthMap.clear() mReceivedSizeMap.clear() } override fun run() { val info = mCurrentTask if (info != null) { var newSpeed = mBytesRead / 2 if (oldSpeed != -1L) { newSpeed = MathUtils.lerp(oldSpeed.toFloat(), newSpeed.toFloat(), 0.75f).toLong() } oldSpeed = newSpeed info.speed = newSpeed // Calculate remaining if (info.total <= 0) { info.remaining = -1 } else if (newSpeed == 0L) { info.remaining = 300L * 24L * 60L * 60L * 1000L // 300 days } else { var downloadingCount = 0 var downloadingContentLengthSum: Long = 0 var totalSize: Long = 0 for (i in 0 until maxOf(mContentLengthMap.size, mReceivedSizeMap.size)) { val contentLength = mContentLengthMap.valueAt(i) val receivedSize = mReceivedSizeMap.valueAt(i) downloadingCount++ downloadingContentLengthSum += contentLength totalSize += contentLength - receivedSize } if (downloadingCount != 0) { totalSize += downloadingContentLengthSum * (info.total - info.downloaded - downloadingCount) / downloadingCount info.remaining = totalSize / newSpeed * 1000 } } if (mDownloadListener != null) { mDownloadListener!!.onDownload(info) } val list: List? = getInfoListForLabel(info.label) if (list != null) { for (l in mDownloadInfoListeners) { l!!.onUpdate(info, list) } } } mBytesRead = 0 if (!mStop) { SimpleHandler.getInstance().postDelayed(this, 2000) } } } private val TAG = DownloadManager::class.java.simpleName private const val TYPE_ON_GET_PAGES = 0 private const val TYPE_ON_GET_509 = 1 private const val TYPE_ON_PAGE_DOWNLOAD = 2 private const val TYPE_ON_PAGE_SUCCESS = 3 private const val TYPE_ON_PAGE_FAILURE = 4 private const val TYPE_ON_FINISH = 5 private fun MutableList.sortByDateDescending() { sortByDescending { it.time } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/download/DownloadService.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.download import android.Manifest import android.app.PendingIntent import android.app.Service import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.os.IBinder import android.os.SystemClock import android.util.Log import androidx.annotation.IntDef import androidx.collection.LongSparseArray import androidx.core.app.ActivityCompat import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.ui.MainActivity import com.hippo.ehviewer.ui.scene.DownloadsScene import com.hippo.scene.StageActivity import com.hippo.util.ReadableTime import com.hippo.util.getParcelableExtraCompat import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.SimpleHandler import com.hippo.yorozuya.collect.LongList class DownloadService : Service(), DownloadManager.DownloadListener { private var mNotifyManager: NotificationManagerCompat? = null private var mDownloadManager: DownloadManager? = null private var mDownloadingBuilder: NotificationCompat.Builder? = null private var mDownloadedBuilder: NotificationCompat.Builder? = null private var m509dBuilder: NotificationCompat.Builder? = null private var mDownloadingDelay: NotificationDelay? = null private var mDownloadedDelay: NotificationDelay? = null private var m509Delay: NotificationDelay? = null private var mChannelID: String? = null override fun onCreate() { super.onCreate() mChannelID = "$packageName.download" mNotifyManager = NotificationManagerCompat.from(this) mNotifyManager!!.createNotificationChannel( NotificationChannelCompat.Builder( mChannelID!!, NotificationManagerCompat.IMPORTANCE_LOW, ) .setName(getString(R.string.download_service)) .build(), ) mDownloadManager = DownloadManager mDownloadManager!!.setDownloadListener(this) ensureDownloadingBuilder() mDownloadingBuilder!!.setContentTitle(getString(R.string.download_service)) .setContentText(null) .setSubText(null) .setProgress(0, 0, true) startForeground(ID_DOWNLOADING, mDownloadingBuilder!!.build()) } override fun onDestroy() { super.onDestroy() mNotifyManager = null if (mDownloadManager != null) { mDownloadManager!!.setDownloadListener(null) mDownloadManager = null } mDownloadingBuilder = null mDownloadedBuilder = null m509dBuilder = null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { handleIntent(intent) return START_STICKY } private fun handleIntent(intent: Intent?) { var action: String? = null intent?.action?.let { action = it } if (ACTION_START == action) { val gi = intent!!.getParcelableExtraCompat(KEY_GALLERY_INFO) val label = intent.getStringExtra(KEY_LABEL) if (gi != null && mDownloadManager != null) { mDownloadManager!!.startDownload(gi, label) } } else if (ACTION_START_RANGE == action) { val gidList = intent!!.getParcelableExtraCompat(KEY_GID_LIST) if (gidList != null && mDownloadManager != null) { mDownloadManager!!.startRangeDownload(gidList) } } else if (ACTION_START_ALL == action) { if (mDownloadManager != null) { mDownloadManager!!.startAllDownload() } } else if (ACTION_STOP == action) { val gid = intent!!.getLongExtra(KEY_GID, -1) if (gid != -1L && mDownloadManager != null) { mDownloadManager!!.stopDownload(gid) } } else if (ACTION_STOP_CURRENT == action) { if (mDownloadManager != null) { mDownloadManager!!.stopCurrentDownload() } } else if (ACTION_STOP_RANGE == action) { val gidList = intent!!.getParcelableExtraCompat(KEY_GID_LIST) if (gidList != null && mDownloadManager != null) { mDownloadManager!!.stopRangeDownload(gidList) } } else if (ACTION_STOP_ALL == action) { if (mDownloadManager != null) { mDownloadManager!!.stopAllDownload() } } else if (ACTION_DELETE == action) { val gid = intent!!.getLongExtra(KEY_GID, -1) if (gid != -1L && mDownloadManager != null) { mDownloadManager!!.deleteDownload(gid) } } else if (ACTION_DELETE_RANGE == action) { val gidList = intent!!.getParcelableExtraCompat(KEY_GID_LIST) if (gidList != null && mDownloadManager != null) { mDownloadManager!!.deleteRangeDownload(gidList) } } else if (ACTION_CLEAR == action) { clear() } checkStopSelf() } override fun onBind(intent: Intent): IBinder? = throw IllegalStateException("No bindService") private fun ensureDownloadingBuilder() { if (mDownloadingBuilder != null) { return } val stopAllIntent = Intent(this, DownloadService::class.java) stopAllIntent.action = ACTION_STOP_ALL val piStopAll = PendingIntent.getService(this, 0, stopAllIntent, PendingIntent.FLAG_IMMUTABLE) mDownloadingBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!) .setSmallIcon(android.R.drawable.stat_sys_download) .setOngoing(true) .setAutoCancel(false) .setCategory(NotificationCompat.CATEGORY_PROGRESS) .addAction( R.drawable.v_pause_x24, getString(R.string.stat_download_action_stop_all), piStopAll, ) .setShowWhen(false) .setChannelId(mChannelID!!) mDownloadingDelay = NotificationDelay(this, mNotifyManager!!, mDownloadingBuilder!!, ID_DOWNLOADING) } private fun ensureDownloadedBuilder() { if (mDownloadedBuilder != null) { return } val clearIntent = Intent(this, DownloadService::class.java) clearIntent.action = ACTION_CLEAR val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE) val bundle = Bundle() bundle.putString(DownloadsScene.KEY_ACTION, DownloadsScene.ACTION_CLEAR_DOWNLOAD_SERVICE) val activityIntent = Intent(this, MainActivity::class.java) activityIntent.action = StageActivity.ACTION_START_SCENE activityIntent.putExtra(StageActivity.KEY_SCENE_NAME, DownloadsScene::class.java.name) activityIntent.putExtra(StageActivity.KEY_SCENE_ARGS, bundle) val piActivity = PendingIntent.getActivity( this@DownloadService, 0, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) mDownloadedBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!) .setSmallIcon(android.R.drawable.stat_sys_download_done) .setContentTitle(getString(R.string.stat_download_done_title)) .setDeleteIntent(piClear) .setOngoing(false) .setAutoCancel(true) .setContentIntent(piActivity) mDownloadedDelay = NotificationDelay(this, mNotifyManager!!, mDownloadedBuilder!!, ID_DOWNLOADED) } private fun ensure509Builder() { if (m509dBuilder != null) { return } m509dBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!) .setSmallIcon(R.drawable.ic_baseline_warning_24) .setContentText(getString(R.string.stat_509_alert_title)) .setContentText(getString(R.string.stat_509_alert_text)) .setStyle( NotificationCompat.BigTextStyle().bigText(getString(R.string.stat_509_alert_text)), ) .setAutoCancel(true) .setOngoing(false) .setCategory(NotificationCompat.CATEGORY_ERROR) m509Delay = NotificationDelay(this, mNotifyManager!!, m509dBuilder!!, ID_509) } override fun onGet509() { if (mDownloadManager != null) { mDownloadManager!!.stopAllDownload() } if (mNotifyManager == null) { return } ensure509Builder() m509dBuilder!!.setWhen(System.currentTimeMillis()) m509Delay!!.show() } override fun onStart(info: DownloadInfo) { if (mNotifyManager == null) { return } ensureDownloadingBuilder() val bundle = Bundle() bundle.putLong(DownloadsScene.KEY_GID, info.gid) val activityIntent = Intent(this, MainActivity::class.java) activityIntent.action = StageActivity.ACTION_START_SCENE activityIntent.putExtra(StageActivity.KEY_SCENE_NAME, DownloadsScene::class.java.name) activityIntent.putExtra(StageActivity.KEY_SCENE_ARGS, bundle) val piActivity = PendingIntent.getActivity( this@DownloadService, 0, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info)) .setContentText(null) .setSubText(null) .setProgress(0, 0, true) .setContentIntent(piActivity) mDownloadingDelay!!.startForeground() } private fun onUpdate(info: DownloadInfo) { if (mNotifyManager == null) { return } ensureDownloadingBuilder() var speed = info.speed if (speed < 0) { speed = 0 } var text = FileUtils.humanReadableByteCount(speed, false) + "/s" val remaining = info.remaining text = if (remaining >= 0) { getString( R.string.download_speed_text_2, text, ReadableTime.getShortTimeInterval(remaining), ) } else { getString(R.string.download_speed_text, text) } mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info)) .setContentText(text) .setStyle(NotificationCompat.BigTextStyle().bigText(text)) .setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total) .setProgress(info.total, info.finished, false) mDownloadingDelay!!.startForeground() } override fun onDownload(info: DownloadInfo) { onUpdate(info) } override fun onGetPage(info: DownloadInfo) { onUpdate(info) } override fun onFinish(info: DownloadInfo) { if (mNotifyManager == null) { return } if (null != mDownloadingDelay) { mDownloadingDelay!!.cancel() } ensureDownloadedBuilder() val finish = info.state == DownloadInfo.STATE_FINISH val gid = info.gid val index = sItemStateArray.indexOfKey(gid) if (index < 0) { // Not contain sItemStateArray.put(gid, finish) sItemTitleArray.put(gid, EhUtils.getSuitableTitle(info)) sDownloadedCount++ if (finish) { sFinishedCount++ } else { sFailedCount++ } } else { // Contain val oldFinish = sItemStateArray.valueAt(index) sItemStateArray.put(gid, finish) sItemTitleArray.put(gid, EhUtils.getSuitableTitle(info)) if (oldFinish && !finish) { sFinishedCount-- sFailedCount++ } else if (!oldFinish && finish) { sFinishedCount++ sFailedCount-- } } val text: String val needStyle: Boolean if (sFinishedCount != 0 && sFailedCount == 0) { if (sFinishedCount == 1) { text = if (sItemTitleArray.size() >= 1) { getString( R.string.stat_download_done_line_succeeded, sItemTitleArray.valueAt(0), ) } else { Log.d("TAG", "WTF, sItemTitleArray is null") getString(R.string.error_unknown) } needStyle = false } else { text = getString(R.string.stat_download_done_text_succeeded, sFinishedCount) needStyle = true } } else if (sFinishedCount == 0 && sFailedCount != 0) { if (sFailedCount == 1) { text = if (sItemTitleArray.size() >= 1) { getString( R.string.stat_download_done_line_failed, sItemTitleArray.valueAt(0), ) } else { Log.d("TAG", "WTF, sItemTitleArray is null") getString(R.string.error_unknown) } needStyle = false } else { text = getString(R.string.stat_download_done_text_failed, sFailedCount) needStyle = true } } else { text = getString(R.string.stat_download_done_text_mix, sFinishedCount, sFailedCount) needStyle = true } val style: NotificationCompat.InboxStyle? if (needStyle) { style = NotificationCompat.InboxStyle() style.setBigContentTitle(getString(R.string.stat_download_done_title)) val stateArray = sItemStateArray var i = 0 val n = stateArray.size() while (i < n) { val id = stateArray.keyAt(i) val fin = stateArray.valueAt(i) val title = sItemTitleArray[id] if (title == null) { i++ continue } style.addLine( getString( if (fin) R.string.stat_download_done_line_succeeded else R.string.stat_download_done_line_failed, title, ), ) i++ } } else { style = null } mDownloadedBuilder!!.setContentText(text) .setStyle(style) .setWhen(System.currentTimeMillis()) .setNumber(sDownloadedCount) mDownloadedDelay!!.show() checkStopSelf() } override fun onCancel(info: DownloadInfo) { if (mNotifyManager == null) { return } if (null != mDownloadingDelay) { mDownloadingDelay!!.cancel() } checkStopSelf() } private fun checkStopSelf() { if (mDownloadManager == null || mDownloadManager!!.isIdle) { ServiceCompat.stopForeground(this@DownloadService, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() } } private class NotificationDelay( private var mService: Service, private val mNotifyManager: NotificationManagerCompat, private val mBuilder: NotificationCompat.Builder, private val mId: Int, ) : Runnable { private var mLastTime: Long = 0 private var mPosted = false // false for show, true for cancel @Ops private var mOps = 0 fun show() { if (mPosted) { mOps = OPS_NOTIFY } else { val now = SystemClock.currentThreadTimeMillis() if (now - mLastTime > DELAY) { // Wait long enough, do it now if (ActivityCompat.checkSelfPermission( mService, Manifest.permission.POST_NOTIFICATIONS, ) != PackageManager.PERMISSION_GRANTED ) { return } mNotifyManager.notify(mId, mBuilder.build()) } else { // Too quick, post delay mOps = OPS_NOTIFY mPosted = true SimpleHandler.getInstance().postDelayed(this, DELAY) } mLastTime = now } } fun cancel() { if (mPosted) { mOps = OPS_CANCEL } else { val now = SystemClock.currentThreadTimeMillis() if (now - mLastTime > DELAY) { // Wait long enough, do it now mNotifyManager.cancel(mId) } else { // Too quick, post delay mOps = OPS_CANCEL mPosted = true SimpleHandler.getInstance().postDelayed(this, DELAY) } } } fun startForeground() { if (mPosted) { mOps = OPS_START_FOREGROUND } else { val now = SystemClock.currentThreadTimeMillis() if (now - mLastTime > DELAY) { // Wait long enough, do it now mService.startForeground(mId, mBuilder.build()) } else { // Too quick, post delay mOps = OPS_START_FOREGROUND mPosted = true SimpleHandler.getInstance().postDelayed(this, DELAY) } } } override fun run() { mPosted = false when (mOps) { OPS_NOTIFY -> { if (ActivityCompat.checkSelfPermission( mService, Manifest.permission.POST_NOTIFICATIONS, ) != PackageManager.PERMISSION_GRANTED ) { return } mNotifyManager.notify(mId, mBuilder.build()) } OPS_CANCEL -> mNotifyManager.cancel(mId) OPS_START_FOREGROUND -> mService.startForeground(mId, mBuilder.build()) } } @IntDef(OPS_NOTIFY, OPS_CANCEL, OPS_START_FOREGROUND) @Retention(AnnotationRetention.SOURCE) private annotation class Ops companion object { private const val OPS_NOTIFY = 0 private const val OPS_CANCEL = 1 private const val OPS_START_FOREGROUND = 2 private const val DELAY: Long = 1000 // 1s } } companion object { const val ACTION_START = "start" const val ACTION_START_RANGE = "start_range" const val ACTION_START_ALL = "start_all" const val ACTION_STOP = "stop" const val ACTION_STOP_RANGE = "stop_range" const val ACTION_STOP_CURRENT = "stop_current" const val ACTION_STOP_ALL = "stop_all" const val ACTION_DELETE = "delete" const val ACTION_DELETE_RANGE = "delete_range" const val ACTION_CLEAR = "clear" const val KEY_GALLERY_INFO = "gallery_info" const val KEY_LABEL = "label" const val KEY_GID = "gid" const val KEY_GID_LIST = "gid_list" private const val ID_DOWNLOADING = 1 private const val ID_DOWNLOADED = 2 private const val ID_509 = 3 private val sItemStateArray = LongSparseArray() private val sItemTitleArray = LongSparseArray() private var sFailedCount = 0 private var sFinishedCount = 0 private var sDownloadedCount = 0 fun clear() { sFailedCount = 0 sFinishedCount = 0 sDownloadedCount = 0 sItemStateArray.clear() sItemTitleArray.clear() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/gallery/ArchiveGalleryProvider.kt ================================================ /* * Copyright 2023 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.gallery import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Log import com.hippo.ehviewer.GetText import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.jni.closeArchive import com.hippo.ehviewer.jni.extractToByteBuffer import com.hippo.ehviewer.jni.extractToFd import com.hippo.ehviewer.jni.getFilename import com.hippo.ehviewer.jni.needPassword import com.hippo.ehviewer.jni.openArchive import com.hippo.ehviewer.jni.providePassword import com.hippo.ehviewer.jni.releaseByteBuffer import com.hippo.image.ByteBufferSource import com.hippo.image.Image import com.hippo.unifile.UniFile import com.hippo.yorozuya.FileUtils import java.nio.ByteBuffer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit class ArchiveGalleryProvider(context: Context, private val uri: Uri, passwdFlow: Flow) : GalleryProvider2(), CoroutineScope { override val coroutineContext = Dispatchers.IO + Job() private lateinit var pfd: ParcelFileDescriptor private val hostJob = launch(start = CoroutineStart.LAZY) { Log.d(DEBUG_TAG, "Open archive $uri") pfd = context.contentResolver.openFileDescriptor(uri, "r")!! size = openArchive(pfd.fd, pfd.statSize, true) if (size == 0) { return@launch } if (needPassword()) { Settings.archivePasswds?.forEach { if (providePassword(it)) return@launch } passwdFlow.collect { if (providePassword(it)) { Settings.putPasswdToArchivePasswds(it) currentCoroutineContext().cancel() } } } } override var size = -1 override fun start() { hostJob.start() } override fun stop() { cancel() closeArchive() pfd.close() Log.d(DEBUG_TAG, "Close archive $uri successfully!") super.stop() } private val mJobMap = hashMapOf() private val mWorkerMutex by lazy { (0 until size).map { Mutex() } } private val mSemaphore = Semaphore(4) override fun onRequest(index: Int) { notifyPageWait(index) synchronized(mJobMap) { val current = mJobMap[index] if (current?.isActive != true) { mJobMap[index] = launch { mWorkerMutex[index].withLock { mSemaphore.withPermit { doRealWork(index) } } } } } } private suspend fun doRealWork(index: Int) { val buffer = extractToByteBuffer(index) buffer ?: return check(buffer.isDirect) val src = object : ByteBufferSource { override val source: ByteBuffer = buffer override fun close() { releaseByteBuffer(buffer) } } runCatching { currentCoroutineContext().ensureActive() }.onFailure { src.close() throw it } val image = Image.decode(src) ?: return notifyPageFailed(index, GetText.getString(R.string.error_decoding_failed)) runCatching { currentCoroutineContext().ensureActive() }.onFailure { image.recycle() throw it } notifyPageSucceed(index, image) } override fun onForceRequest(index: Int) { onRequest(index) } override suspend fun awaitReady(): Boolean { hostJob.join() return size != -1 } override val isReady: Boolean get() = size != -1 override fun onCancelRequest(index: Int) { mJobMap[index]?.cancel() } override fun getImageFilename(index: Int): String = FileUtils.getNameFromFilename(getImageFilenameWithExtension(index)) override fun getImageFilenameWithExtension(index: Int): String = FileUtils.sanitizeFilename(getFilename(index)) override fun save(index: Int, file: UniFile) = runCatching { file.openFileDescriptor("w").use { extractToFd(index, it.fd) } }.getOrElse { it.printStackTrace() false } override fun save(index: Int, dir: UniFile, filename: String): UniFile { val extension = FileUtils.getExtensionFromFilename(getImageFilenameWithExtension(index)) val dst = dir.subFile(if (null != extension) "$filename.$extension" else filename) save(index, dst!!) return dst } override suspend fun downloadOriginal(index: Int, dir: UniFile, filename: String): UniFile? = null override fun preloadPages(pages: List, pair: Pair) {} } private const val DEBUG_TAG = "ArchiveGalleryProvider" ================================================ FILE: app/src/main/java/com/hippo/ehviewer/gallery/EhGalleryProvider.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.gallery import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.spider.SpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener import com.hippo.image.Image import com.hippo.unifile.UniFile import com.hippo.yorozuya.SimpleHandler import java.util.Locale class EhGalleryProvider(private val mGalleryInfo: GalleryInfo) : GalleryProvider2(), OnSpiderListener { private lateinit var mSpiderQueen: SpiderQueen override fun start() { mSpiderQueen = obtainSpiderQueen(mGalleryInfo, SpiderQueen.MODE_READ) mSpiderQueen.addOnSpiderListener(this) } override fun stop() { super.stop() mSpiderQueen.removeOnSpiderListener(this) // Activity recreate may called, so wait 3000s SimpleHandler.getInstance().postDelayed(ReleaseTask(mSpiderQueen), 3000) } override val startPage get() = mSpiderQueen.startPage override fun getImageFilename(index: Int): String = String.format( Locale.US, "%d-%s-%08d", mGalleryInfo.gid, mGalleryInfo.token, index + 1, ) override fun getImageFilenameWithExtension(index: Int): String { val extension = mSpiderQueen.getExtension(index) if (extension != null) { return String.format( Locale.US, "%d-%s-%08d.%s", mGalleryInfo.gid, mGalleryInfo.token, index + 1, extension, ) } return String.format( Locale.US, "%d-%s-%08d", mGalleryInfo.gid, mGalleryInfo.token, index + 1, ) } override fun save(index: Int, file: UniFile): Boolean = mSpiderQueen.save(index, file) override fun save(index: Int, dir: UniFile, filename: String): UniFile? = mSpiderQueen.save(index, dir, filename) override suspend fun downloadOriginal(index: Int, dir: UniFile, filename: String): UniFile? = mSpiderQueen.downloadOriginal(index, dir, filename) override fun putStartPage(page: Int) { mSpiderQueen.putStartPage(page) } override val size: Int get() = mSpiderQueen.size override fun onRequest(index: Int) { notifyPageWait(index) mSpiderQueen.request(index) } override fun onForceRequest(index: Int) { notifyPageWait(index) mSpiderQueen.forceRequest(index) } override suspend fun awaitReady(): Boolean = mSpiderQueen.awaitReady() override val isReady: Boolean get() = mSpiderQueen.isReady override fun onCancelRequest(index: Int) { mSpiderQueen.cancelRequest(index) } override fun onGetPages(pages: Int) {} override fun onGet509(index: Int) {} override fun onPageDownload( index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int, ) { if (contentLength > 0) { notifyPagePercent(index, receivedSize.toFloat() / contentLength) } } override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) {} override fun onPageFailure( index: Int, error: String?, finished: Int, downloaded: Int, total: Int, ) { notifyPageFailed(index, error) } override fun onFinish(finished: Int, downloaded: Int, total: Int) {} override fun onGetImageSuccess(index: Int, image: Image?) { notifyPageSucceed(index, image!!) } override fun onGetImageFailure(index: Int, error: String?) { notifyPageFailed(index, error) } override fun preloadPages(pages: List, pair: Pair) { mSpiderQueen.preloadPages(pages, pair) } private class ReleaseTask(private var mSpiderQueen: SpiderQueen?) : Runnable { override fun run() { mSpiderQueen?.let { releaseSpiderQueen(it, SpiderQueen.MODE_READ) } mSpiderQueen = null } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/gallery/GalleryProvider2.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.gallery import com.hippo.glgallery.GalleryProvider import com.hippo.unifile.UniFile abstract class GalleryProvider2 : GalleryProvider() { open val startPage: Int get() = 0 open fun putStartPage(page: Int) {} /** * @return without extension */ abstract fun getImageFilename(index: Int): String /** * @return with extension */ abstract fun getImageFilenameWithExtension(index: Int): String abstract fun save(index: Int, file: UniFile): Boolean /** * @param filename without extension */ abstract fun save(index: Int, dir: UniFile, filename: String): UniFile? abstract suspend fun downloadOriginal(index: Int, dir: UniFile, filename: String): UniFile? companion object { // With dot val SUPPORT_IMAGE_EXTENSIONS = arrayOf( ".jpg", ".png", ".gif", ".webp", ) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/jni/Archive.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ @file:Suppress("unused") package com.hippo.ehviewer.jni import java.nio.ByteBuffer external fun releaseByteBuffer(buffer: ByteBuffer) external fun openArchive(fd: Int, size: Long, sortEntries: Boolean): Int external fun extractToByteBuffer(index: Int): ByteBuffer? external fun extractToFd(index: Int, fd: Int): Boolean external fun getFilename(index: Int): String external fun needPassword(): Boolean external fun providePassword(str: String): Boolean external fun closeArchive() ================================================ FILE: app/src/main/java/com/hippo/ehviewer/jni/GifUtils.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ @file:Suppress("unused") package com.hippo.ehviewer.jni import java.nio.ByteBuffer external fun isGif(fd: Int): Boolean external fun rewriteGifSource(buffer: ByteBuffer) external fun mmap(fd: Int): ByteBuffer? external fun munmap(buffer: ByteBuffer) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/jni/Hash.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ @file:Suppress("unused") package com.hippo.ehviewer.jni external fun sha1(fd: Int): String ================================================ FILE: app/src/main/java/com/hippo/ehviewer/jni/Image.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ @file:Suppress("unused") package com.hippo.ehviewer.jni import android.graphics.Bitmap external fun nativeTexImage( bitmap: Bitmap, init: Boolean, offsetX: Int, offsetY: Int, width: Int, height: Int, ) ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/AccountPreference.kt ================================================ /* * Copyright 2018 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.preference import android.content.Context import android.content.DialogInterface import android.content.Intent import android.util.AttributeSet import android.view.View import android.view.WindowManager import android.widget.Button import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.ui.MainActivity import com.hippo.ehviewer.ui.SettingsActivity import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.preference.DialogPreference import com.hippo.util.ReadableTime import com.hippo.util.addTextToClipboard import com.hippo.util.launchIO import com.hippo.util.withUIContext import kotlinx.coroutines.delay import okhttp3.HttpUrl.Companion.toHttpUrl class AccountPreference( context: Context, attrs: AttributeSet? = null, ) : DialogPreference(context, attrs), View.OnClickListener { private val mActivity = context as SettingsActivity private var mCookie: String? = null private var mMessage: String? = context.getString(R.string.settings_eh_account_name_tourist) private lateinit var mDialog: AlertDialog private lateinit var refreshButton: Button private fun updateMessage() { if (EhCookieStore.hasSignedIn()) { var ipbMemberId: String? = null var ipbPassHash: String? = null var igneous: String? = null var igneousExpire = 0L EhCookieStore.getCookies(EhUrl.HOST_EX.toHttpUrl()).forEach { when (it.name) { EhCookieStore.KEY_IPB_MEMBER_ID -> ipbMemberId = it.value EhCookieStore.KEY_IPB_PASS_HASH -> ipbPassHash = it.value EhCookieStore.KEY_IGNEOUS -> { igneous = it.value igneousExpire = it.expiresAt } } } mCookie = EhCookieStore.KEY_IPB_MEMBER_ID + ": " + ipbMemberId + "\n" + EhCookieStore.KEY_IPB_PASS_HASH + ": " + ipbPassHash igneous?.let { mCookie += "\n" + EhCookieStore.KEY_IGNEOUS + ": " + it } mMessage = context.getString(R.string.settings_eh_account_identity_cookies, mCookie) if (igneousExpire > 0 && igneousExpire != ReadableTime.MAX_VALUE_MILLIS) { mMessage += "\n\n" + context.getString(R.string.settings_eh_account_igneous_expire) + ReadableTime.getShortTime(igneousExpire) } mDialog.setMessage(mMessage) } } override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { super.onPrepareDialogBuilder(builder) if (EhCookieStore.hasSignedIn()) { builder.setNeutralButton(R.string.settings_eh_account_identity_cookies_copy) { dialog: DialogInterface, which: Int -> mActivity.addTextToClipboard(mCookie, true) this@AccountPreference.onClick(dialog, which) } builder.setNegativeButton(R.string.settings_eh_account_refresh_igneous, null) } builder.setPositiveButton(R.string.settings_eh_account_sign_out) { _: DialogInterface, _: Int -> mActivity.lifecycleScope.launchIO { EhUtils.signOut() withUIContext { mActivity.showTip( R.string.settings_eh_account_sign_out_tip, BaseScene.LENGTH_SHORT, ) } delay(1500) withUIContext { val intent = Intent(mActivity, MainActivity::class.java) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) mActivity.startActivity(intent) } } } builder.setMessage(mMessage) } override fun onDialogCreated(dialog: AlertDialog) { super.onDialogCreated(dialog) mDialog = dialog refreshButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE) refreshButton.setOnClickListener(this) mDialog.window!!.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) updateMessage() } override fun onClick(v: View) { refreshButton.isEnabled = false mActivity.lifecycleScope.launchIO { EhCookieStore.deleteCookie( EhUrl.HOST_EX.toHttpUrl(), EhCookieStore.KEY_IGNEOUS, ) runCatching { EhEngine.getUConfig(EhUrl.URL_UCONFIG_EX) } withUIContext { updateMessage() refreshButton.isEnabled = true } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/CleanRedundancyPreference.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.preference import android.content.Context import android.util.AttributeSet import com.hippo.ehviewer.GetText import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.download.DownloadManager as downloadManager import com.hippo.unifile.UniFile import com.hippo.util.launchUI import com.hippo.util.withUIContext import kotlinx.coroutines.Job import kotlinx.coroutines.launch class CleanRedundancyPreference( context: Context, attrs: AttributeSet? = null, ) : TaskPreference(context, attrs) { private fun clearFile(file: UniFile): Boolean { var name = file.name ?: return false val index = name.indexOf('-') if (index >= 0) { name = name.substring(0, index) } val gid = name.toLongOrNull() ?: return false if (downloadManager.containDownloadInfo(gid)) { return false } file.delete() return true } private fun doRealWork(): Int = Settings.downloadLocation?.listFiles()?.sumOf { clearFile(it).compareTo(false) } ?: 0 override val jobTitle = JOB_TITLE_CLEAR_REDUNDANCY override fun launchJob() { if (singletonJob?.isActive == true) { singletonJob?.invokeOnCompletion { launchUI { mDialog.dismiss() } } } else { singletonJob = launch { val cnt = doRealWork() withUIContext { showTip(FINAL_CLEAR_REDUNDANCY_MSG(cnt)) mDialog.dismiss() } } } } companion object { private val JOB_TITLE_CLEAR_REDUNDANCY = GetText.getString(R.string.settings_download_clean_redundancy) private val NO_REDUNDANCY = GetText.getString(R.string.settings_download_clean_redundancy_no_redundancy) private val CLEAR_REDUNDANCY_DONE = { cnt: Int -> GetText.getString(R.string.settings_download_clean_redundancy_done, cnt) } private val FINAL_CLEAR_REDUNDANCY_MSG = { cnt: Int -> if (cnt == 0) NO_REDUNDANCY else CLEAR_REDUNDANCY_DONE(cnt) } } } private var singletonJob: Job? = null ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/ClearSearchHistoryPreference.kt ================================================ /* * Copyright 2025 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.preference import android.content.Context import android.util.AttributeSet import com.hippo.ehviewer.EhApplication.Companion.searchDatabase import com.hippo.ehviewer.GetText import com.hippo.ehviewer.R import com.hippo.util.launchUI import kotlinx.coroutines.launch class ClearSearchHistoryPreference( context: Context, attrs: AttributeSet? = null, ) : TaskPreference(context, attrs) { override val jobTitle = JOB_TITLE_CLEAR_SEARCH_HISTORY override fun launchJob() { launch { searchDatabase.clearQuery() launchUI { mDialog.dismiss() showTip(SEARCH_HISTORY_CLEARED) } } } companion object { private val JOB_TITLE_CLEAR_SEARCH_HISTORY = GetText.getString(R.string.settings_privacy_clear_search_history) private val SEARCH_HISTORY_CLEARED = GetText.getString(R.string.settings_privacy_clear_search_history_cleared) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/ImageLimitsPreference.kt ================================================ /* * Copyright 2018 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.preference import android.content.Context import android.content.DialogInterface import android.util.AttributeSet import android.view.View import android.widget.Button import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.parser.HomeParser import com.hippo.ehviewer.ui.SettingsActivity import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.preference.DialogPreference import com.hippo.util.ReadableTime import com.hippo.util.launchIO import com.hippo.util.withUIContext import okhttp3.HttpUrl.Companion.toHttpUrl class ImageLimitsPreference( context: Context, attrs: AttributeSet? = null, ) : DialogPreference(context, attrs), View.OnClickListener { private val mActivity = context as SettingsActivity private val placeholder = context.getString(R.string.please_wait) private val coroutineScope = mActivity.lifecycleScope private lateinit var resetButton: Button private lateinit var mDialog: AlertDialog private lateinit var mLimits: HomeParser.Limits private lateinit var mFunds: HomeParser.Funds init { if (EhCookieStore.hasSignedIn()) { coroutineScope.launchIO { getImageLimits { updateSummary() } } } } override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { super.onPrepareDialogBuilder(builder) builder.setMessage(placeholder) } override fun onDialogCreated(dialog: AlertDialog) { super.onDialogCreated(dialog) mDialog = dialog resetButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE) resetButton.setOnClickListener(this) resetButton.isEnabled = false if (this::mLimits.isInitialized) { bind() } else { coroutineScope.launchIO { getImageLimits(true) { bind() } } } } private fun formatCurrent(): String { val (current, maximum, _) = mLimits return when (maximum) { HomeParser.IP_NORMAL -> mActivity.getString(R.string.settings_eh_image_limits_summary_ip) + mActivity.getString(R.string.settings_eh_image_limits_summary_ip_ok) HomeParser.IP_RESTRICTED -> mActivity.getString(R.string.settings_eh_image_limits_summary_ip) + mActivity.getString(R.string.settings_eh_image_limits_summary_ip_restricted) else -> mActivity.getString(R.string.settings_eh_image_limits_summary_acc, current, maximum) } } private fun updateSummary() { summary = formatCurrent() } private suspend fun getImageLimits(showError: Boolean = false, onSuccess: () -> Unit) { runCatching { EhEngine.getImageLimits() }.onFailure { it.printStackTrace() if (showError) { withUIContext { mDialog.setMessage(it.message) } } }.onSuccess { mLimits = it.limits mFunds = it.funds withUIContext { onSuccess() } } } private fun bind() { val (_, maximum, resetCost) = mLimits val (fundsGP, fundsC) = mFunds var quotaExpire = 0L EhCookieStore.getCookies(EhUrl.HOST_E.toHttpUrl()).forEach { if (it.name == EhCookieStore.KEY_QUOTA) { quotaExpire = it.expiresAt } } var message = formatCurrent() if (quotaExpire > 0) { message += " (~${ReadableTime.getShortTime(quotaExpire)})" } message += "\n" + if (maximum < 0) { mActivity.getString(R.string.settings_eh_unlock_cost, resetCost) } else { mActivity.getString(R.string.settings_eh_reset_cost, resetCost) } + "\n" + mActivity.getString(R.string.current_funds, "$fundsGP+", fundsC) mDialog.setMessage(message) resetButton.text = if (maximum < 0) { mActivity.getString(R.string.settings_eh_unlock) } else { mActivity.getString(R.string.settings_eh_reset) } resetButton.isEnabled = resetCost != 0 updateSummary() } override fun onClick(v: View) { resetButton.isEnabled = false mDialog.setMessage(placeholder) coroutineScope.launchIO { runCatching { EhEngine.resetImageLimits(mLimits.maximum < 0) }.onFailure { it.printStackTrace() withUIContext { mDialog.setMessage(it.message) } }.onSuccess { EhCookieStore.copyCookie(EhUrl.DOMAIN_E, EhUrl.DOMAIN_EX, EhCookieStore.KEY_QUOTA) withUIContext { mLimits = it mActivity.showTip( R.string.settings_eh_reset_limits_succeed, BaseScene.LENGTH_SHORT, ) bind() } } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/ProxyPreference.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.preference import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.text.TextUtils import android.util.AttributeSet import android.view.View import android.widget.EditText import android.widget.Spinner import androidx.appcompat.app.AlertDialog import com.google.android.material.textfield.TextInputLayout import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.EhProxySelector import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.network.InetValidator import com.hippo.preference.DialogPreference import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.ViewUtils class ProxyPreference( context: Context, attrs: AttributeSet? = null, ) : DialogPreference(context, attrs), View.OnClickListener { private var mType: Spinner? = null private var mIpInputLayout: TextInputLayout? = null private var mIp: EditText? = null private var mPortInputLayout: TextInputLayout? = null private var mPort: EditText? = null private val mArray: Array = context.resources.getStringArray(R.array.proxy_types) init { dialogLayoutResource = R.layout.preference_dialog_proxy updateSummary(Settings.proxyType, Settings.proxyIp, Settings.proxyPort) } private fun getProxyTypeText(type: Int): String = mArray[MathUtils.clamp(type, 0, mArray.size - 1)] private fun updateSummary(type: Int, ip: String?, port: Int) { var type1 = type if ((type1 == EhProxySelector.TYPE_HTTP || type1 == EhProxySelector.TYPE_SOCKS) && (TextUtils.isEmpty(ip) || !InetValidator.isValidInetPort(port)) ) { type1 = EhProxySelector.TYPE_SYSTEM } summary = if (type1 == EhProxySelector.TYPE_HTTP || type1 == EhProxySelector.TYPE_SOCKS) { val context = context context.getString( R.string.settings_advanced_proxy_summary_1, getProxyTypeText(type1), ip, port, ) } else { val context = context context.getString( R.string.settings_advanced_proxy_summary_2, getProxyTypeText(type1), ) } } override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { super.onPrepareDialogBuilder(builder) builder.setPositiveButton(android.R.string.ok, null) } @SuppressLint("SetTextI18n") override fun onDialogCreated(dialog: AlertDialog) { super.onDialogCreated(dialog) dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(this) mType = ViewUtils.`$$`(dialog, R.id.type) as Spinner mIpInputLayout = ViewUtils.`$$`(dialog, R.id.ip_input_layout) as TextInputLayout mIp = ViewUtils.`$$`(dialog, R.id.ip) as EditText mPortInputLayout = ViewUtils.`$$`(dialog, R.id.port_input_layout) as TextInputLayout mPort = ViewUtils.`$$`(dialog, R.id.port) as EditText val type = Settings.proxyType mType!!.setSelection( MathUtils.clamp( type, 0, mArray.size, ), ) mIp!!.setText(Settings.proxyIp) val portString: String? val port = Settings.proxyPort portString = if (!InetValidator.isValidInetPort(port)) { null } else { port.toString() } mPort!!.setText(portString) } override fun onDialogClosed(positiveResult: Boolean) { super.onDialogClosed(positiveResult) mType = null mIpInputLayout = null mIp = null mPortInputLayout = null mPort = null } override fun onClick(v: View) { val dialog = dialog val context: Context = context if (null == dialog || null == mType || null == mIpInputLayout || null == mIp || null == mPortInputLayout || null == mPort) { return } val type = mType!!.selectedItemPosition val ip = mIp!!.text.toString().trim { it <= ' ' } if (ip.isEmpty()) { if (type == EhProxySelector.TYPE_HTTP || type == EhProxySelector.TYPE_SOCKS) { mIpInputLayout!!.error = context.getString(R.string.text_is_empty) return } } mIpInputLayout!!.error = null val port: Int val portString = mPort!!.text.toString().trim { it <= ' ' } if (portString.isEmpty()) { if (type == EhProxySelector.TYPE_HTTP || type == EhProxySelector.TYPE_SOCKS) { mPortInputLayout!!.error = context.getString(R.string.text_is_empty) return } else { port = -1 } } else { port = try { portString.toInt() } catch (_: NumberFormatException) { -1 } if (!InetValidator.isValidInetPort(port)) { mPortInputLayout!!.error = context.getString(R.string.proxy_invalid_port) return } } mPortInputLayout!!.error = null Settings.putProxyType(type) Settings.putProxyIp(ip) Settings.putProxyPort(port) updateSummary(type, ip, port) EhApplication.ehProxySelector.updateProxy() dialog.dismiss() } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/RestoreDownloadPreference.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.preference import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import com.hippo.ehviewer.GetText import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhEngine.fillGalleryListByApi import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.data.BaseGalleryInfo import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.spider.SpiderDen.Companion.getGalleryDownloadDir import com.hippo.ehviewer.spider.SpiderInfo import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME import com.hippo.ehviewer.spider.readFromUniFile import com.hippo.ehviewer.spider.saveToUniFile import com.hippo.unifile.UniFile import com.hippo.unifile.openInputStream import com.hippo.util.launchUI import com.hippo.util.runSuspendCatching import com.hippo.util.withUIContext import kotlinx.coroutines.Job import kotlinx.coroutines.launch import okio.buffer import okio.source class RestoreDownloadPreference( context: Context, attrs: AttributeSet? = null, ) : TaskPreference(context, attrs) { private var restoreDirCount = 0 private val nonSpiderInfoItemList = mutableListOf() @SuppressLint("ParcelCreator") private class RestoreItem(val dirname: String, gid: Long, token: String) : BaseGalleryInfo(gid, token) private fun getRestoreItem(dir: UniFile): RestoreItem? { if (!dir.isDirectory) return null return runCatching { val result = dir.findFile(SPIDER_INFO_FILENAME)?.let { readFromUniFile(it)?.run { GalleryDetailUrlParser.Result(gid, token!!) } } ?: dir.findFile(COMIC_INFO_FILE)?.let { file -> file.openInputStream().source().buffer().use { GalleryDetailUrlParser.parse(it.readUtf8()) }.also { it?.apply { nonSpiderInfoItemList.add(gid) } } } ?: return null val gid = result.gid val dirname = dir.name!! if (DownloadManager.containDownloadInfo(gid)) { // Restore download dir to avoid re-download val dbDirname = DownloadManager.getDownloadDirname(gid) if (null == dbDirname || dirname != dbDirname) { DownloadManager.putDownloadDirname(gid, dirname) restoreDirCount++ } return null } RestoreItem(dirname, gid, result.token) }.onFailure { it.printStackTrace() }.getOrNull() } private suspend fun doRealWork(): List? { val dir = Settings.downloadLocation ?: return null val files = dir.listFiles() ?: return null return runSuspendCatching { files.mapNotNull { getRestoreItem(it) }.also { fillGalleryListByApi(it, EhUrl.referer) } }.onFailure { it.printStackTrace() }.getOrNull() } override val jobTitle = JOB_TITLE_RESTORE_DOWNLOAD override fun launchJob() { if (singletonJob?.isActive == true) { singletonJob?.invokeOnCompletion { launchUI { mDialog.dismiss() } } } else { singletonJob = launch { val result = doRealWork() withUIContext { if (result == null) { showTip(RESTORE_FAILED) } else { if (result.isEmpty()) { showTip(RESTORE_COUNT_MSG(restoreDirCount)) } else { var count = 0 result.forEach { item -> if (null != item.title) { val gid = item.gid // Put to download DownloadManager.addDownload(item, null) // Put download dir to DB DownloadManager.putDownloadDirname(gid, item.dirname) // Create missing .ehviewer file if (gid in nonSpiderInfoItemList) { getGalleryDownloadDir(gid)?.run { createFile(SPIDER_INFO_FILENAME)?.also { SpiderInfo(gid, item.token, item.pages).saveToUniFile(it) } } } count++ } } showTip(RESTORE_COUNT_MSG(count + restoreDirCount)) } } mDialog.dismiss() } } } } companion object { private val JOB_TITLE_RESTORE_DOWNLOAD = GetText.getString(R.string.settings_download_restore_download_items) private val RESTORE_NOT_FOUND = GetText.getString(R.string.settings_download_restore_not_found) private val RESTORE_FAILED = GetText.getString(R.string.settings_download_restore_failed) private val RESTORE_COUNT_MSG = { cnt: Int -> if (cnt == 0) RESTORE_NOT_FOUND else GetText.getString(R.string.settings_download_restore_successfully, cnt) } } } private const val COMIC_INFO_FILE = "ComicInfo.xml" private var singletonJob: Job? = null ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/TaskPreference.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.preference import android.content.Context import android.content.DialogInterface import android.util.AttributeSet import androidx.appcompat.app.AlertDialog import com.google.android.material.snackbar.Snackbar import com.hippo.ehviewer.R import com.hippo.ehviewer.ui.SettingsActivity import com.hippo.preference.DialogPreference import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job abstract class TaskPreference( context: Context, attrs: AttributeSet? = null, ) : DialogPreference(context, attrs), CoroutineScope { lateinit var mDialog: AlertDialog override val coroutineContext = Dispatchers.IO + Job() override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) { builder.setTitle(jobTitle) .setMessage(R.string.settings_download_task_confirm) .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> mDialog = AlertDialog.Builder(context) .setTitle(null) .setView(R.layout.preference_dialog_task) .setCancelable(false) .show() launchJob() } .setNegativeButton(android.R.string.cancel, null) } abstract val jobTitle: String? abstract fun launchJob() protected fun showTip(msg: String) = (context as SettingsActivity).showTip(msg, Snackbar.LENGTH_SHORT) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/UserAgentPreference.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.preference import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.util.AttributeSet import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.widget.Button import android.widget.EditText import android.widget.TextView import android.widget.TextView.OnEditorActionListener import androidx.appcompat.app.AlertDialog import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.okhttp.CHROME_USER_AGENT import com.hippo.preference.DialogPreference @SuppressLint("InflateParams") class UserAgentPreference( context: Context, attrs: AttributeSet? = null, ) : DialogPreference(context, attrs), View.OnClickListener, OnEditorActionListener { private lateinit var mDialog: AlertDialog private lateinit var mButton: Button private var mUserAgent: String? = Settings.userAgent private val view: View = LayoutInflater.from(context).inflate(R.layout.dialog_edittext_builder, null) private val editText: EditText = view.findViewById(R.id.edit_text) init { updateSummary() } private fun updateSummary() { summary = mUserAgent } override fun onCreateDialogView(): View = view override fun onDialogCreated(dialog: AlertDialog) { super.onDialogCreated(dialog) mDialog = dialog mButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE) mButton.setOnClickListener(this) editText.isSingleLine = false editText.setText(mUserAgent) editText.setSelection(editText.text.length) editText.setOnEditorActionListener(this) } override fun onClick(v: View) { mUserAgent = editText.text.toString().trim().ifBlank { null } ?: CHROME_USER_AGENT Settings.putUserAgent(mUserAgent) updateSummary() mDialog.dismiss() } override fun onEditorAction(v: TextView?, p1: Int, event: KeyEvent?): Boolean { mButton.performClick() return true } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/preference/VersionPreference.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.preference import android.content.Context import android.util.AttributeSet import androidx.preference.Preference import com.hippo.ehviewer.BuildConfig import com.hippo.ehviewer.R class VersionPreference( context: Context, attrs: AttributeSet? = null, ) : Preference(context, attrs) { init { setTitle(R.string.settings_about_version) summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.COMMIT_SHA})\n" + context.getString(R.string.settings_about_build_time, BuildConfig.BUILD_TIME) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/shortcuts/ShortcutsActivity.kt ================================================ /* * Copyright 2018 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.shortcuts import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import com.hippo.ehviewer.download.DownloadService /** * Created by onlymash on 3/25/18. */ class ShortcutsActivity : AppCompatActivity() { public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val action: String? = intent?.action if (action != null && (action == DownloadService.ACTION_START_ALL || action == DownloadService.ACTION_STOP_ALL)) { ContextCompat.startForegroundService( this, Intent(this, DownloadService::class.java).setAction(action), ) } finish() } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/spider/DownloadInfoMagics.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.spider import com.hippo.ehviewer.client.thumbUrl import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.download.DownloadManager object DownloadInfoMagics { private const val DOWNLOAD_INFO_DIRNAME_URL_MAGIC = "$" private const val DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR = "|" fun encodeMagicRequest(info: DownloadInfo): String { val url = info.thumbUrl!! val location = DownloadManager.getDownloadDirname(info.gid) return if (location.isNullOrBlank()) { url } else { DOWNLOAD_INFO_DIRNAME_URL_MAGIC + url + DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR + location } } fun decodeMagicRequestOrUrl(encoded: String): Pair = if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) { val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR) a to b } else { encoded to null } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.spider import com.hippo.ehviewer.EhApplication.Companion.imageCache as sCache import com.hippo.ehviewer.EhApplication.Companion.nonCacheOkHttpClient import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhRequestBuilder import com.hippo.ehviewer.client.EhUtils.getSuitableTitle import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.getImageKey import com.hippo.ehviewer.coil.edit import com.hippo.ehviewer.coil.read import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.gallery.GalleryProvider2.Companion.SUPPORT_IMAGE_EXTENSIONS import com.hippo.image.UniFileSource import com.hippo.unifile.UniFile import com.hippo.unifile.openOutputStream import com.hippo.unifile.sha1 import com.hippo.util.runInterruptibleOkio import com.hippo.util.runSuspendCatching import com.hippo.util.sendTo import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.cleanAsDirname import java.io.IOException import java.util.Locale import okhttp3.Response import okhttp3.coroutines.executeAsync import okio.buffer import okio.sink class SpiderDen(private val mGalleryInfo: GalleryInfo) { private val fileHashRegex = Regex("/h/([0-9a-f]{40})") private val mGid = mGalleryInfo.gid var downloadDir: UniFile? = null @Volatile @SpiderQueen.Mode var mode = SpiderQueen.MODE_READ set(value) { field = value if (field == SpiderQueen.MODE_DOWNLOAD && downloadDir == null) { val title = getSuitableTitle(mGalleryInfo) val dirname = FileUtils.sanitizeFilename("$mGid-$title") downloadDir = perSafeDownloadDir(mGid, dirname) } } private fun containInCache(index: Int): Boolean { val key = getImageKey(mGid, index) return sCache.openSnapshot(key)?.use { true } == true } private fun containInDownloadDir(index: Int): Boolean { val dir = downloadDir ?: return false return findImageFile(dir, index) != null } private fun copyFromCacheToDownloadDir(index: Int, skip: Boolean): Boolean { val dir = downloadDir ?: return false // Find image file in cache val key = getImageKey(mGid, index) return runCatching { sCache.read(key) { // Get extension val extension = fixExtension("." + metadata.toFile().readText()) // Don't copy from cache if `download original image` enabled, ignore gif if (skip && extension != GIF_IMAGE_EXTENSION) { return false } // Copy from cache to download dir val file = dir.createFile(perFilename(index, extension)) ?: return false UniFile.fromFile(data.toFile())!! sendTo file } }.getOrElse { it.printStackTrace() false } } fun copyFromUniFileToDownloadDir(oldDir: UniFile, oldIndex: Int, index: Int): Boolean { val dir = downloadDir ?: return false return runCatching { val oldFile = findImageFile(oldDir, oldIndex) ?: return false val extension = oldFile.name.let { name -> FileUtils.getExtensionFromFilename(name) } val file = dir.createFile(perFilename(index, ".$extension")) ?: return false oldFile sendTo file true }.getOrElse { it.printStackTrace() false } } operator fun contains(index: Int): Boolean = when (mode) { SpiderQueen.MODE_READ -> { containInCache(index) || containInDownloadDir(index) } SpiderQueen.MODE_DOWNLOAD -> { containInDownloadDir(index) || copyFromCacheToDownloadDir(index, Settings.skipCopyImage) } else -> { false } } private fun removeFromCache(index: Int): Boolean { val key = getImageKey(mGid, index) return sCache.remove(key) } private fun removeFromDownloadDir(index: Int): Boolean = downloadDir?.let { findImageFile(it, index)?.delete() } == true fun remove(index: Int): Boolean = removeFromCache(index) or removeFromDownloadDir(index) private fun findDownloadFileForIndex(index: Int, extension: String): UniFile? { val dir = downloadDir ?: return null val ext = fixExtension(".$extension") return dir.createFile(perFilename(index, ext)) } @Throws(IOException::class) suspend fun saveImageFromUrl( url: String, referer: String?, dir: UniFile, filename: String, ): UniFile? { nonCacheOkHttpClient.newCall(EhRequestBuilder(url, referer).build()).executeAsync().use { if (it.code >= 400) return null val ext = it.body.contentType()?.subtype ?: "jpg" val dst = dir.subFile("$filename.$ext") ?: return null return runSuspendCatching { var ret = 0L runInterruptibleOkio { dst.openOutputStream().sink().buffer().use { sink -> it.body.source().use { source -> while (true) { val bytesRead = source.read(sink.buffer, 8192) if (bytesRead == -1L) break ret += bytesRead sink.emitCompleteSegments() } } } } if (ret == it.body.contentLength()) dst else null }.onFailure { e -> e.printStackTrace() }.getOrNull() } } @Throws(IOException::class) suspend fun makeHttpCallAndSaveImage( index: Int, url: String, referer: String?, notifyProgress: (Long, Long, Int) -> Unit, ): Boolean { // TODO: Use HttpEngine[https://developer.android.com/reference/android/net/http/HttpEngine] directly here if available // Since we don't want unnecessary copy between jvm heap & native heap nonCacheOkHttpClient.newCall(EhRequestBuilder(url, referer).build()).executeAsync().use { if (it.code >= 400) return false return saveFromHttpResponse(index, it, notifyProgress) } } private suspend fun saveFromHttpResponse(index: Int, response: Response, notifyProgress: (Long, Long, Int) -> Unit): Boolean { val url = response.request.url.toString() val extension = response.body.contentType()?.subtype ?: "jpg" val length = response.body.contentLength() suspend fun doSave(outFile: UniFile): Long { var ret = 0L runInterruptibleOkio { outFile.openOutputStream().sink().buffer().use { sink -> response.body.source().use { source -> while (true) { val bytesRead = source.read(sink.buffer, 8192) if (bytesRead == -1L) break ret += bytesRead sink.emitCompleteSegments() notifyProgress(length, ret, bytesRead.toInt()) } } } fileHashRegex.find(url)?.let { val expected = it.groupValues[1] val actual = outFile.sha1() check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" } } } return ret } findDownloadFileForIndex(index, extension)?.runSuspendCatching { return doSave(this) == length }?.onFailure { it.printStackTrace() return false } // Read Mode, allow save to cache if (mode == SpiderQueen.MODE_READ) { val key = getImageKey(mGid, index) var received: Long = 0 runSuspendCatching { sCache.edit(key) { metadata.toFile().writeText(extension) received = doSave(UniFile.fromFile(data.toFile())!!) } }.onFailure { it.printStackTrace() }.onSuccess { return received == length } } return false } fun saveToUniFile(index: Int, file: UniFile): Boolean { val key = getImageKey(mGid, index) // Read from diskCache first sCache.read(key) { runCatching { UniFile.fromFile(data.toFile())!! sendTo file return true }.onFailure { it.printStackTrace() return false } } // Read from download dir runCatching { findImageFile(downloadDir!!, index)!! sendTo file }.onFailure { it.printStackTrace() return false }.onSuccess { return true } return false } fun getExtension(index: Int): String? { val key = getImageKey(mGid, index) return sCache.openSnapshot(key)?.use { it.metadata.toFile().readText() } ?: downloadDir?.let { findImageFile(it, index) } ?.name.let { FileUtils.getExtensionFromFilename(it) } } fun getImageSource(index: Int): UniFileSource? { if (mode == SpiderQueen.MODE_READ) { val key = getImageKey(mGid, index) sCache.openSnapshot(key)?.let { val source = UniFile.fromFile(it.data.toFile())!! return object : UniFileSource, AutoCloseable by it { override val source = source } } } val dir = downloadDir ?: return null val source = findImageFile(dir, index) ?: return null return object : UniFileSource { override val source = source override fun close() {} } } companion object { private val COMPAT_IMAGE_EXTENSIONS = SUPPORT_IMAGE_EXTENSIONS + ".jpeg" private val GIF_IMAGE_EXTENSION = SUPPORT_IMAGE_EXTENSIONS[2] /** * @param extension with dot */ private fun fixExtension(extension: String): String = extension.takeIf { SUPPORT_IMAGE_EXTENSIONS.contains(it) } ?: SUPPORT_IMAGE_EXTENSIONS[0] fun findImageFile(dir: UniFile, index: Int): UniFile? = COMPAT_IMAGE_EXTENSIONS.firstNotNullOfOrNull { dir.findFile(perFilename(index, it)) } /** * @param extension with dot */ fun perFilename(index: Int, extension: String?): String = String.format(Locale.US, "%08d%s", index + 1, extension) fun getGalleryDownloadDir(gid: Long): UniFile? { val dir = Settings.downloadLocation ?: return null val dirname = DownloadManager.getDownloadDirname(gid) ?: return null return dir.subFile(dirname) } private fun perDownloadDir(gid: Long, dirname: String): UniFile? { DownloadManager.putDownloadDirname(gid, dirname) return getGalleryDownloadDir(gid)?.takeIf { it.ensureDir() } } fun perSafeDownloadDir(gid: Long, dirname: String): UniFile? = perDownloadDir(gid, dirname) ?: perDownloadDir(gid, dirname.cleanAsDirname()) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/spider/SpiderInfo.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.spider import coil3.disk.DiskCache import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.coil.edit import com.hippo.unifile.UniFile import com.hippo.unifile.openInputStream import com.hippo.unifile.openOutputStream import com.hippo.util.runSuspendCatching import com.hippo.yorozuya.NumberUtils import java.io.InputStream import kotlinx.serialization.Serializable import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray import okio.buffer import okio.source @Serializable class SpiderInfo( val gid: Long, var token: String? = null, val pages: Int, val pTokenMap: MutableMap = hashMapOf(), var startPage: Int = 0, var previewPages: Int = -1, var previewPerPage: Int = -1, var upgradeFrom: Long? = null, ) private val cbor = Cbor { ignoreUnknownKeys = true } private val spiderInfoCache by lazy { DiskCache.Builder() .directory(EhApplication.cacheDir / "spider_info_v2") .maxSizeBytes(20 * 1024 * 1024) .build() } fun readFromUniFile(file: UniFile): SpiderInfo? = runCatching { file.openInputStream().use { cbor.decodeFromByteArray(it.readBytes()) } }.getOrNull() ?: runCatching { file.openInputStream().use { readLegacySpiderInfo(it) } }.onFailure { it.printStackTrace() }.getOrNull() fun SpiderInfo.saveToUniFile(file: UniFile) = runSuspendCatching { file.openOutputStream().use { it.write(cbor.encodeToByteArray(this@saveToUniFile)) } }.onFailure { it.printStackTrace() } fun readFromCache(gid: Long): SpiderInfo? = runCatching { spiderInfoCache.openSnapshot(gid.toString())?.use { cbor.decodeFromByteArray(it.data.toFile().readBytes()) } }.onFailure { it.printStackTrace() }.getOrNull() fun SpiderInfo.saveToCache() = runSuspendCatching { spiderInfoCache.edit(gid.toString()) { data.toFile().writeBytes(cbor.encodeToByteArray(this@saveToCache)) } }.onFailure { it.printStackTrace() } private fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo? { val source = inputStream.source().buffer() fun read(): String = source.readUtf8LineStrict() fun readInt(): Int = read().toInt() fun readLong(): Long = read().toLong() fun getVersion(str: String): Int = if (str.startsWith(VERSION_STR)) { NumberUtils.parseIntSafely(str.substring(VERSION_STR.length), -1) } else { 1 } val version = getVersion(read()) var startPage = 0 when (version) { VERSION -> { // Read next line startPage = read().toInt(16).coerceAtLeast(0) } 1 -> { // pass } else -> { // Invalid version return null } } val gid = readLong() val token = read() read() // Deprecated, mode, skip it val previewPages = readInt() val previewPerPage = if (version == 1) 0 else readInt() val pages = readInt() if (gid == -1L || pages <= 0) return null val pTokenMap = hashMapOf() runCatching { while (true) { val line = read() val pos = line.indexOf(" ") if (pos > 0) { val index = line.substring(0, pos).toInt() val pToken = line.substring(pos + 1) if (pToken.isNotEmpty()) { pTokenMap[index] = pToken } } } } return SpiderInfo(gid, token, pages, pTokenMap, startPage, previewPages, previewPerPage) } private const val VERSION_STR = "VERSION" private const val VERSION = 2 ================================================ FILE: app/src/main/java/com/hippo/ehviewer/spider/SpiderQueen.kt ================================================ /* * Copyright 2016 Hippo Seven * Rewrite with Kotlin coroutines, Tarsin Norbin 2023 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.spider import android.util.Log import androidx.annotation.IntDef import androidx.collection.LongSparseArray import androidx.collection.set import com.hippo.ehviewer.EhApplication.Companion.okHttpClient as plainTextOkHttpClient import com.hippo.ehviewer.GetText import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhRequestBuilder import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils.isMPVAvailable import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.exception.QuotaExceededException import com.hippo.ehviewer.client.parser.GalleryDetailParser import com.hippo.ehviewer.client.parser.GalleryPageUrlParser import com.hippo.image.Image import com.hippo.unifile.UniFile import com.hippo.util.ExceptionUtils import com.hippo.util.launchIO import com.hippo.util.runSuspendCatching import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeout import okhttp3.coroutines.executeAsync class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineScope { override val coroutineContext = Dispatchers.IO + Job() @Volatile lateinit var mPageStateArray: IntArray lateinit var mSpiderInfo: SpiderInfo val mSpiderDen: SpiderDen = SpiderDen(galleryInfo) private val mPageStateLock = Any() private val mDownloadedPages = AtomicInteger(0) private val mFinishedPages = AtomicInteger(0) private val mSpiderListeners: MutableList = ArrayList() private var mOldDownloadDir: UniFile? = null private var mOldHashMap: MutableMap? = null private var mReadReference = 0 private var mDownloadReference = 0 fun addOnSpiderListener(listener: OnSpiderListener) { synchronized(mSpiderListeners) { mSpiderListeners.add(listener) } } fun removeOnSpiderListener(listener: OnSpiderListener) { synchronized(mSpiderListeners) { mSpiderListeners.remove(listener) } } private fun notifyGetPages(pages: Int) { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onGetPages(pages) } } } fun notifyGet509(index: Int) { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onGet509(index) } } } fun notifyPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onPageDownload( index, contentLength, receivedSize, bytesRead, ) } } } private fun notifyPageSuccess(index: Int) { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onPageSuccess( index, mFinishedPages.get(), mDownloadedPages.get(), mPageStateArray.size, ) } } } private fun notifyPageFailure(index: Int, error: String?) { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onPageFailure( index, error, mFinishedPages.get(), mDownloadedPages.get(), mPageStateArray.size, ) } } } private fun notifyAllPageDownloaded() { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onFinish( mFinishedPages.get(), mDownloadedPages.get(), mPageStateArray.size, ) } } mSpiderInfo.upgradeFrom = null } fun notifyGetImageSuccess(index: Int, image: Image) { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onGetImageSuccess(index, image) } } } fun notifyGetImageFailure(index: Int, error: String) { synchronized(mSpiderListeners) { mSpiderListeners.forEach { it.onGetImageFailure(index, error) } } } private var downloadMode = false val isReady get() = this::mSpiderInfo.isInitialized && this::mPageStateArray.isInitialized @Synchronized private fun updateMode() { if (!isReady) return val mode: Int = if (mDownloadReference > 0) { MODE_DOWNLOAD } else { MODE_READ } mSpiderDen.mode = mode // Update download page val intoDownloadMode = mode == MODE_DOWNLOAD if (intoDownloadMode && !downloadMode) { // Clear download state synchronized(mPageStateLock) { val temp: IntArray = mPageStateArray var i = 0 val n = temp.size while (i < n) { val oldState = temp[i] if (STATE_DOWNLOADING != oldState) { temp[i] = STATE_NONE } i++ } mDownloadedPages.lazySet(0) mFinishedPages.lazySet(0) } mWorkerScope.enterDownloadMode() } downloadMode = intoDownloadMode } private fun resetStates() { synchronized(mPageStateLock) { if (this::mPageStateArray.isInitialized) { mPageStateArray.fill(STATE_NONE) } mDownloadedPages.set(0) mFinishedPages.set(0) } mWorkerScope.clearRAList() } private fun setMode(@Mode mode: Int) { when (mode) { MODE_READ -> mReadReference++ MODE_DOWNLOAD -> mDownloadReference++ } check(mDownloadReference <= 1) { "mDownloadReference can't more than 1" } } private fun clearMode(@Mode mode: Int) { when (mode) { MODE_READ -> mReadReference-- MODE_DOWNLOAD -> mDownloadReference-- } check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" } } private val prepareJob = launchIO { doPrepare() } private suspend fun doPrepare() { mSpiderDen.downloadDir = SpiderDen.getGalleryDownloadDir(galleryInfo.gid)?.takeIf { it.isDirectory } mSpiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet() ?: return mPageStateArray = IntArray(mSpiderInfo.pages) prepareUpgrade() notifyGetPages(mSpiderInfo.pages) } private suspend fun prepareUpgrade() { mOldDownloadDir = mSpiderInfo.upgradeFrom?.let { gid -> SpiderDen.getGalleryDownloadDir(gid)?.takeIf { it.isDirectory } } mOldDownloadDir?.findFile(SPIDER_INFO_FILENAME)?.let { oldSpiderInfoFile -> val oldSpiderInfo = readFromUniFile(oldSpiderInfoFile) if (oldSpiderInfo != null && oldSpiderInfo.gid == mSpiderInfo.upgradeFrom) { if (oldSpiderInfo.pTokenMap.size != oldSpiderInfo.pages) { getPTokenFromMultiPageViewer( oldSpiderInfo.gid, oldSpiderInfo.token!!, oldSpiderInfo, ) if (oldSpiderInfo.pTokenMap.size == oldSpiderInfo.pages) { oldSpiderInfo.saveToUniFile(oldSpiderInfoFile) } } mOldHashMap = oldSpiderInfo.pTokenMap.entries.associateBy({ it.value }, { it.key }).toMutableMap() } } } suspend fun awaitReady(): Boolean { prepareJob.join() return isReady } suspend fun awaitStartPage(): Int { prepareJob.join() if (!isReady) return 0 return mSpiderInfo.startPage } private fun stop() { val queenScope = this launchIO { queenScope.cancel() runCatching { writeSpiderInfoToLocal() }.onFailure { it.printStackTrace() } } } val size get() = mPageStateArray.size fun forceRequest(index: Int) { request(index, true) } fun request(index: Int) { request(index, false) } private fun getPageState(index: Int): Int { synchronized(mPageStateLock) { return if (index >= 0 && index < mPageStateArray.size) { mPageStateArray[index] } else { STATE_NONE } } } fun cancelRequest(index: Int) { mWorkerScope.cancelDecode(index) } fun preloadPages(pages: List, pair: Pair) { mWorkerScope.updateRAList(pages, pair) } private fun request(index: Int, force: Boolean) { // Get page state val state = getPageState(index) // Fix state for force if (force && state == STATE_FINISHED || state == STATE_FAILED) { // Update state to none at once updatePageState(index, STATE_NONE) } mWorkerScope.launch(index, force) } suspend fun downloadOriginal(index: Int, dir: UniFile, filename: String): UniFile? { val pToken = getPToken(index) ?: return null val pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken) val originImageUrl = EhEngine.getGalleryPage(pageUrl, mSpiderInfo.gid, mSpiderInfo.token) .originImageUrl ?: return save(index, dir, filename) return runSuspendCatching { val targetImageUrl = EhEngine.getOriginalImageUrl(originImageUrl, pageUrl) mSpiderDen.saveImageFromUrl(targetImageUrl, pageUrl, dir, filename) }.onFailure { it.printStackTrace() }.getOrNull() } fun save(index: Int, file: UniFile): Boolean { val state = getPageState(index) return if (STATE_FINISHED != state) { false } else { mSpiderDen.saveToUniFile(index, file) } } fun save(index: Int, dir: UniFile, filename: String): UniFile? { val state = getPageState(index) if (STATE_FINISHED != state) { return null } val ext = mSpiderDen.getExtension(index) val dst = dir.subFile(if (null != ext) "$filename.$ext" else filename) ?: return null return if (!mSpiderDen.saveToUniFile(index, dst)) null else dst } fun getExtension(index: Int): String? { val state = getPageState(index) return if (STATE_FINISHED != state) { null } else { mSpiderDen.getExtension(index) } } val startPage: Int get() = mSpiderInfo.startPage fun putStartPage(page: Int) { mSpiderInfo.startPage = page } private fun readSpiderInfoFromLocal(): SpiderInfo? = mSpiderDen.downloadDir?.run { findFile(SPIDER_INFO_FILENAME)?.let { file -> readFromUniFile(file)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token } } } ?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token } private suspend fun readSpiderInfoFromInternet(): SpiderInfo? { val url = EhUrl.getGalleryDetailUrl( galleryInfo.gid, galleryInfo.token, 0, false, GET_FULL_HASH, ) val request = EhRequestBuilder(url, EhUrl.referer).build() return runSuspendCatching { plainTextOkHttpClient.newCall(request).executeAsync().use { response -> val body = response.body.string() val pages = GalleryDetailParser.parsePages(body) val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages) readPreviews(body, 0, spiderInfo) spiderInfo } }.onFailure { it.printStackTrace() }.getOrNull() } private suspend fun getPTokenFromMultiPageViewer(gid: Long, token: String, spiderInfo: SpiderInfo) { if (!isMPVAvailable) return runSuspendCatching { EhEngine.getPTokenFromMultiPageViewer( gid, token, GET_FULL_HASH, ).forEachIndexed { index, pToken -> spiderInfo.pTokenMap[index] = pToken } }.onFailure { it.printStackTrace() } } private suspend fun getPTokenFromMultiPageViewer(index: Int): String? { getPTokenFromMultiPageViewer(galleryInfo.gid, galleryInfo.token!!, mSpiderInfo) return mSpiderInfo.pTokenMap[index] } private suspend fun getPTokenFromInternet(index: Int): String? { // Check previewIndex val previewIndex = if (mSpiderInfo.previewPerPage >= 0) { (index / mSpiderInfo.previewPerPage).coerceAtMost(mSpiderInfo.previewPages.takeIf { it > 0 }?.minus(1) ?: Int.MAX_VALUE) } else { 0 } val url = EhUrl.getGalleryDetailUrl( galleryInfo.gid, galleryInfo.token, previewIndex, false, GET_FULL_HASH, ) val request = EhRequestBuilder(url, EhUrl.referer).build() return runSuspendCatching { plainTextOkHttpClient.newCall(request).executeAsync().use { response -> val body = response.body.string() readPreviews(body, previewIndex, mSpiderInfo) mSpiderInfo.pTokenMap[index] } }.getOrElse { it.printStackTrace() null } } suspend fun getPToken(index: Int): String? { if (index !in 0 until size) return null return mSpiderInfo.pTokenMap[index] ?: getPTokenFromMultiPageViewer(index) ?: getPTokenFromInternet(index) // Preview size may changed, so try to get pToken twice ?: getPTokenFromInternet(index) } @Synchronized private fun writeSpiderInfoToLocal() { if (!isReady) return mSpiderDen.downloadDir?.run { createFile(SPIDER_INFO_FILENAME)?.also { mSpiderInfo.saveToUniFile(it) } } mSpiderInfo.saveToCache() } private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED fun updatePageState(index: Int, @State state: Int, error: String? = null) { var oldState: Int synchronized(mPageStateLock) { oldState = mPageStateArray[index] mPageStateArray[index] = state if (!isStateDone(oldState) && isStateDone(state)) { mDownloadedPages.incrementAndGet() } else if (isStateDone(oldState) && !isStateDone(state)) { mDownloadedPages.decrementAndGet() } if (oldState != STATE_FINISHED && state == STATE_FINISHED) { mFinishedPages.incrementAndGet() } else if (oldState == STATE_FINISHED && state != STATE_FINISHED) { mFinishedPages.decrementAndGet() } } // Notify listeners if (state == STATE_FAILED) { notifyPageFailure(index, error) } else if (state == STATE_FINISHED) { notifyPageSuccess(index) } if (mDownloadedPages.get() == size) notifyAllPageDownloaded() } @IntDef(MODE_READ, MODE_DOWNLOAD) @Retention annotation class Mode @IntDef(STATE_NONE, STATE_DOWNLOADING, STATE_FINISHED, STATE_FAILED) @Retention(AnnotationRetention.SOURCE) annotation class State interface OnSpiderListener { fun onGetPages(pages: Int) fun onGet509(index: Int) fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) fun onFinish(finished: Int, downloaded: Int, total: Int) fun onGetImageSuccess(index: Int, image: Image?) fun onGetImageFailure(index: Int, error: String?) } private val mWorkerScope = object { private val mFetcherJobMap = hashMapOf() private val mSemaphore = Semaphore(Settings.downloadThreadCount) private val pTokenLock = Mutex() private var showKey: String? = null private val showKeyLock = Mutex() private val mDownloadDelay = Settings.downloadDelay.milliseconds private val downloadTimeout = Settings.downloadTimeout.seconds private var lastRequestTime = TimeSource.Monotonic.markNow() private var isDownloadMode = false fun cancelDecode(index: Int) { decoder.cancel(index) } @Synchronized fun enterDownloadMode() { if (isDownloadMode) return updateRAList((0 until size).toList()) isDownloadMode = true } fun updateRAList(list: List, cancelBounds: Pair = 0 to Int.MAX_VALUE) { if (isDownloadMode) return synchronized(mFetcherJobMap) { mFetcherJobMap.forEach { (i, job) -> if (i < cancelBounds.first || i > cancelBounds.second) { job.cancel() } } list.forEach { if (mFetcherJobMap[it]?.isActive != true) { doLaunchDownloadJob(it, false) } } } } fun clearRAList() { synchronized(mFetcherJobMap) { mFetcherJobMap.forEach { (_, job) -> job.cancel() } mFetcherJobMap.clear() } } private fun doLaunchDownloadJob(index: Int, force: Boolean) { val state = mPageStateArray[index] if (!force && state == STATE_FINISHED) return val currentJob = mFetcherJobMap[index] val skipHath = force && currentJob?.isActive == true if (force) currentJob?.cancel(CancellationException(FORCE_RETRY)) if (currentJob?.isActive != true) { mFetcherJobMap[index] = launch { runCatching { mSemaphore.withPermit { doInJob(index, force, skipHath) } }.onFailure { if (it is CancellationException) { if (mReadReference > 0) { Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled") if (it.message != FORCE_RETRY) { updatePageState(index, STATE_FAILED, "Cancelled") } } throw it } updatePageState(index, STATE_FAILED, ExceptionUtils.getReadableString(it)) } } } } fun launch(index: Int, force: Boolean = false) { check(index in 0 until size) if (!isDownloadMode) synchronized(mFetcherJobMap) { doLaunchDownloadJob(index, force) } if (force) decoder.cancel(index) decoder.launch(index) } private suspend fun doInJob(index: Int, force: Boolean, skipHath: Boolean) { val previousPToken: String? val pToken: String pTokenLock.withLock { if (!force && index in mSpiderDen) { return updatePageState(index, STATE_FINISHED) } pToken = getPToken(index) ?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE) previousPToken = getPToken(index - 1) mOldDownloadDir?.let { oldDir -> (mOldHashMap?.get(pToken) ?: mOldHashMap?.get(pToken.take(10)))?.let { oldIndex -> if (mSpiderDen.copyFromUniFileToDownloadDir(oldDir, oldIndex, index)) { return updatePageState(index, STATE_FINISHED) } } } // The lock for delay should be acquired before anything else to maintain FIFO order delay(mDownloadDelay - lastRequestTime.elapsedNow()) lastRequestTime = TimeSource.Monotonic.markNow() } updatePageState(index, STATE_DOWNLOADING) var skipHathKey: String? = null var originImageUrl: String? = null var error: String? = null var forceHtml = false runSuspendCatching { repeat(2) { retries -> var imageUrl: String? = null var localShowKey: String? showKeyLock.withLock { localShowKey = showKey if (localShowKey == null || forceHtml) { var pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken) // Skipping H@H costs 50 points, only use it as last resort if (skipHathKey != null) { pageUrl += if ("?" in pageUrl) { "&nl=$skipHathKey" } else { "?nl=$skipHathKey" } } EhEngine.getGalleryPage(pageUrl, mSpiderInfo.gid, mSpiderInfo.token) .let { result -> check509(result.imageUrl) imageUrl = result.imageUrl skipHathKey = result.skipHathKey originImageUrl = result.originImageUrl localShowKey = result.showKey showKey = result.showKey } } } if (imageUrl == null) { runSuspendCatching { EhEngine.getGalleryPageApi( mSpiderInfo.gid, index, pToken, localShowKey, previousPToken, ) }.getOrElse { forceHtml = true return@repeat }.let { check509(it.imageUrl) imageUrl = it.imageUrl skipHathKey = it.skipHathKey originImageUrl = it.originImageUrl } } if (retries == 0 && skipHath) { forceHtml = true return@repeat } val targetImageUrl: String? val referer: String? if (Settings.getDownloadOriginImage(mSpiderDen.downloadDir != null) && originImageUrl != null) { if (retries == 1 && skipHathKey != null) { originImageUrl += if ("?" in originImageUrl!!) { "&nl=$skipHathKey" } else { "?nl=$skipHathKey" } } val pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken) targetImageUrl = EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl) referer = EhUrl.referer } else { // Original image url won't change, so only set forceHtml in this case forceHtml = true targetImageUrl = imageUrl referer = null } checkNotNull(targetImageUrl) repeat(3) { times -> runCatching { Log.d(WORKER_DEBUG_TAG, "Start download image $index attempt #$times") val success = withTimeout(downloadTimeout) { mSpiderDen.makeHttpCallAndSaveImage( index, targetImageUrl, referer, ) { contentLength: Long, receivedSize: Long, bytesRead: Int -> notifyPageDownload(index, contentLength, receivedSize, bytesRead) } } check(success) Log.d(WORKER_DEBUG_TAG, "Download image $index succeed") updatePageState(index, STATE_FINISHED) return }.onFailure { mSpiderDen.remove(index) Log.d(WORKER_DEBUG_TAG, "Download image $index attempt #$times failed") error = when (it) { is TimeoutCancellationException -> ERROR_TIMEOUT is CancellationException -> throw it else -> ExceptionUtils.getReadableString(it) } } } } }.onFailure { when (it) { is QuotaExceededException -> notifyGet509(index) } error = ExceptionUtils.getReadableString(it) } updatePageState(index, STATE_FAILED, error) } private val decoder = object { private val mSemaphore = Semaphore(4) private val mDecodeJobMap = hashMapOf() fun cancel(index: Int) { synchronized(mDecodeJobMap) { mDecodeJobMap.remove(index)?.cancel() } } fun launch(index: Int) { synchronized(mDecodeJobMap) { val currentJob = mDecodeJobMap[index] if (currentJob?.isActive != true) { mDecodeJobMap[index] = launch { doInJob(index) } } } } private suspend fun doInJob(index: Int) { mFetcherJobMap[index]?.takeIf { it.isActive }?.join() val src = mSpiderDen.getImageSource(index) ?: return val image = mSemaphore.withPermit { Image.decode(src) } runCatching { currentCoroutineContext().ensureActive() }.onFailure { image?.recycle() throw it } if (image == null) { notifyGetImageFailure(index, DECODE_ERROR) } else { notifyGetImageSuccess(index, image) } } } } companion object { const val MODE_READ = 0 const val MODE_DOWNLOAD = 1 const val STATE_NONE = 0 const val STATE_DOWNLOADING = 1 const val STATE_FINISHED = 2 const val STATE_FAILED = 3 const val SPIDER_INFO_FILENAME = ".ehviewer" const val GET_FULL_HASH = true private val sQueenMap = LongSparseArray() private val PTOKEN_FAILED_MESSAGE = GetText.getString(R.string.error_get_ptoken_error) private val ERROR_TIMEOUT = GetText.getString(R.string.error_timeout) private val DECODE_ERROR = GetText.getString(R.string.error_decoding_failed) private val URL_509_PATTERN = Regex("\\.org/.+/509s?\\.gif") private const val FORCE_RETRY = "Force retry" private const val WORKER_DEBUG_TAG = "SpiderQueenWorker" fun reset(gid: Long) { sQueenMap[gid]?.resetStates() } private fun check509(url: String) { if (URL_509_PATTERN in url) throw QuotaExceededException() } fun obtainSpiderQueen(galleryInfo: GalleryInfo, @Mode mode: Int): SpiderQueen { val gid = galleryInfo.gid return (sQueenMap[gid] ?: SpiderQueen(galleryInfo).also { sQueenMap[gid] = it }).apply { setMode(mode) launchIO { if (awaitReady()) updateMode() } } } fun releaseSpiderQueen(queen: SpiderQueen, @Mode mode: Int) { queen.run { clearMode(mode) if (mReadReference == 0 && mDownloadReference == 0) { stop() sQueenMap.remove(galleryInfo.gid) } else { launchIO { if (awaitReady()) updateMode() } } } } fun readPreviews(body: String, index: Int, spiderInfo: SpiderInfo) { spiderInfo.previewPages = GalleryDetailParser.parsePreviewPages(body) val previewSet = GalleryDetailParser.parsePreviewSet(body) if (previewSet.size() > 0) { if (index == 0) { spiderInfo.previewPerPage = previewSet.size() } else { spiderInfo.previewPerPage = previewSet.getPosition(0) / index } } for (i in 0 until previewSet.size()) { if (GET_FULL_HASH) { spiderInfo.pTokenMap[previewSet.getPosition(i)] = previewSet.getSha1At(i) } else { GalleryPageUrlParser.parse(previewSet.getPageUrlAt(i))?.let { spiderInfo.pTokenMap[it.page] = it.pToken } } } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/CommonOperations.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui import android.app.Activity import android.content.DialogInterface import android.content.Intent import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import com.hippo.app.EditTextCheckBoxDialogBuilder import com.hippo.app.ListCheckBoxDialogBuilder import com.hippo.ehviewer.EhApplication.Companion.application import com.hippo.ehviewer.EhApplication.Companion.favouriteStatusRouter import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhRequest import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.download.DownloadService import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.unifile.UniFile import com.hippo.util.isAtLeastT import com.hippo.yorozuya.collect.LongList import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock object CommonOperations { private fun doAddToFavorites( activity: Activity, galleryInfo: GalleryInfo, slot: Int, note: String, listener: EhClient.Callback, ) { val request = EhRequest() request.setMethod(EhClient.METHOD_ADD_FAVORITES) request.setArgs(galleryInfo.gid, galleryInfo.token, slot, note) request.setCallback(listener) request.enqueue(activity) } private fun doAddToFavorites( activity: Activity, galleryInfo: GalleryInfo, slot: Int, listener: EhClient.Callback, foreEdit: Boolean, ) { when (slot) { -1 -> { EhDB.putLocalFavorites(galleryInfo) listener.onSuccess(Unit) } in 0..9 -> { if (!foreEdit && Settings.neverAddFavNotes) { doAddToFavorites(activity, galleryInfo, slot, "", listener) } else { val builder = EditTextCheckBoxDialogBuilder( activity, null, activity.getString(R.string.favorite_note), activity.getString(R.string.favorite_note_never_show), Settings.neverAddFavNotes, ) builder.setTitle(R.string.add_favorite_note_dialog_title) builder.setPositiveButton(android.R.string.ok, null) val dialog = builder.show() dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { val text = builder.text.trim { it <= ' ' } Settings.putNeverAddFavNotes(builder.isChecked) dialog.dismiss() doAddToFavorites(activity, galleryInfo, slot, text, listener) } dialog.setOnCancelListener { listener.onCancel() } } } else -> { listener.onFailure(Exception()) // TODO Add text } } } fun addToFavorites( activity: Activity, galleryInfo: GalleryInfo, listener: EhClient.Callback, foreSelect: Boolean = false, ) { val slot = Settings.defaultFavSlot val localFav = activity.getString(R.string.local_favorites) val items = Settings.favCat.toMutableList().apply { add(0, localFav) } if (!foreSelect && slot in -1..9) { val newFavoriteName = if (slot >= 0) items[slot + 1] else null doAddToFavorites( activity, galleryInfo, slot, DelegateFavoriteCallback(listener, galleryInfo, newFavoriteName, slot), false, ) } else { ListCheckBoxDialogBuilder( activity, items, { builder: ListCheckBoxDialogBuilder?, _: AlertDialog?, position: Int -> val slot1 = position - 1 val newFavoriteName = if (slot1 in 0..9) items[slot1 + 1] else null doAddToFavorites( activity, galleryInfo, slot1, DelegateFavoriteCallback(listener, galleryInfo, newFavoriteName, slot1), foreSelect, ) if (builder?.isChecked == true) { Settings.putDefaultFavSlot(slot1) } else { Settings.putDefaultFavSlot(Settings.INVALID_DEFAULT_FAV_SLOT) } }, activity.getString(R.string.remember_favorite_collection), slot != Settings.INVALID_DEFAULT_FAV_SLOT, ) .setTitle(R.string.add_favorites_dialog_title) .setOnCancelListener { listener.onCancel() } .show() } } fun removeFromFavorites( activity: Activity?, galleryInfo: GalleryInfo, listener: EhClient.Callback, isLocal: Boolean = false, ) { EhDB.removeLocalFavorites(galleryInfo.gid) if (isLocal) { EhDB.updateHistoryFavSlot(galleryInfo.gid, -2) listener.onSuccess(Unit) } else { val request = EhRequest() request.setMethod(EhClient.METHOD_ADD_FAVORITES) request.setArgs(galleryInfo.gid, galleryInfo.token, -1, "") request.setCallback(DelegateFavoriteCallback(listener, galleryInfo, null, -2)) request.enqueue(activity!!) } } fun startDownload(activity: MainActivity, galleryInfo: GalleryInfo, forceDefault: Boolean) { startDownload(activity, listOf(galleryInfo), forceDefault) } fun startDownload( activity: MainActivity, galleryInfos: List, forceDefault: Boolean, ) { if (isAtLeastT) { application.topActivity?.checkAndRequestNotificationPermission() } doStartDownload(activity, galleryInfos, forceDefault) } private fun doStartDownload( activity: MainActivity, galleryInfos: List, forceDefault: Boolean, ) { val toStart = LongList() val toAdd: MutableList = ArrayList() for (gi in galleryInfos) { if (DownloadManager.containDownloadInfo(gi.gid)) { toStart.add(gi.gid) } else { toAdd.add(gi) } } if (!toStart.isEmpty()) { val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START_RANGE intent.putExtra(DownloadService.KEY_GID_LIST, toStart) ContextCompat.startForegroundService(activity, intent) } if (toAdd.isEmpty()) { activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT) return } var justStart = forceDefault var label: String? = null // Get default download label if (!justStart && Settings.hasDefaultDownloadLabel) { label = Settings.defaultDownloadLabel justStart = label == null || DownloadManager.containLabel(label) } // If there is no other label, just use null label if (!justStart && DownloadManager.labelList.isEmpty()) { justStart = true label = null } if (justStart) { // Got default label for (gi in toAdd) { val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START intent.putExtra(DownloadService.KEY_LABEL, label) intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi) ContextCompat.startForegroundService(activity, intent) } // Notify activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT) } else { // Let use chose label val list = DownloadManager.labelList val items = mutableListOf() items.add(activity.getString(R.string.default_download_label_name)) items.addAll(list.mapNotNull { it.label }) ListCheckBoxDialogBuilder( activity, items, { builder: ListCheckBoxDialogBuilder?, _: AlertDialog?, position: Int -> var label1: String? if (position == 0) { label1 = null } else { label1 = items[position] if (!DownloadManager.containLabel(label1)) { label1 = null } } // Start download for (gi in toAdd) { val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START intent.putExtra(DownloadService.KEY_LABEL, label1) intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi) ContextCompat.startForegroundService(activity, intent) } // Save settings if (builder?.isChecked == true) { Settings.putHasDefaultDownloadLabel(true) Settings.putDefaultDownloadLabel(label1) } else { Settings.putHasDefaultDownloadLabel(false) } // Notify activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT) }, activity.getString(R.string.remember_download_label), false, ) .setTitle(R.string.download) .show() } } private class DelegateFavoriteCallback( private val delegate: EhClient.Callback, private val info: GalleryInfo, private val newFavoriteName: String?, private val slot: Int, ) : EhClient.Callback { override fun onSuccess(result: Unit) { EhDB.updateHistoryFavSlot(info.gid, slot) info.favoriteName = newFavoriteName info.favoriteSlot = slot delegate.onSuccess(result) favouriteStatusRouter.modifyFavourites(info.gid, slot) } override fun onFailure(e: Exception) { delegate.onFailure(e) } override fun onCancel() { delegate.onCancel() } } } private fun removeNoMediaFile(downloadDir: UniFile) { val noMedia = downloadDir.subFile(".nomedia") ?: return noMedia.delete() } private fun ensureNoMediaFile(downloadDir: UniFile) { downloadDir.createFile(".nomedia") ?: return } private val lck = Mutex() suspend fun keepNoMediaFileStatus() { lck.withLock { val downloadLocation = Settings.downloadLocation ?: return if (Settings.mediaScan) { removeNoMediaFile(downloadLocation) } else { ensureNoMediaFile(downloadLocation) } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/EhActivity.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui import android.Manifest import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources.Theme import android.graphics.Color import android.os.Build import android.os.Bundle import android.view.WindowManager import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.util.isAtLeastQ import rikka.core.res.resolveColor import rikka.insets.WindowInsetsHelper import rikka.layoutinflater.view.LayoutInflaterFactory abstract class EhActivity : AppCompatActivity() { @StyleRes fun getThemeStyleRes(): Int = if (Settings.blackDarkTheme && (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_YES > 0)) R.style.ThemeOverlay_Black else R.style.ThemeOverlay override fun onApplyThemeResource(theme: Theme, resid: Int, first: Boolean) { theme.applyStyle(resid, true) theme.applyStyle(getThemeStyleRes(), true) } override fun onNightModeChanged(mode: Int) { theme.applyStyle(getThemeStyleRes(), true) } @Suppress("DEPRECATION") override fun onCreate(savedInstanceState: Bundle?) { layoutInflater.factory2 = LayoutInflaterFactory(delegate).addOnViewCreatedListener(WindowInsetsHelper.LISTENER) super.onCreate(savedInstanceState) window.statusBarColor = Color.TRANSPARENT window.decorView.post { window.navigationBarColor = theme.resolveColor(android.R.attr.navigationBarColor) and 0x00ffffff or -0x20000000 if (isAtLeastQ) { window.isNavigationBarContrastEnforced = false } } (application as EhApplication).registerActivity(this) } override fun onDestroy() { super.onDestroy() (application as EhApplication).unregisterActivity(this) } override fun onResume() { super.onResume() if (Settings.enabledSecurity) { window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { Settings.putNotificationRequired() } @RequiresApi(Build.VERSION_CODES.TIRAMISU) fun checkAndRequestNotificationPermission() { if (Settings.notificationRequired || ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/GalleryActivity.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui import android.Manifest import android.animation.Animator import android.animation.ObjectAnimator import android.animation.ValueAnimator.AnimatorUpdateListener import android.annotation.SuppressLint import android.app.assist.AssistContent import android.content.ClipData import android.content.ClipboardManager import android.content.ContentValues import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.graphics.Typeface import android.net.Uri import android.os.Bundle import android.os.Environment import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor.MODE_READ_ONLY import android.provider.MediaStore import android.text.TextUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.webkit.MimeTypeMap import android.widget.CompoundButton import android.widget.FrameLayout import android.widget.ImageView import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.Spinner import android.widget.Switch import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.hippo.app.EditTextDialogBuilder import com.hippo.ehviewer.AppConfig import com.hippo.ehviewer.BuildConfig import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.gallery.ArchiveGalleryProvider import com.hippo.ehviewer.gallery.EhGalleryProvider import com.hippo.ehviewer.gallery.GalleryProvider2 import com.hippo.ehviewer.widget.GalleryGuideView import com.hippo.ehviewer.widget.GalleryHeader import com.hippo.ehviewer.widget.ReversibleSeekBar import com.hippo.glgallery.GalleryProvider import com.hippo.glgallery.GalleryView import com.hippo.glgallery.SimpleAdapter import com.hippo.glview.view.GLRootView import com.hippo.unifile.UniFile import com.hippo.util.ExceptionUtils import com.hippo.util.getParcelableCompat import com.hippo.util.getParcelableExtraCompat import com.hippo.util.isAtLeastP import com.hippo.util.isAtLeastQ import com.hippo.util.launchIO import com.hippo.util.sendTo import com.hippo.util.withUIContext import com.hippo.widget.ColorView import com.hippo.yorozuya.AnimationUtils import com.hippo.yorozuya.ConcurrentPool import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.ResourcesUtils import com.hippo.yorozuya.SimpleAnimatorListener import com.hippo.yorozuya.SimpleHandler import com.hippo.yorozuya.ViewUtils import java.io.File import java.io.IOException import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import rikka.core.res.isNight import rikka.core.res.resolveColor class GalleryActivity : EhActivity(), OnSeekBarChangeListener, GalleryView.Listener { private val requestStoragePermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { result -> if (result && mSavingPage != -1) { saveImage(mSavingPage) } else { Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() } mSavingPage = -1 } private val saveImageToLauncher = registerForActivityResult( CreateDocument("todo/todo"), ) { uri -> if (uri != null) { val filepath = AppConfig.getExternalTempDir().toString() + File.separator + mCacheFileName val cacheFile = File(filepath) lifecycleScope.launchIO { try { ParcelFileDescriptor.open(cacheFile, MODE_READ_ONLY).use { from -> contentResolver.openFileDescriptor(uri, "w")!!.use { from sendTo it } } } catch (e: IOException) { e.printStackTrace() } finally { runOnUiThread { Toast.makeText( this@GalleryActivity, getString(R.string.image_saved, uri.path), Toast.LENGTH_SHORT, ).show() } } cacheFile.delete() } } } private val mHideSliderRunnable = Runnable { mSeekBarPanel?.let { hideSlider(it) } } private val mHideSliderListener: SimpleAnimatorListener = object : SimpleAnimatorListener() { override fun onAnimationEnd(animation: Animator) { mSeekBarPanelAnimator = null mSeekBarPanel?.visibility = View.INVISIBLE } } private val mUpdateSliderListener = AnimatorUpdateListener { mSeekBarPanel?.requestLayout() } private val mShowSliderListener: SimpleAnimatorListener = object : SimpleAnimatorListener() { override fun onAnimationEnd(animation: Animator) { mSeekBarPanelAnimator = null } } private val mNotifyTaskPool = ConcurrentPool(3) private var mAction: String? = null private var mFilename: String? = null private var mUri: Uri? = null private var mGalleryInfo: GalleryInfo? = null private var mPage = 0 private var mCacheFileName: String? = null private var mGLRootView: GLRootView? = null private var mGalleryView: GalleryView? = null private var mGalleryProvider: GalleryProvider2? = null private var mGalleryAdapter: GalleryAdapter? = null private var insetsController: WindowInsetsControllerCompat? = null private var mMaskView: ColorView? = null private var mClock: View? = null private var mProgress: TextView? = null private var mBattery: View? = null private var mSeekBarPanel: View? = null private var mGLLoading: View? = null private var mLeftText: TextView? = null private var mRightText: TextView? = null private var mSeekBar: ReversibleSeekBar? = null private var mAutoTransfer: ImageView? = null private var mSeekBarPanelAnimator: ObjectAnimator? = null private var mLayoutMode = 0 private var mSize = 0 private var mCurrentIndex = 0 private var mSavingPage = -1 private lateinit var builder: EditTextDialogBuilder private lateinit var dialog: AlertDialog private var dialogShown = false private var mAutoTransferJob: Job? = null private var mTurnPageIntervalVal = Settings.turnPageInterval private val galleryDetailUrl: String? get() { val gid: Long val token: String if (mGalleryInfo != null) { gid = mGalleryInfo!!.gid token = mGalleryInfo!!.token!! } else { return null } return EhUrl.getGalleryDetailUrl(gid, token, 0, false) } private fun buildProvider(replace: Boolean = false) { if (mGalleryProvider != null) { if (replace) mGalleryProvider!!.stop() else return } if (ACTION_EH == mAction) { mGalleryInfo?.let { mGalleryProvider = EhGalleryProvider(it) } } else if (Intent.ACTION_VIEW == mAction) { if (mUri != null) { try { grantUriPermission( BuildConfig.APPLICATION_ID, mUri, Intent.FLAG_GRANT_READ_URI_PERMISSION, ) } catch (_: Exception) { Toast.makeText(this, R.string.error_reading_failed, Toast.LENGTH_SHORT).show() } val continuation: AtomicReference?> = AtomicReference(null) mGalleryProvider = ArchiveGalleryProvider( this, mUri!!, flow { if (!dialogShown) { withUIContext { dialogShown = true dialog.run { show() getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val passwd = builder.text if (passwd.isEmpty()) { builder.setError(getString(R.string.passwd_cannot_be_empty)) } else { continuation.getAndSet(null)?.resume(passwd) } } setOnCancelListener { finish() } } } } while (true) { currentCoroutineContext().ensureActive() val r = suspendCancellableCoroutine { continuation.set(it) it.invokeOnCancellation { dialog.dismiss() } } emit(r) withUIContext { builder.setError(getString(R.string.passwd_wrong)) } } }, ) } } } private fun handleIntent(intent: Intent?) { intent ?: return mAction = intent.action mFilename = intent.getStringExtra(KEY_FILENAME) mUri = intent.data mGalleryInfo = intent.getParcelableExtraCompat(KEY_GALLERY_INFO) mPage = intent.getIntExtra(KEY_PAGE, -1) } private fun onInit() { handleIntent(intent) buildProvider() } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntent(intent) buildProvider(true) mGalleryProvider?.let { lifecycleScope.launchIO { it.start() if (it.awaitReady()) { withUIContext { mCurrentIndex = 0 setGallery() } } } } } private fun onRestore(savedInstanceState: Bundle) { mAction = savedInstanceState.getString(KEY_ACTION) mFilename = savedInstanceState.getString(KEY_FILENAME) mUri = savedInstanceState.getParcelableCompat(KEY_URI) mGalleryInfo = savedInstanceState.getParcelableCompat(KEY_GALLERY_INFO) mPage = savedInstanceState.getInt(KEY_PAGE, -1) mCurrentIndex = savedInstanceState.getInt(KEY_CURRENT_INDEX) buildProvider() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_ACTION, mAction) outState.putString(KEY_FILENAME, mFilename) outState.putParcelable(KEY_URI, mUri) if (mGalleryInfo != null) { outState.putParcelable(KEY_GALLERY_INFO, mGalleryInfo) } outState.putInt(KEY_PAGE, mPage) outState.putInt(KEY_CURRENT_INDEX, mCurrentIndex) } override fun attachBaseContext(newBase: Context) { delegate.localNightMode = when (Settings.readTheme) { 1 -> AppCompatDelegate.MODE_NIGHT_YES 2 -> AppCompatDelegate.MODE_NIGHT_NO else -> Settings.theme } super.attachBaseContext(newBase) } @Suppress("DEPRECATION") override fun onCreate(savedInstanceState: Bundle?) { if (Settings.readingFullscreen) { window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) } super.onCreate(savedInstanceState) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } builder = EditTextDialogBuilder(this, null, getString(R.string.archive_passwd)) builder.setTitle(getString(R.string.archive_need_passwd)) builder.setPositiveButton(getString(android.R.string.ok), null) dialog = builder.create() dialog.setCanceledOnTouchOutside(false) mGalleryProvider.let { if (it == null) { finish() return } initializeGallery() lifecycleScope.launchIO { it.start() if (it.awaitReady()) withUIContext { setGallery() } } } } private fun initializeGallery() { setContentView(R.layout.activity_gallery) mGLRootView = ViewUtils.`$$`(this, R.id.gl_root_view) as GLRootView mMaskView = ViewUtils.`$$`(this, R.id.mask) as ColorView mClock = ViewUtils.`$$`(this, R.id.clock) mProgress = ViewUtils.`$$`(this, R.id.progress) as TextView mBattery = ViewUtils.`$$`(this, R.id.battery) mSeekBarPanel = ViewUtils.`$$`(this, R.id.seek_bar_panel) mGLLoading = ViewUtils.`$$`(this, R.id.gl_loading) mLeftText = ViewUtils.`$$`(mSeekBarPanel, R.id.left) as TextView mRightText = ViewUtils.`$$`(mSeekBarPanel, R.id.right) as TextView mSeekBar = ViewUtils.`$$`(mSeekBarPanel, R.id.seek_bar) as ReversibleSeekBar mAutoTransfer = ViewUtils.`$$`(mSeekBarPanel, R.id.auto_transfer) as ImageView mClock!!.visibility = if (Settings.showClock) View.VISIBLE else View.GONE mProgress!!.visibility = if (Settings.showProgress) View.VISIBLE else View.GONE mBattery!!.visibility = if (Settings.showBattery) View.VISIBLE else View.GONE mMaskView!!.setOnGenericMotionListener { _: View?, event: MotionEvent -> if (mGalleryView == null) { return@setOnGenericMotionListener false } if (event.action == MotionEvent.ACTION_SCROLL) { val scroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * 300 val isNext = scroll < 0.0f when (mLayoutMode) { GalleryView.LAYOUT_RIGHT_TO_LEFT -> { mGalleryView?.run { if (isNext) pageLeft() else pageRight() } } GalleryView.LAYOUT_LEFT_TO_RIGHT -> { mGalleryView?.run { if (isNext) pageRight() else pageLeft() } } GalleryView.LAYOUT_TOP_TO_BOTTOM -> { mGalleryView?.onScroll(0f, -scroll, 0f, -scroll, 0f, -scroll) } } } false } mSeekBar!!.setOnSeekBarChangeListener(this) mAutoTransfer!!.setOnClickListener { autoTransfer() } WindowCompat.setDecorFitsSystemWindows(window, false) insetsController = WindowCompat.getInsetsController(window, window.decorView) if (Settings.readingFullscreen) { insetsController!!.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE insetsController!!.hide(WindowInsetsCompat.Type.systemBars()) } else { insetsController!!.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT insetsController!!.show(WindowInsetsCompat.Type.systemBars()) } val night = resources.configuration.isNight() insetsController!!.isAppearanceLightStatusBars = !night // Cutout if (isAtLeastP) { window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } val galleryHeader = findViewById(R.id.gallery_header) ViewCompat.setOnApplyWindowInsetsListener(galleryHeader) { _: View?, insets: WindowInsetsCompat -> if (!Settings.readingFullscreen) { galleryHeader.setTopInsets(insets.getInsets(WindowInsetsCompat.Type.statusBars()).top) } else { galleryHeader.setDisplayCutout(insets.displayCutout) } WindowInsetsCompat.CONSUMED } // Screen lightness setScreenLightness(Settings.customScreenLightness, Settings.screenLightness) // Update keep screen on if (Settings.keepScreenOn) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } // Orientation requestedOrientation = when (Settings.screenRotation) { 0 -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED 1 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT 2 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE 3 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } // Guide if (Settings.guideGallery) { val mainLayout = ViewUtils.`$$`(this, R.id.main) as FrameLayout mainLayout.addView(GalleryGuideView(this)) } } private fun setGallery() { if (mGalleryProvider?.isReady != true) return // TODO: Not well place to call it dialog.dismiss() mGLLoading?.visibility = View.GONE mGLRootView?.visibility = View.VISIBLE // Get start page if (mCurrentIndex == 0) mCurrentIndex = if (mPage >= 0) mPage else mGalleryProvider!!.startPage mGalleryAdapter = GalleryAdapter(mGLRootView!!, mGalleryProvider!!) val resources = resources mGalleryView = GalleryView.Builder(this, mGalleryAdapter!!) .setListener(this) .setLayoutMode(Settings.readingDirection) .setScaleMode(Settings.pageScaling) .setStartPosition(Settings.startPosition) .setStartPage(mCurrentIndex) .setBackgroundColor(theme.resolveColor(android.R.attr.colorBackground)) .setPagerInterval(if (Settings.showPageInterval) resources.getDimensionPixelOffset(R.dimen.gallery_pager_interval) else 0) .setScrollInterval(if (Settings.showPageInterval) resources.getDimensionPixelOffset(R.dimen.gallery_scroll_interval) else 0) .setPageMinHeight(resources.getDimensionPixelOffset(R.dimen.gallery_page_min_height)) .setPageInfoInterval(resources.getDimensionPixelOffset(R.dimen.gallery_page_info_interval)) .setProgressColor(ResourcesUtils.getAttrColor(this, androidx.appcompat.R.attr.colorPrimary)) .setProgressSize(resources.getDimensionPixelOffset(R.dimen.gallery_progress_size)) .setPageTextColor(theme.resolveColor(android.R.attr.textColorSecondary)) .setPageTextSize(resources.getDimensionPixelOffset(R.dimen.gallery_page_text_size)) .setPageTextTypeface(Typeface.DEFAULT) .setErrorTextColor(this@GalleryActivity.getColor(R.color.red_500)) .setErrorTextSize(resources.getDimensionPixelOffset(R.dimen.gallery_error_text_size)) .setEmptyString(resources.getString(R.string.error_empty)) .build() mGLRootView!!.setContentPane(mGalleryView) mGalleryProvider!!.setListener(mGalleryAdapter) mGalleryProvider!!.setGLRoot(mGLRootView!!) if (mGalleryView != null) { mLayoutMode = mGalleryView!!.layoutMode } mSize = mGalleryProvider!!.size updateSlider() } private fun pageTurn(isPrevious: Boolean) { val isRTL = mLayoutMode == GalleryView.LAYOUT_RIGHT_TO_LEFT if (isPrevious xor isRTL) { mGalleryView?.pageLeft() } else { mGalleryView?.pageRight() } } private fun autoTransfer() { if (mAutoTransferJob == null && mCurrentIndex + 1 != mSize) { startAutoTransfer() } else { stopAutoTransfer() } } private fun startAutoTransfer() { mAutoTransfer?.setImageResource(R.drawable.v_pause_x24) mAutoTransferJob = lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { while (true) { delay(mTurnPageIntervalVal.coerceAtLeast(1) * 1000L) pageTurn(false) } } } } private fun stopAutoTransfer() { mAutoTransfer?.setImageResource(R.drawable.v_play_x24) mAutoTransferJob?.cancel() mAutoTransferJob = null } override fun onDestroy() { super.onDestroy() mAutoTransferJob?.cancel() mGLRootView = null mGalleryView = null if (mGalleryAdapter != null) { mGalleryAdapter!!.clearUploader() mGalleryAdapter = null } if (mGalleryProvider != null) { mGalleryProvider!!.setListener(null) mGalleryProvider!!.stop() mGalleryProvider = null } mMaskView = null mClock = null mProgress = null mBattery = null mSeekBarPanel = null mGLLoading = null mLeftText = null mRightText = null mSeekBar = null mAutoTransfer = null mAutoTransferJob = null SimpleHandler.getInstance().removeCallbacks(mHideSliderRunnable) } override fun onPause() { super.onPause() mGLRootView?.onPause() } override fun onResume() { super.onResume() mGLRootView?.onResume() } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { mGalleryView ?: return super.onKeyDown(keyCode, event) return when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { if (!Settings.volumePage) { false } else { val isPrevious = Settings.reverseVolumePage.xor(keyCode == KeyEvent.KEYCODE_VOLUME_UP) val shouldTurn = event.repeatCount % (Settings.volumePageInterval + 1) == 0 if (shouldTurn) pageTurn(isPrevious) true } } KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_DPAD_UP -> pageTurn(true).let { true } KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_DPAD_DOWN -> pageTurn(false).let { true } KeyEvent.KEYCODE_DPAD_LEFT -> mGalleryView!!.pageLeft().let { true } KeyEvent.KEYCODE_DPAD_RIGHT -> mGalleryView!!.pageRight().let { true } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MENU -> { onTapMenuArea() true } else -> false } || super.onKeyDown(keyCode, event) } override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { // Check volume if (Settings.volumePage) { if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP ) { return true } } // Check keyboard and Dpad return if (keyCode == KeyEvent.KEYCODE_PAGE_UP || keyCode == KeyEvent.KEYCODE_PAGE_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_SPACE || keyCode == KeyEvent.KEYCODE_MENU ) { true } else { super.onKeyUp(keyCode, event) } } @SuppressLint("SetTextI18n") private fun updateProgress() { if (mCurrentIndex + 1 == mSize) autoTransfer() mProgress?.text = if (mSize <= 0 || mCurrentIndex < 0) null else (mCurrentIndex + 1).toString() + "/" + mSize } @SuppressLint("SetTextI18n") private fun updateSlider() { if (mSeekBar == null || mRightText == null || mLeftText == null || mSize <= 0 || mCurrentIndex < 0) { return } val start: TextView val end: TextView if (mLayoutMode == GalleryView.LAYOUT_RIGHT_TO_LEFT) { start = mRightText!! end = mLeftText!! mSeekBar!!.setReverse(true) } else { start = mLeftText!! end = mRightText!! mSeekBar!!.setReverse(false) } start.text = (mCurrentIndex + 1).toString() end.text = mSize.toString() mSeekBar!!.max = mSize - 1 mSeekBar!!.progress = mCurrentIndex } @SuppressLint("SetTextI18n") override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { val start = if (mLayoutMode == GalleryView.LAYOUT_RIGHT_TO_LEFT) { mRightText } else { mLeftText } if (fromUser && null != start) { start.text = (progress + 1).toString() } if (fromUser && null != mGalleryView) { mGalleryView!!.setCurrentPage(progress) } } override fun onStartTrackingTouch(seekBar: SeekBar) { SimpleHandler.getInstance().removeCallbacks(mHideSliderRunnable) } override fun onStopTrackingTouch(seekBar: SeekBar) { SimpleHandler.getInstance().postDelayed(mHideSliderRunnable, HIDE_SLIDER_DELAY) } override fun onUpdateCurrentIndex(index: Int) { mGalleryProvider?.putStartPage(index) val task = mNotifyTaskPool.pop() ?: NotifyTask() task.setData(NOTIFY_KEY_CURRENT_INDEX, index) SimpleHandler.getInstance().post(task) } override fun onTapSliderArea() { val task = mNotifyTaskPool.pop() ?: NotifyTask() task.setData(NOTIFY_KEY_TAP_SLIDER_AREA, 0) SimpleHandler.getInstance().post(task) } override fun onTapMenuArea() { val task = mNotifyTaskPool.pop() ?: NotifyTask() task.setData(NOTIFY_KEY_TAP_MENU_AREA, 0) SimpleHandler.getInstance().post(task) } override fun onTapErrorText(index: Int) { val task = mNotifyTaskPool.pop() ?: NotifyTask() task.setData(NOTIFY_KEY_TAP_ERROR_TEXT, index) SimpleHandler.getInstance().post(task) } override fun onLongPressPage(index: Int) { val task = mNotifyTaskPool.pop() ?: NotifyTask() task.setData(NOTIFY_KEY_LONG_PRESS_PAGE, index) SimpleHandler.getInstance().post(task) } private fun showSlider(sliderPanel: View) { if (null != mSeekBarPanelAnimator) { mSeekBarPanelAnimator!!.cancel() mSeekBarPanelAnimator = null } sliderPanel.translationY = sliderPanel.height.toFloat() sliderPanel.visibility = View.VISIBLE mSeekBarPanelAnimator = ObjectAnimator.ofFloat(sliderPanel, "translationY", 0.0f) mSeekBarPanelAnimator!!.duration = SLIDER_ANIMATION_DURING mSeekBarPanelAnimator!!.interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR mSeekBarPanelAnimator!!.addUpdateListener(mUpdateSliderListener) mSeekBarPanelAnimator!!.addListener(mShowSliderListener) mSeekBarPanelAnimator!!.start() if (Settings.readingFullscreen) insetsController?.show(WindowInsetsCompat.Type.systemBars()) } private fun hideSlider(sliderPanel: View) { if (null != mSeekBarPanelAnimator) { mSeekBarPanelAnimator!!.cancel() mSeekBarPanelAnimator = null } mSeekBarPanelAnimator = ObjectAnimator.ofFloat(sliderPanel, "translationY", sliderPanel.height.toFloat()) mSeekBarPanelAnimator!!.duration = SLIDER_ANIMATION_DURING mSeekBarPanelAnimator!!.interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR mSeekBarPanelAnimator!!.addUpdateListener(mUpdateSliderListener) mSeekBarPanelAnimator!!.addListener(mHideSliderListener) mSeekBarPanelAnimator!!.start() if (Settings.readingFullscreen) insetsController?.hide(WindowInsetsCompat.Type.systemBars()) } /** * @param lightness 0 - 200 */ private fun setScreenLightness(enable: Boolean, lightness: Int) { var mLightness = lightness if (null == mMaskView) { return } val w = window val lp = w.attributes if (enable) { mLightness = MathUtils.clamp(mLightness, 0, 200) if (mLightness > 100) { mMaskView!!.setColor(0) // Avoid BRIGHTNESS_OVERRIDE_OFF, // screen may be off when lp.screenBrightness is 0.0f lp.screenBrightness = ((mLightness - 100) / 100.0f).coerceAtLeast(0.01f) } else { mMaskView!!.setColor(MathUtils.lerp(0xde, 0x00, mLightness / 100.0f) shl 24) lp.screenBrightness = 0.01f } } else { mMaskView!!.setColor(0) lp.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE } w.attributes = lp } private fun shareImage(page: Int) { if (null == mGalleryProvider) { return } val dir = AppConfig.getExternalTempDir() if (null == dir) { Toast.makeText(this, R.string.error_cant_create_temp_file, Toast.LENGTH_SHORT).show() return } val file = mGalleryProvider!!.save( page, UniFile.fromFile(dir)!!, mGalleryProvider!!.getImageFilename(page), ) if (file == null) { Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() return } val filename = file.name if (filename == null) { Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() return } var mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension( MimeTypeMap.getFileExtensionFromUrl(filename), ) if (TextUtils.isEmpty(mimeType)) { mimeType = "image/jpeg" } val uri = FileProvider.getUriForFile( this, BuildConfig.APPLICATION_ID + ".fileprovider", File(dir, filename), ) val intent = Intent() intent.action = Intent.ACTION_SEND intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.putExtra(Intent.EXTRA_STREAM, uri) if (mGalleryInfo != null) { intent.putExtra( Intent.EXTRA_TEXT, EhUrl.getGalleryDetailUrl(mGalleryInfo!!.gid, mGalleryInfo!!.token), ) } intent.setDataAndType(uri, mimeType) try { startActivity(Intent.createChooser(intent, getString(R.string.share_image))) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) Toast.makeText(this, R.string.error_cant_find_activity, Toast.LENGTH_SHORT).show() } } private fun copyImage(page: Int) { if (null == mGalleryProvider) { return } val dir = AppConfig.getExternalCopyTempDir() if (null == dir) { Toast.makeText(this, R.string.error_cant_create_temp_file, Toast.LENGTH_SHORT).show() return } val file = mGalleryProvider!!.save( page, UniFile.fromFile(dir)!!, mGalleryProvider!!.getImageFilename(page), ) if (file == null) { Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() return } val filename = file.name if (filename == null) { Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() return } val uri = FileProvider.getUriForFile( this, BuildConfig.APPLICATION_ID + ".fileprovider", File(dir, filename), ) val clipboardManager = getSystemService(ClipboardManager::class.java) if (clipboardManager != null) { val clipData = ClipData.newUri(contentResolver, "ehviewer", uri) clipboardManager.setPrimaryClip(clipData) Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() } } private fun saveImage(page: Int) { if (null == mGalleryProvider) { return } if (!isAtLeastQ && ContextCompat.checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE, ) != PackageManager.PERMISSION_GRANTED ) { mSavingPage = page requestStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) return } val filename = mGalleryProvider!!.getImageFilenameWithExtension(page) var mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension( MimeTypeMap.getFileExtensionFromUrl(filename), ) if (TextUtils.isEmpty(mimeType)) { mimeType = "image/jpeg" } val realPath: String val resolver = contentResolver val values = ContentValues() values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename) values.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis()) values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType) if (isAtLeastQ) { values.put( MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + File.separator + AppConfig.APP_DIRNAME, ) values.put(MediaStore.MediaColumns.IS_PENDING, 1) realPath = Environment.DIRECTORY_PICTURES + File.separator + AppConfig.APP_DIRNAME } else { val path = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), AppConfig.APP_DIRNAME, ) realPath = path.toString() if (!FileUtils.ensureDirectory(path)) { Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() return } values.put(MediaStore.MediaColumns.DATA, path.toString() + File.separator + filename) } val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) if (imageUri == null) { Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() return } if (!mGalleryProvider!!.save(page, UniFile.fromMediaUri(this, imageUri))) { try { resolver.delete(imageUri, null, null) } catch (e: Exception) { e.printStackTrace() } Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show() return } else if (isAtLeastQ) { val contentValues = ContentValues() contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) resolver.update(imageUri, contentValues, null, null) } Toast.makeText( this, getString(R.string.image_saved, realPath + File.separator + filename), Toast.LENGTH_SHORT, ).show() } private fun saveImageTo(page: Int, original: Boolean = false) { lifecycleScope.launchIO { if (null == mGalleryProvider) { return@launchIO } val dir = AppConfig.getExternalTempDir() if (null == dir) { withUIContext { Toast.makeText( this@GalleryActivity, R.string.error_cant_create_temp_file, Toast.LENGTH_SHORT, ).show() } return@launchIO } val file = if (original) { withUIContext { Toast.makeText( this@GalleryActivity, R.string.start_download_original, Toast.LENGTH_SHORT, ).show() } mGalleryProvider!!.downloadOriginal( page, UniFile.fromFile(dir)!!, mGalleryProvider!!.getImageFilename(page), ) } else { mGalleryProvider!!.save( page, UniFile.fromFile(dir)!!, mGalleryProvider!!.getImageFilename(page), ) } if (file == null) { withUIContext { Toast.makeText( this@GalleryActivity, R.string.error_cant_save_image, Toast.LENGTH_SHORT, ).show() } return@launchIO } val filename = file.name if (filename == null) { withUIContext { Toast.makeText( this@GalleryActivity, R.string.error_cant_save_image, Toast.LENGTH_SHORT, ).show() } return@launchIO } mCacheFileName = filename try { saveImageToLauncher.launch(filename) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) withUIContext { Toast.makeText( this@GalleryActivity, R.string.error_cant_find_activity, Toast.LENGTH_SHORT, ).show() } } } } private fun showPageDialog(page: Int) { val resources = this@GalleryActivity.resources val builder = AlertDialog.Builder(this@GalleryActivity) builder.setTitle(resources.getString(R.string.page_menu_title, page + 1)) val items = arrayListOf( getString(R.string.page_menu_refresh), getString(R.string.page_menu_share), getString(android.R.string.copy), getString(R.string.page_menu_save), getString(R.string.page_menu_save_to), ) if (ACTION_EH == mAction && !Settings.getDownloadOriginImage(false)) { items.add(getString(R.string.page_menu_download_original)) } pageDialogListener(builder, items.toTypedArray(), page) builder.show() } private fun pageDialogListener( builder: AlertDialog.Builder, items: Array, page: Int, ) { builder.setItems(items) { _: DialogInterface?, which: Int -> if (mGalleryProvider == null) { return@setItems } when (which) { 0 -> { mGalleryProvider!!.removeCache(page) mGalleryProvider!!.forceRequest(page) } 1 -> shareImage(page) 2 -> copyImage(page) 3 -> saveImage(page) 4 -> saveImageTo(page) 5 -> saveImageTo(page, true) } } } override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) galleryDetailUrl?.let { outContent.webUri = it.toUri() } } @SuppressLint("InflateParams", "UseSwitchCompatOrMaterialCode") private inner class GalleryMenuHelper(context: Context?) : DialogInterface.OnClickListener { val view: View = LayoutInflater.from(context).inflate(R.layout.dialog_gallery_menu, null) private val mScreenRotation: Spinner = view.findViewById(R.id.screen_rotation) private val mReadingDirection: Spinner = view.findViewById(R.id.reading_direction) private val mScaleMode: Spinner = view.findViewById(R.id.page_scaling) private val mStartPosition: Spinner = view.findViewById(R.id.start_position) private val mReadTheme: Spinner = view.findViewById(R.id.read_theme) private val mKeepScreenOn: Switch = view.findViewById(R.id.keep_screen_on) private val mShowClock: Switch = view.findViewById(R.id.show_clock) private val mShowProgress: Switch = view.findViewById(R.id.show_progress) private val mShowBattery: Switch = view.findViewById(R.id.show_battery) private val mShowPageInterval: Switch = view.findViewById(R.id.show_page_interval) private val mTurnPageInterval: SeekBar = view.findViewById(R.id.turn_page_interval) private val mVolumePage: Switch = view.findViewById(R.id.volume_page) private val mVolumePageInterval: SeekBar = view.findViewById(R.id.volume_page_interval) private val mReverseVolumePage: Switch = view.findViewById(R.id.reverse_volume_page) private val mReadingFullscreen: Switch = view.findViewById(R.id.reading_fullscreen) private val mCustomScreenLightness: Switch = view.findViewById(R.id.custom_screen_lightness) private val mScreenLightness: SeekBar = view.findViewById(R.id.screen_lightness) init { mScreenRotation.setSelection(Settings.screenRotation) mReadingDirection.setSelection(Settings.readingDirection) mScaleMode.setSelection(Settings.pageScaling) mStartPosition.setSelection(Settings.startPosition) mReadTheme.setSelection(Settings.readTheme) mKeepScreenOn.isChecked = Settings.keepScreenOn mShowClock.isChecked = Settings.showClock mShowProgress.isChecked = Settings.showProgress mShowBattery.isChecked = Settings.showBattery mShowPageInterval.isChecked = Settings.showPageInterval mTurnPageInterval.progress = Settings.turnPageInterval - 1 mVolumePage.isChecked = Settings.volumePage mVolumePage.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> (mVolumePageInterval.parent as? ViewGroup)?.visibility = if (isChecked) View.VISIBLE else View.GONE mReverseVolumePage.visibility = if (isChecked) View.VISIBLE else View.GONE } (mVolumePageInterval.parent as? ViewGroup)?.visibility = if (Settings.volumePage) View.VISIBLE else View.GONE mVolumePageInterval.progress = Settings.volumePageInterval mReverseVolumePage.visibility = if (Settings.volumePage) View.VISIBLE else View.GONE mReverseVolumePage.isChecked = Settings.reverseVolumePage mReadingFullscreen.isChecked = Settings.readingFullscreen mCustomScreenLightness.isChecked = Settings.customScreenLightness mCustomScreenLightness.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> mScreenLightness.visibility = if (isChecked) View.VISIBLE else View.GONE } mScreenLightness.progress = Settings.screenLightness mScreenLightness.visibility = if (Settings.customScreenLightness) View.VISIBLE else View.GONE } override fun onClick(dialog: DialogInterface, which: Int) { if (mGalleryView == null) { return } val screenRotation = mScreenRotation.selectedItemPosition val layoutMode = GalleryView.sanitizeLayoutMode(mReadingDirection.selectedItemPosition) val scaleMode = GalleryView.sanitizeScaleMode(mScaleMode.selectedItemPosition) val startPosition = GalleryView.sanitizeStartPosition(mStartPosition.selectedItemPosition) val readTheme = mReadTheme.selectedItemPosition val keepScreenOn = mKeepScreenOn.isChecked val showClock = mShowClock.isChecked val showProgress = mShowProgress.isChecked val showBattery = mShowBattery.isChecked val showPageInterval = mShowPageInterval.isChecked val turnPageInterval = mTurnPageInterval.progress + 1 val volumePage = mVolumePage.isChecked val volumePageInterval = mVolumePageInterval.progress val reverseVolumePage = mReverseVolumePage.isChecked val readingFullscreen = mReadingFullscreen.isChecked val customScreenLightness = mCustomScreenLightness.isChecked val screenLightness = mScreenLightness.progress val oldReadingFullscreen = Settings.readingFullscreen val oldReadTheme = Settings.readTheme Settings.putScreenRotation(screenRotation) Settings.putReadingDirection(layoutMode) Settings.putPageScaling(scaleMode) Settings.putStartPosition(startPosition) Settings.putReadTheme(readTheme) Settings.putKeepScreenOn(keepScreenOn) Settings.putShowClock(showClock) Settings.putShowProgress(showProgress) Settings.putShowBattery(showBattery) Settings.putShowPageInterval(showPageInterval) Settings.putTurnPageInterval(turnPageInterval) Settings.putVolumePage(volumePage) Settings.putVolumePageInterval(volumePageInterval) Settings.putReverseVolumePage(reverseVolumePage) Settings.putReadingFullscreen(readingFullscreen) Settings.putCustomScreenLightness(customScreenLightness) Settings.putScreenLightness(screenLightness) requestedOrientation = when (screenRotation) { 0 -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED 1 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT 2 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE 3 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } mGalleryView!!.layoutMode = layoutMode mGalleryView!!.setScaleMode(scaleMode) mGalleryView!!.setStartPosition(startPosition) if (keepScreenOn) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } mClock?.visibility = if (showClock) View.VISIBLE else View.GONE mProgress?.visibility = if (showProgress) View.VISIBLE else View.GONE mBattery?.visibility = if (showBattery) View.VISIBLE else View.GONE mGalleryView!!.setPagerInterval( if (showPageInterval) { resources.getDimensionPixelOffset( R.dimen.gallery_pager_interval, ) } else { 0 }, ) mGalleryView!!.setScrollInterval( if (showPageInterval) { resources.getDimensionPixelOffset( R.dimen.gallery_scroll_interval, ) } else { 0 }, ) mTurnPageIntervalVal = turnPageInterval setScreenLightness(customScreenLightness, screenLightness) // Update slider mLayoutMode = layoutMode updateSlider() if (oldReadingFullscreen != readingFullscreen || oldReadTheme != readTheme) { recreate() } } } private inner class NotifyTask : Runnable { private var mKey = 0 private var mValue = 0 fun setData(key: Int, value: Int) { mKey = key mValue = value } private fun onTapMenuArea() { val builder = AlertDialog.Builder(this@GalleryActivity) val helper = GalleryMenuHelper(builder.context) builder.setTitle(R.string.gallery_menu_title) .setView(helper.view) .setPositiveButton(android.R.string.ok, helper).show() } private fun onTapSliderArea() { if (mSeekBarPanel == null || mSize <= 0 || mCurrentIndex < 0) { return } SimpleHandler.getInstance().removeCallbacks(mHideSliderRunnable) if (mSeekBarPanel!!.isVisible) { hideSlider(mSeekBarPanel!!) } else { showSlider(mSeekBarPanel!!) SimpleHandler.getInstance().postDelayed(mHideSliderRunnable, HIDE_SLIDER_DELAY) } } private fun onTapErrorText(index: Int) { if (mGalleryProvider != null) { mGalleryProvider!!.forceRequest(index) } } private fun onLongPressPage(index: Int) { showPageDialog(index) } override fun run() { when (mKey) { NOTIFY_KEY_LAYOUT_MODE -> { mLayoutMode = mValue updateSlider() } NOTIFY_KEY_SIZE -> { mSize = mValue updateSlider() updateProgress() } NOTIFY_KEY_CURRENT_INDEX -> { mCurrentIndex = mValue updateSlider() updateProgress() } NOTIFY_KEY_TAP_MENU_AREA -> onTapMenuArea() NOTIFY_KEY_TAP_SLIDER_AREA -> onTapSliderArea() NOTIFY_KEY_TAP_ERROR_TEXT -> onTapErrorText(mValue) NOTIFY_KEY_LONG_PRESS_PAGE -> onLongPressPage(mValue) } mNotifyTaskPool.push(this) } } private inner class GalleryAdapter(glRootView: GLRootView, provider: GalleryProvider) : SimpleAdapter(glRootView, provider) { override fun onDataChanged() { super.onDataChanged() if (mGalleryProvider != null) { val size = mGalleryProvider!!.size val task = mNotifyTaskPool.pop() ?: NotifyTask() task.setData(NOTIFY_KEY_SIZE, size) SimpleHandler.getInstance().post(task) } } } companion object { const val ACTION_EH = "eh" const val KEY_ACTION = "action" const val KEY_FILENAME = "filename" const val KEY_URI = "uri" const val KEY_GALLERY_INFO = "gallery_info" const val KEY_PAGE = "page" const val KEY_CURRENT_INDEX = "current_index" private const val SLIDER_ANIMATION_DURING: Long = 150 private const val HIDE_SLIDER_DELAY: Long = 3000 private const val NOTIFY_KEY_LAYOUT_MODE = 0 private const val NOTIFY_KEY_SIZE = 1 private const val NOTIFY_KEY_CURRENT_INDEX = 2 private const val NOTIFY_KEY_TAP_SLIDER_AREA = 3 private const val NOTIFY_KEY_TAP_MENU_AREA = 4 private const val NOTIFY_KEY_TAP_ERROR_TEXT = 5 private const val NOTIFY_KEY_LONG_PRESS_PAGE = 6 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/MainActivity.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui import android.annotation.SuppressLint import android.app.UiModeManager import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.content.pm.verify.domain.DomainVerificationManager import android.content.pm.verify.domain.DomainVerificationUserState import android.content.res.Configuration import android.net.ConnectivityManager import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PersistableBundle import android.text.TextUtils import android.view.MenuItem import android.view.View import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.IdRes import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDelegate import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.core.view.GravityCompat import androidx.core.view.isVisible import androidx.drawerlayout.widget.DrawerLayout import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar import com.hippo.app.EditTextDialogBuilder import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUrlOpener import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.ListUrlBuilder import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser import com.hippo.ehviewer.client.parser.GalleryPageUrlParser import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.ehviewer.ui.scene.CookieSignInScene import com.hippo.ehviewer.ui.scene.DownloadsScene import com.hippo.ehviewer.ui.scene.FavoritesScene import com.hippo.ehviewer.ui.scene.GalleryCommentsScene import com.hippo.ehviewer.ui.scene.GalleryDetailScene import com.hippo.ehviewer.ui.scene.GalleryInfoScene import com.hippo.ehviewer.ui.scene.GalleryListScene import com.hippo.ehviewer.ui.scene.GalleryPreviewsScene import com.hippo.ehviewer.ui.scene.HistoryScene import com.hippo.ehviewer.ui.scene.ProgressScene import com.hippo.ehviewer.ui.scene.SecurityScene import com.hippo.ehviewer.ui.scene.SelectSiteScene import com.hippo.ehviewer.ui.scene.SignInScene import com.hippo.ehviewer.ui.scene.SolidScene import com.hippo.ehviewer.ui.scene.WebViewSignInScene import com.hippo.ehviewer.widget.EhStageLayout import com.hippo.scene.Announcer import com.hippo.scene.SceneFragment import com.hippo.scene.StageActivity import com.hippo.unifile.UniFile import com.hippo.unifile.sha1 import com.hippo.util.addTextToClipboard import com.hippo.util.getClipboardManager import com.hippo.util.getParcelableExtraCompat import com.hippo.util.getUrlFromClipboard import com.hippo.util.isAtLeastQ import com.hippo.util.isAtLeastS import com.hippo.widget.DrawerView import com.hippo.widget.LoadImageView import com.hippo.yorozuya.SimpleHandler import com.hippo.yorozuya.ViewUtils class MainActivity : StageActivity(), NavigationView.OnNavigationItemSelectedListener { private val settingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) refreshTopScene() } private lateinit var connectivityManager: ConnectivityManager private var mSnackBar: CoordinatorLayout? = null private var mDrawerLayout: DrawerLayout? = null private var mStageLayout: EhStageLayout? = null private var mNavView: NavigationView? = null private var mRightDrawer: DrawerView? = null private var mAvatar: LoadImageView? = null private var mDisplayName: TextView? = null private var mNavCheckedItem = 0 override var containerViewId: Int = R.id.fragment_container override var launchAnnouncer: Announcer = if (!TextUtils.isEmpty(Settings.security)) { Announcer(SecurityScene::class.java) } else if (EhUtils.needSignedIn()) { Announcer(SignInScene::class.java) } else if (Settings.selectSite) { Announcer(SelectSiteScene::class.java) } else { val args = Bundle() args.putString( GalleryListScene.KEY_ACTION, Settings.launchPageGalleryListSceneAction, ) Announcer(GalleryListScene::class.java).setArgs(args) } // Sometimes scene can't show directly private fun processAnnouncer(announcer: Announcer): Announcer { if (0 == sceneCount) { val newArgs = Bundle() newArgs.putString(SolidScene.KEY_TARGET_SCENE, announcer.clazz.name) newArgs.putBundle(SolidScene.KEY_TARGET_ARGS, announcer.args) if (!TextUtils.isEmpty(Settings.security)) { return Announcer(SecurityScene::class.java).setArgs(newArgs) } else if (EhUtils.needSignedIn()) { return Announcer(SignInScene::class.java).setArgs(newArgs) } else if (Settings.selectSite) { return Announcer(SelectSiteScene::class.java).setArgs(newArgs) } } return announcer } private fun handleIntent(intent: Intent?): Boolean { if (intent == null) { return false } val action = intent.action if (Intent.ACTION_VIEW == action) { val uri = intent.data ?: return false val announcer = EhUrlOpener.parseUrl(uri.toString()) if (announcer != null) { startScene(processAnnouncer(announcer)) return true } } else if (Intent.ACTION_SEND == action) { val type = intent.type if ("text/plain" == type) { val builder = ListUrlBuilder() builder.keyword = intent.getStringExtra(Intent.EXTRA_TEXT) startScene(processAnnouncer(GalleryListScene.getStartAnnouncer(builder))) return true } else if (type != null && type.startsWith("image/")) { val uri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM) if (null != uri) { UniFile.fromUri(this, uri)?.sha1()?.let { val builder = ListUrlBuilder() builder.mode = ListUrlBuilder.MODE_IMAGE_SEARCH builder.hash = it startScene(processAnnouncer(GalleryListScene.getStartAnnouncer(builder))) return true } } } } return false } override fun onUnrecognizedIntent(intent: Intent?) { val clazz = topSceneClass if (clazz != null && SolidScene::class.java.isAssignableFrom(clazz)) { // TODO the intent lost return } if (!handleIntent(intent)) { var handleUrl = false if (intent != null && Intent.ACTION_VIEW == intent.action) { handleUrl = true if (intent.data != null) { val url = intent.data.toString() EditTextDialogBuilder(this, url, "") .setTitle(R.string.error_cannot_parse_the_url) .setPositiveButton(android.R.string.copy) { _: DialogInterface?, _: Int -> this.addTextToClipboard( url, false, ) } .show() } } if (0 == sceneCount) { if (handleUrl) { finish() } else { val args = Bundle() args.putString( GalleryListScene.KEY_ACTION, Settings.launchPageGalleryListSceneAction, ) startScene(processAnnouncer(Announcer(GalleryListScene::class.java).setArgs(args))) } } } } override fun onStartSceneFromIntent(clazz: Class<*>, args: Bundle?): Announcer = processAnnouncer(Announcer(clazz).setArgs(args)) override fun onCreate2(savedInstanceState: Bundle?) { connectivityManager = getSystemService()!! setContentView(R.layout.activity_main) mSnackBar = ViewUtils.`$$`(this, R.id.snackbar) as CoordinatorLayout mStageLayout = ViewUtils.`$$`(this, R.id.fragment_container) as EhStageLayout mDrawerLayout = ViewUtils.`$$`(this, R.id.draw_view) as DrawerLayout mNavView = ViewUtils.`$$`(this, R.id.nav_view) as NavigationView mRightDrawer = ViewUtils.`$$`(this, R.id.right_drawer) as DrawerView if (mDrawerLayout != null) { mDrawerLayout!!.setStatusBarBackgroundColor(0) } if (mNavView != null) { val headerLayout = mNavView!!.getHeaderView(0) mAvatar = ViewUtils.`$$`(headerLayout, R.id.avatar) as LoadImageView mDisplayName = ViewUtils.`$$`(headerLayout, R.id.display_name) as TextView ViewUtils.`$$`(headerLayout, R.id.night_mode).setOnClickListener { val theme = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_YES <= 0 val target = if (((getSystemService(UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES) == theme) { AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } else if (theme) { AppCompatDelegate.MODE_NIGHT_YES } else { AppCompatDelegate.MODE_NIGHT_NO } AppCompatDelegate.setDefaultNightMode(target) Settings.putTheme(target) recreate() } updateProfile() mNavView!!.setNavigationItemSelectedListener(this) mAvatar!!.setOnClickListener { updateProfile() } } if (savedInstanceState == null) { checkDownloadLocation() if (Settings.meteredNetworkWarning) { checkMeteredNetwork() } if (isAtLeastS) { if (!Settings.appLinkVerifyTip) { try { checkAppLinkVerify() } catch (_: PackageManager.NameNotFoundException) { } } } } else { onRestore(savedInstanceState) } } @RequiresApi(Build.VERSION_CODES.S) @Throws(PackageManager.NameNotFoundException::class) private fun checkAppLinkVerify() { val manager = getSystemService(DomainVerificationManager::class.java) val userState = manager.getDomainVerificationUserState(packageName) ?: return var hasUnverified = false val hostToStateMap = userState.hostToStateMap for (key in hostToStateMap.keys) { val stateValue = hostToStateMap[key] if (stateValue == null || stateValue == DomainVerificationUserState.DOMAIN_STATE_VERIFIED || stateValue == DomainVerificationUserState.DOMAIN_STATE_SELECTED) { continue } hasUnverified = true break } if (hasUnverified) { AlertDialog.Builder(this) .setTitle(R.string.app_link_not_verified_title) .setMessage(R.string.app_link_not_verified_message) .setPositiveButton(R.string.open_settings) { _: DialogInterface?, _: Int -> try { val intent = Intent( android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, "package:$packageName".toUri(), ) startActivity(intent) } catch (_: Throwable) { val intent = Intent( android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri(), ) startActivity(intent) } } .setNegativeButton(android.R.string.cancel, null) .setNeutralButton(R.string.dont_show_again) { _: DialogInterface?, _: Int -> Settings.putAppLinkVerifyTip( true, ) } .show() } } private fun checkDownloadLocation() { val uniFile = Settings.downloadLocation // null == uniFile for first start if (null == uniFile || uniFile.ensureDir()) { return } AlertDialog.Builder(this) .setTitle(R.string.waring) .setMessage(R.string.invalid_download_location) .setPositiveButton(R.string.get_it, null) .show() } private fun checkMeteredNetwork() { if (connectivityManager.isActiveNetworkMetered) { if (isAtLeastQ && mSnackBar != null) { Snackbar.make( mSnackBar!!, R.string.metered_network_warning, Snackbar.LENGTH_LONG, ) .setAction(R.string.settings) { val panelIntent = Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY) startActivity(panelIntent) } .show() } else { showTip(R.string.metered_network_warning, BaseScene.LENGTH_LONG) } } } private fun onRestore(savedInstanceState: Bundle) { mNavCheckedItem = savedInstanceState.getInt(KEY_NAV_CHECKED_ITEM) } override fun onSaveInstanceState(outState: Bundle, outPersistentState: PersistableBundle) { super.onSaveInstanceState(outState, outPersistentState) outState.putInt(KEY_NAV_CHECKED_ITEM, mNavCheckedItem) } override fun onDestroy() { super.onDestroy() mDrawerLayout = null mNavView = null mRightDrawer = null mAvatar = null mDisplayName = null } override fun onResume() { super.onResume() setNavCheckedItem(mNavCheckedItem) checkClipboardUrl() } override fun onTransactScene() { super.onTransactScene() checkClipboardUrl() } private fun checkClipboardUrl() { SimpleHandler.getInstance().postDelayed({ if (!isSolid) { checkClipboardUrlInternal() } }, 300) } private val isSolid: Boolean get() { val topClass = topSceneClass return topClass == null || SolidScene::class.java.isAssignableFrom(topClass) } private fun createAnnouncerFromClipboardUrl(url: String): Announcer? { val result1 = GalleryDetailUrlParser.parse(url, false) if (result1 != null) { val args = Bundle() args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GID_TOKEN) args.putLong(GalleryDetailScene.KEY_GID, result1.gid) args.putString(GalleryDetailScene.KEY_TOKEN, result1.token) return Announcer(GalleryDetailScene::class.java).setArgs(args) } val result2 = GalleryPageUrlParser.parse(url, false) if (result2 != null) { val args = Bundle() args.putString(ProgressScene.KEY_ACTION, ProgressScene.ACTION_GALLERY_TOKEN) args.putLong(ProgressScene.KEY_GID, result2.gid) args.putString(ProgressScene.KEY_PTOKEN, result2.pToken) args.putInt(ProgressScene.KEY_PAGE, result2.page) return Announcer(ProgressScene::class.java).setArgs(args) } return null } private fun checkClipboardUrlInternal() { val text = this.getClipboardManager().getUrlFromClipboard(this) val hashCode = text?.hashCode() ?: 0 if (text != null && hashCode != 0 && Settings.clipboardTextHashCode != hashCode) { val announcer = createAnnouncerFromClipboardUrl(text) if (announcer != null && mSnackBar != null) { val snackbar = Snackbar.make( mSnackBar!!, R.string.clipboard_gallery_url_snack_message, Snackbar.LENGTH_SHORT, ) snackbar.setAction(R.string.clipboard_gallery_url_snack_action) { startScene( announcer, ) } snackbar.show() } } Settings.putClipboardTextHashCode(hashCode) } override fun onSceneViewCreated(scene: SceneFragment, savedInstanceState: Bundle?) { super.onSceneViewCreated(scene, savedInstanceState) createDrawerView(scene) } @SuppressLint("RtlHardcoded") fun createDrawerView(scene: SceneFragment?) { if (scene is BaseScene && mRightDrawer != null && mDrawerLayout != null) { mRightDrawer!!.removeAllViews() val drawerView = scene.createDrawerView( scene.layoutInflater, mRightDrawer, null, ) if (drawerView != null) { mRightDrawer!!.addView(drawerView) mDrawerLayout!!.setDrawerLockMode( DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END, ) } else { mDrawerLayout!!.setDrawerLockMode( DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END, ) } } } override fun onSceneViewDestroyed(scene: SceneFragment) { super.onSceneViewDestroyed(scene) if (scene is BaseScene) { scene.destroyDrawerView() } } fun updateProfile() { if (mAvatar == null || mDisplayName == null) { return } val avatarUrl = Settings.avatar if (TextUtils.isEmpty(avatarUrl)) { mAvatar!!.load(R.drawable.default_avatar) } else { mAvatar!!.load(avatarUrl!!, avatarUrl) } val displayName = Settings.displayName if (TextUtils.isEmpty(displayName)) { mDisplayName!!.text = getString(R.string.default_display_name) } else { mDisplayName!!.text = displayName } } fun addAboveSnackView(view: View) { mStageLayout?.addAboveSnackView(view) } fun removeAboveSnackView(view: View) { mStageLayout?.removeAboveSnackView(view) } fun setDrawerLockMode(lockMode: Int, edgeGravity: Int) { mDrawerLayout?.setDrawerLockMode(lockMode, edgeGravity) } fun openDrawer(drawerGravity: Int) { mDrawerLayout?.openDrawer(drawerGravity) } fun closeDrawer(drawerGravity: Int) { mDrawerLayout?.closeDrawer(drawerGravity) } fun toggleDrawer(drawerGravity: Int) { mDrawerLayout?.run { if (isDrawerOpen(drawerGravity)) { closeDrawer(drawerGravity) } else { openDrawer(drawerGravity) } } } fun setNavCheckedItem(@IdRes resId: Int) { mNavCheckedItem = resId mNavView?.run { if (resId == 0) { setCheckedItem(R.id.nav_stub) } else { setCheckedItem(resId) } } } fun showTip(@StringRes id: Int, length: Int) { showTip(getString(id), length) } private val isDrawerOpen get() = mNavView?.isVisible == true || mRightDrawer?.isVisible == true /** * If activity is running, show snack bar, otherwise show toast */ fun showTip(message: CharSequence, length: Int) { if (mSnackBar != null && !isDrawerOpen) { Snackbar.make( mSnackBar!!, message, if (length == BaseScene.LENGTH_LONG) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT, ).show() } else { Toast.makeText( this, message, if (length == BaseScene.LENGTH_LONG) Toast.LENGTH_LONG else Toast.LENGTH_SHORT, ).show() } } @Deprecated("Deprecated in Java") override fun onBackPressed() { if (isDrawerOpen) { mDrawerLayout!!.closeDrawers() } else { @Suppress("DEPRECATION") super.onBackPressed() } } override fun onNavigationItemSelected(item: MenuItem): Boolean { // Don't select twice if (item.isChecked) { return false } val id = item.itemId when (id) { R.id.nav_homepage -> { val args = Bundle() args.putString(GalleryListScene.KEY_ACTION, GalleryListScene.ACTION_HOMEPAGE) startSceneFirstly( Announcer(GalleryListScene::class.java) .setArgs(args), ) } R.id.nav_subscription -> { val args = Bundle() args.putString(GalleryListScene.KEY_ACTION, GalleryListScene.ACTION_SUBSCRIPTION) startSceneFirstly( Announcer(GalleryListScene::class.java) .setArgs(args), ) } R.id.nav_whats_hot -> { val args = Bundle() args.putString(GalleryListScene.KEY_ACTION, GalleryListScene.ACTION_WHATS_HOT) startSceneFirstly( Announcer(GalleryListScene::class.java) .setArgs(args), ) } R.id.nav_toplist -> { val args = Bundle() args.putString(GalleryListScene.KEY_ACTION, GalleryListScene.ACTION_TOP_LIST) startSceneFirstly( Announcer(GalleryListScene::class.java) .setArgs(args), ) } R.id.nav_favourite -> { startScene(Announcer(FavoritesScene::class.java)) } R.id.nav_history -> { startScene(Announcer(HistoryScene::class.java)) } R.id.nav_downloads -> { startScene(Announcer(DownloadsScene::class.java)) } R.id.nav_settings -> { val intent = Intent(this, SettingsActivity::class.java) settingsLauncher.launch(intent) } } if (id != R.id.nav_stub) { mDrawerLayout?.closeDrawers() } return true } companion object { private const val KEY_NAV_CHECKED_ITEM = "nav_checked_item" init { registerLaunchMode(SecurityScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TASK) registerLaunchMode(SignInScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TASK) registerLaunchMode(WebViewSignInScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TASK) registerLaunchMode(CookieSignInScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TASK) registerLaunchMode(SelectSiteScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TASK) registerLaunchMode(GalleryListScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TOP) registerLaunchMode(GalleryDetailScene::class.java, SceneFragment.LAUNCH_MODE_STANDARD) registerLaunchMode(GalleryInfoScene::class.java, SceneFragment.LAUNCH_MODE_STANDARD) registerLaunchMode(GalleryCommentsScene::class.java, SceneFragment.LAUNCH_MODE_STANDARD) registerLaunchMode(GalleryPreviewsScene::class.java, SceneFragment.LAUNCH_MODE_STANDARD) registerLaunchMode(DownloadsScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TASK) registerLaunchMode(FavoritesScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TASK) registerLaunchMode(HistoryScene::class.java, SceneFragment.LAUNCH_MODE_SINGLE_TOP) registerLaunchMode(ProgressScene::class.java, SceneFragment.LAUNCH_MODE_STANDARD) } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/SettingsActivity.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui import android.os.Bundle import android.view.MenuItem import androidx.annotation.StringRes import androidx.fragment.app.FragmentTransaction import com.google.android.material.snackbar.Snackbar import com.hippo.ehviewer.R import com.hippo.ehviewer.ui.fragment.SettingsFragment import com.hippo.ehviewer.ui.scene.BaseScene class SettingsActivity : EhActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_preference) setSupportActionBar(findViewById(R.id.toolbar)) val bar = supportActionBar bar?.setDisplayHomeAsUpEnabled(true) if (savedInstanceState == null) { supportFragmentManager .beginTransaction() .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_MATCH_ACTIVITY_OPEN) .replace(R.id.fragment, SettingsFragment()) .commitAllowingStateLoss() } } fun showTip(@StringRes id: Int, length: Int) { showTip(getString(id), length) } fun showTip(message: CharSequence?, length: Int) { Snackbar.make( findViewById(R.id.snackbar), message!!, if (length == BaseScene.LENGTH_LONG) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT, ).show() } @Suppress("DEPRECATION") override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { onBackPressed() return true } return super.onOptionsItemSelected(item) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/WebViewActivity.kt ================================================ package com.hippo.ehviewer.ui import android.content.Context import android.content.Intent import android.os.Bundle import android.webkit.WebView import android.webkit.WebViewClient import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.util.setDefaultSettings class WebViewActivity : EhActivity() { private var webView: WebView? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val url = intent.extras?.getString(KEY_URL) ?: return webView = WebView(applicationContext).apply { setDefaultSettings() webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String) { val cloudflareBypassed = EhCookieStore.saveFromWebView(url) { it.name == EhCookieStore.KEY_CLOUDFLARE } if (cloudflareBypassed) { finish() } } } } setContentView(webView) EhCookieStore.loadForWebView(url) { it.name != EhCookieStore.KEY_CLOUDFLARE } webView!!.loadUrl(url) } override fun onDestroy() { super.onDestroy() webView?.destroy() webView = null } companion object { const val KEY_URL = "url" fun newIntent(context: Context, url: String): Intent = Intent(context, WebViewActivity::class.java).apply { putExtra(KEY_URL, url) } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/dialog/SelectItemWithIconAdapter.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.dialog import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources import com.hippo.ehviewer.R class SelectItemWithIconAdapter( private val context: Context, private val texts: Array, private val icons: IntArray, ) : BaseAdapter() { private val inflater: LayoutInflater init { require(texts.size == icons.size) { "Length conflict" } inflater = LayoutInflater.from(context) } override fun getCount(): Int = texts.size override fun getItem(position: Int): Any = texts[position] override fun getItemId(position: Int): Long = position.toLong() override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val mConvertView = convertView ?: inflater.inflate(R.layout.dialog_item_select_with_icon, parent, false) val view = mConvertView as TextView view.text = texts[position] val icon = AppCompatResources.getDrawable(context, icons[position]) icon!!.setBounds(0, 0, icon.intrinsicWidth, icon.intrinsicHeight) view.setCompoundDrawables(icon, null, null, null) return view } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/AboutFragment.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.fragment import android.os.Bundle import androidx.annotation.StringRes import androidx.preference.Preference import com.hippo.ehviewer.R import com.hippo.util.loadHtml class AboutFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.about_settings) val author = findPreference(KEY_AUTHOR) author!!.summary = loadHtml(getString(R.string.settings_about_author_summary).replace('$', '@')) } @get:StringRes override val fragmentTitle: Int get() = R.string.settings_about companion object { private const val KEY_AUTHOR = "author" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/AdvancedFragment.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.fragment import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings import android.util.Log import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDelegate import androidx.core.net.toUri import androidx.core.os.LocaleListCompat import androidx.lifecycle.coroutineScope import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import com.hippo.ehviewer.AppConfig import com.hippo.ehviewer.BuildConfig import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.GetText import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings as AppSettings import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhRequest import com.hippo.ehviewer.client.data.FavListUrlBuilder import com.hippo.ehviewer.client.parser.FavoritesParser import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.util.ExceptionUtils import com.hippo.util.LogCat import com.hippo.util.ReadableTime import com.hippo.util.isAtLeastS import com.hippo.util.launchIO import com.hippo.util.withUIContext import com.hippo.yorozuya.IOUtils import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlin.math.ceil import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @Suppress("BlockingMethodInNonBlockingContext") class AdvancedFragment : BasePreferenceFragment() { private var exportLauncher = registerForActivityResult( ActivityResultContracts.CreateDocument("application/octet-stream"), ) { uri: Uri? -> if (uri != null) { try { // grantUriPermission might throw RemoteException on MIUI requireActivity().grantUriPermission( BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } catch (e: Exception) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() } try { val alertDialog = AlertDialog.Builder(requireActivity()) .setCancelable(false) .setView(R.layout.preference_dialog_task) .show() lifecycleScope.launchIO { val success = EhDB.exportDB(requireActivity(), uri) withUIContext { if (alertDialog.isShowing) { alertDialog.dismiss() } showTip( if (success) { GetText.getString( R.string.settings_advanced_export_data_to, uri.toString(), ) } else { GetText.getString(R.string.settings_advanced_export_data_failed) }, BaseScene.LENGTH_SHORT, ) } } } catch (_: Exception) { showTip(R.string.settings_advanced_export_data_failed, BaseScene.LENGTH_SHORT) } } } private var dumpLogcatLauncher = registerForActivityResult( ActivityResultContracts.CreateDocument("application/zip"), ) { uri: Uri? -> if (uri != null) { try { // grantUriPermission might throw RemoteException on MIUI requireActivity().grantUriPermission( BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } catch (e: Exception) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() } try { val zipFile = File(AppConfig.getExternalTempDir(), "logs.zip") if (zipFile.exists()) { zipFile.delete() } val files = ArrayList() AppConfig.getExternalParseErrorDir()?.listFiles()?.let { files.addAll(it) } AppConfig.getExternalCrashDir()?.listFiles()?.let { files.addAll(it) } var finished = false var origin: BufferedInputStream? = null var out: ZipOutputStream? = null try { val dest = FileOutputStream(zipFile) out = ZipOutputStream(BufferedOutputStream(dest)) val bytes = ByteArray(1024 * 64) for (file in files) { if (!file.isFile) { continue } try { val fi = FileInputStream(file) origin = BufferedInputStream(fi, bytes.size) val entry = ZipEntry(file.name) out.putNextEntry(entry) var count: Int while (origin.read(bytes, 0, bytes.size).also { count = it } != -1) { out.write(bytes, 0, count) } origin.close() origin = null } catch (e: Exception) { e.printStackTrace() } } val entry = ZipEntry("logcat-" + ReadableTime.getFilenamableTime() + ".txt") out.putNextEntry(entry) LogCat.save(out) out.closeEntry() out.close() IOUtils.copy( FileInputStream(zipFile), requireActivity().contentResolver.openOutputStream(uri), ) finished = true } catch (e: Exception) { e.printStackTrace() } finally { origin?.close() out?.close() } if (!finished) { finished = LogCat.save(requireActivity().contentResolver.openOutputStream(uri)) } showTip( if (finished) { getString( R.string.settings_advanced_dump_logcat_to, uri.toString(), ) } else { getString(R.string.settings_advanced_dump_logcat_failed) }, BaseScene.LENGTH_SHORT, ) } catch (_: Exception) { showTip( getString(R.string.settings_advanced_dump_logcat_failed), BaseScene.LENGTH_SHORT, ) } } } private var importDataLauncher = registerForActivityResult, Uri>( ActivityResultContracts.OpenDocument(), ) { uri: Uri? -> if (uri != null) { try { // grantUriPermission might throw RemoteException on MIUI requireActivity().grantUriPermission( BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION, ) } catch (e: Exception) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() } try { val alertDialog = AlertDialog.Builder(requireActivity()) .setCancelable(false) .setView(R.layout.preference_dialog_task) .show() lifecycleScope.launchIO { val error = EhDB.importDB(requireActivity(), uri) withUIContext { if (alertDialog.isShowing) { alertDialog.dismiss() } if (null == error) { showTip( getString(R.string.settings_advanced_import_data_successfully), BaseScene.LENGTH_SHORT, ) } else { showTip(error, BaseScene.LENGTH_SHORT) } } } } catch (e: Exception) { showTip(e.localizedMessage, BaseScene.LENGTH_SHORT) } } } private var favTotal = 0 private var favIndex = 0 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.advanced_settings) val dumpLogcat = findPreference(KEY_DUMP_LOGCAT) val appLanguage = findPreference(AppSettings.KEY_APP_LANGUAGE) val importData = findPreference(KEY_IMPORT_DATA) val exportData = findPreference(KEY_EXPORT_DATA) val backupFavorite = findPreference(KEY_BACKUP_FAVORITE) val openByDefault = findPreference(KEY_OPEN_BY_DEFAULT) if (isAtLeastS) { openByDefault!!.onPreferenceClickListener = this } else { openByDefault!!.isVisible = false } dumpLogcat!!.onPreferenceClickListener = this importData!!.onPreferenceClickListener = this exportData!!.onPreferenceClickListener = this backupFavorite!!.onPreferenceClickListener = this appLanguage!!.onPreferenceChangeListener = this } override fun onPreferenceClick(preference: Preference): Boolean { val key = preference.key if (KEY_DUMP_LOGCAT == key) { try { dumpLogcatLauncher.launch("log-" + ReadableTime.getFilenamableTime() + ".zip") } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) showTip(R.string.error_cant_find_activity, BaseScene.LENGTH_SHORT) } return true } else if (KEY_IMPORT_DATA == key) { try { importDataLauncher.launch(arrayOf("*/*")) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) showTip(R.string.error_cant_find_activity, BaseScene.LENGTH_SHORT) } return true } else if (KEY_EXPORT_DATA == key) { try { exportLauncher.launch(ReadableTime.getFilenamableTime() + ".db") } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) showTip(R.string.error_cant_find_activity, BaseScene.LENGTH_SHORT) } return true } else if (KEY_BACKUP_FAVORITE == key) { try { backupFavorite() } catch (e: Exception) { ExceptionUtils.throwIfFatal(e) showTip(R.string.settings_advanced_backup_favorite_failed, BaseScene.LENGTH_SHORT) } return true } else if (KEY_OPEN_BY_DEFAULT == key) { try { @SuppressLint("InlinedApi") val intent = Intent( Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, "package:${requireContext().packageName}".toUri(), ) startActivity(intent) } catch (_: Throwable) { val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${requireContext().packageName}".toUri(), ) startActivity(intent) } return true } return false } private fun backupFavorite() { val mClient = EhClient val favListUrlBuilder = FavListUrlBuilder() favTotal = 0 favIndex = 1 val request = EhRequest() request.setMethod(EhClient.METHOD_GET_FAVORITES) request.setCallback(object : EhClient.Callback { override fun onSuccess(result: FavoritesParser.Result) { try { if (result.galleryInfoList.isEmpty()) { showTip( R.string.settings_advanced_backup_favorite_nothing, BaseScene.LENGTH_SHORT, ) } else { if (favTotal == 0) { var totalFav = 0 for (i in 0..9) { totalFav += result.countArray[i] } favTotal = ceil(totalFav.toDouble() / result.galleryInfoList.size).toInt() } val status = "($favIndex/$favTotal)" showTip( GetText.getString( R.string.settings_advanced_backup_favorite_start, status, ), BaseScene.LENGTH_SHORT, ) Log.d("LocalFavorites", "now backup page $status") EhDB.putLocalFavorites(result.galleryInfoList) if (result.next != null) { try { runBlocking { delay(AppSettings.downloadDelay.toLong()) } } catch (e: InterruptedException) { e.printStackTrace() } favIndex++ favListUrlBuilder.setIndex(result.next, true) request.setArgs(favListUrlBuilder.build()) viewLifecycleOwner.lifecycleScope.launch { delay(100) request.enqueue(this@AdvancedFragment) } } else { showTip( R.string.settings_advanced_backup_favorite_success, BaseScene.LENGTH_SHORT, ) } } } catch (_: Exception) { showTip( R.string.settings_advanced_backup_favorite_failed, BaseScene.LENGTH_SHORT, ) } } override fun onFailure(e: Exception) { showTip(R.string.settings_advanced_backup_favorite_failed, BaseScene.LENGTH_SHORT) } override fun onCancel() { showTip(R.string.settings_advanced_backup_favorite_failed, BaseScene.LENGTH_SHORT) } }) request.setArgs(favListUrlBuilder.build()) mClient.enqueue(request, viewLifecycleOwner.lifecycle.coroutineScope) } override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { val key = preference.key if (AppSettings.KEY_APP_LANGUAGE == key) { if ("system" == newValue) { AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) } else { AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(newValue as String)) } return true } return false } override val fragmentTitle: Int get() = R.string.settings_advanced companion object { private const val KEY_DUMP_LOGCAT = "dump_logcat" private const val KEY_IMPORT_DATA = "import_data" private const val KEY_EXPORT_DATA = "export_data" private const val KEY_OPEN_BY_DEFAULT = "open_by_default" private const val KEY_BACKUP_FAVORITE = "backup_favorite" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/BaseFragment.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.hippo.ehviewer.ui.SettingsActivity abstract class BaseFragment : Fragment() { override fun onStart() { super.onStart() setTitle(getFragmentTitle()) } abstract fun getFragmentTitle(): Int private fun setTitle(@StringRes string: Int) { requireActivity().setTitle(string) } fun showTip(@StringRes id: Int, length: Int) { (requireActivity() as SettingsActivity).showTip(getString(id), length) } fun showTip(message: CharSequence?, length: Int) { (requireActivity() as SettingsActivity).showTip(message, length) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/BasePreferenceFragment.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import android.os.Bundle import androidx.annotation.StringRes import androidx.fragment.app.FragmentTransaction import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.hippo.ehviewer.R import com.hippo.ehviewer.ui.SettingsActivity open class BasePreferenceFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener, Preference.OnPreferenceChangeListener { override fun onStart() { super.onStart() setTitle(fragmentTitle) } @get:StringRes open val fragmentTitle: Int get() = -1 override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean = false override fun onPreferenceClick(preference: Preference): Boolean = false override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {} override fun onPreferenceTreeClick(preference: Preference): Boolean { val fragment = when (preference.key) { "eh" -> EhFragment() "read" -> ReadFragment() "download" -> DownloadFragment() "privacy" -> PrivacyFragment() "advanced" -> AdvancedFragment() "about" -> AboutFragment() "uconfig" -> UConfigFragment() "mytags" -> MyTagsFragment() "filter" -> FilterFragment() "security" -> SetSecurityFragment() else -> null } fragment?.let { requireActivity().supportFragmentManager .beginTransaction() .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .replace(R.id.fragment, it) .addToBackStack(null) .commitAllowingStateLoss() } return true } private fun setTitle(@StringRes string: Int) { requireActivity().setTitle(string) } fun showTip(@StringRes id: Int, length: Int) { (requireActivity() as SettingsActivity).showTip(getString(id), length) } fun showTip(message: CharSequence?, length: Int) { (requireActivity() as SettingsActivity).showTip(message, length) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/DownloadFragment.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.fragment import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference import androidx.preference.Preference import com.hippo.ehviewer.AppConfig import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.ui.keepNoMediaFileStatus import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.unifile.UniFile import com.hippo.util.ExceptionUtils import com.hippo.util.launchNonCancellable class DownloadFragment : BasePreferenceFragment() { private var mDownloadLocation: Preference? = null private var pickImageDirLauncher = registerForActivityResult( ActivityResultContracts.OpenDocumentTree(), ) { treeUri: Uri? -> if (treeUri != null) { requireActivity().contentResolver.takePersistableUriPermission( treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) val uniFile = UniFile.fromTreeUri(requireActivity(), treeUri) if (uniFile != null) { Settings.putDownloadLocation(uniFile) lifecycleScope.launchNonCancellable { keepNoMediaFileStatus() } onUpdateDownloadLocation() } else { showTip( R.string.settings_download_cant_get_download_location, BaseScene.LENGTH_SHORT, ) } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.download_settings) mDownloadLocation = findPreference(Settings.KEY_DOWNLOAD_LOCATION) val mediaScan = findPreference(Settings.KEY_MEDIA_SCAN) val multiThreadDownload = findPreference(Settings.KEY_MULTI_THREAD_DOWNLOAD) val downloadDelay = findPreference(Settings.KEY_DOWNLOAD_DELAY) val preloadImage = findPreference(Settings.KEY_PRELOAD_IMAGE) val downloadOriginImage = findPreference(Settings.KEY_DOWNLOAD_ORIGIN_IMAGE) mDownloadLocation?.onPreferenceClickListener = this mediaScan!!.onPreferenceChangeListener = this multiThreadDownload!!.setSummaryProvider { getString(R.string.settings_download_concurrency_summary, (it as ListPreference).entry) } downloadDelay!!.setSummaryProvider { getString(R.string.settings_download_download_delay_summary, (it as ListPreference).entry) } preloadImage!!.setSummaryProvider { getString(R.string.settings_download_preload_image_summary, (it as ListPreference).entry) } downloadOriginImage!!.setSummaryProvider { getString(R.string.settings_download_download_origin_image_summary, (it as ListPreference).entry) } onUpdateDownloadLocation() } override fun onDestroy() { super.onDestroy() mDownloadLocation = null } private fun onUpdateDownloadLocation() { val file = Settings.downloadLocation if (mDownloadLocation != null) { if (file != null) { mDownloadLocation!!.summary = file.uri.toString() } else { mDownloadLocation!!.setSummary(R.string.settings_download_invalid_download_location) } } } override fun onPreferenceClick(preference: Preference): Boolean { val key = preference.key if (Settings.KEY_DOWNLOAD_LOCATION == key) { val file = Settings.downloadLocation if (file != null && !UniFile.isFileUri(Settings.downloadLocation!!.uri) ) { AlertDialog.Builder(requireContext()) .setTitle(R.string.settings_download_download_location) .setMessage(file.uri.toString()) .setPositiveButton(R.string.settings_download_pick_new_location) { _, _ -> openDirPickerL() } .setNeutralButton(R.string.settings_download_reset_location) { _, _ -> val uniFile = UniFile.fromFile(AppConfig.getDefaultDownloadDir()) if (uniFile != null) { Settings.putDownloadLocation(uniFile) lifecycleScope.launchNonCancellable { keepNoMediaFileStatus() } onUpdateDownloadLocation() } else { showTip( R.string.settings_download_cant_get_download_location, BaseScene.LENGTH_SHORT, ) } } .show() } else { openDirPickerL() } return true } return false } private fun openDirPickerL() { try { pickImageDirLauncher.launch(null) } catch (e: Throwable) { ExceptionUtils.throwIfFatal(e) showTip(R.string.error_cant_find_activity, BaseScene.LENGTH_SHORT) } } override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { val key = preference.key if (Settings.KEY_MEDIA_SCAN == key) { if (newValue is Boolean) { lifecycleScope.launchNonCancellable { keepNoMediaFileStatus() } } return true } return false } override val fragmentTitle: Int get() = R.string.settings_download } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/EhFragment.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.fragment import android.app.Activity import android.content.res.Configuration import android.os.Bundle import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhTagDatabase import com.hippo.util.launchNonCancellable class EhFragment : BasePreferenceFragment() { private lateinit var detailSize: Preference private lateinit var listThumbSize: Preference private lateinit var thumbSize: Preference private lateinit var thumbShowTitle: Preference override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.eh_settings) val account = findPreference(Settings.KEY_ACCOUNT) val gallerySite = findPreference(Settings.KEY_GALLERY_SITE) val theme = findPreference(Settings.KEY_THEME) val blackDarkTheme = findPreference(Settings.KEY_BLACK_DARK_THEME) val listMode = findPreference(Settings.KEY_LIST_MODE) val showTagTranslations = findPreference(Settings.KEY_SHOW_TAG_TRANSLATIONS) val tagTranslationsSource = findPreference(Settings.KEY_TAG_TRANSLATIONS_SOURCE) detailSize = findPreference(Settings.KEY_DETAIL_SIZE)!! listThumbSize = findPreference(Settings.KEY_LIST_THUMB_SIZE)!! thumbSize = findPreference(Settings.KEY_THUMB_SIZE)!! thumbShowTitle = findPreference(Settings.KEY_THUMB_SHOW_TITLE)!! gallerySite!!.onPreferenceChangeListener = this theme!!.onPreferenceChangeListener = this blackDarkTheme!!.onPreferenceChangeListener = this listMode!!.onPreferenceChangeListener = this showTagTranslations!!.onPreferenceChangeListener = this detailSize.onPreferenceChangeListener = this listThumbSize.onPreferenceChangeListener = this thumbSize.onPreferenceChangeListener = this thumbShowTitle.onPreferenceChangeListener = this Settings.displayName?.let { account?.summary = it } if (!EhTagDatabase.isTranslatable(requireActivity())) { if (!Settings.showTagTranslations) { preferenceScreen.removePreference(showTagTranslations) } preferenceScreen.removePreference(tagTranslationsSource!!) } if (!EhCookieStore.hasSignedIn()) { Settings.SIGN_IN_REQUIRED.forEach { val preference = findPreference(it) preferenceScreen.removePreference(preference!!) } } updateListPreference(Settings.listMode) } override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { val key = preference.key if (Settings.KEY_THEME == key) { AppCompatDelegate.setDefaultNightMode((newValue as String).toInt()) requireActivity().recreate() } else if (Settings.KEY_BLACK_DARK_THEME == key) { if (requireActivity().resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_YES > 0) { EhApplication.application.recreateAllActivity() } } else if (Settings.KEY_GALLERY_SITE == key) { requireActivity().setResult(Activity.RESULT_OK) lifecycleScope.launchNonCancellable { runCatching { EhEngine.getUConfig() }.onFailure { it.printStackTrace() } } } else if (Settings.KEY_LIST_MODE == key) { updateListPreference((newValue as String).toInt()) requireActivity().setResult(Activity.RESULT_OK) } else if (Settings.KEY_DETAIL_SIZE == key) { requireActivity().setResult(Activity.RESULT_OK) } else if (Settings.KEY_LIST_THUMB_SIZE == key) { requireActivity().setResult(Activity.RESULT_OK) } else if (Settings.KEY_THUMB_SIZE == key) { requireActivity().setResult(Activity.RESULT_OK) } else if (Settings.KEY_THUMB_SHOW_TITLE == key) { requireActivity().setResult(Activity.RESULT_OK) } else if (Settings.KEY_SHOW_TAG_TRANSLATIONS == key) { if (java.lang.Boolean.TRUE == newValue) { lifecycleScope.launchNonCancellable { EhTagDatabase.update(true) } } } return true } @get:StringRes override val fragmentTitle: Int get() = R.string.settings_eh private fun updateListPreference(newValue: Int) { val isDetailMode = newValue == 0 detailSize.isVisible = isDetailMode listThumbSize.isVisible = isDetailMode thumbSize.isVisible = !isDetailMode thumbShowTitle.isVisible = !isDetailMode } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/FilterFragment.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import android.annotation.SuppressLint import android.content.DialogInterface import android.os.Bundle import android.text.TextUtils import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.ImageView import android.widget.Spinner import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.textfield.TextInputLayout import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.LinearDividerItemDecoration import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhFilter import com.hippo.ehviewer.dao.Filter import com.hippo.view.ViewTransition import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.ViewUtils import rikka.core.res.resolveColor class FilterFragment : BaseFragment() { private var mViewTransition: ViewTransition? = null private var mAdapter: FilterAdapter = FilterAdapter() private var mFilterList: FilterList = FilterList() private val mMenuProvider: MenuProvider = FilterMenuProvider() inner class FilterMenuProvider : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_filter, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { val itemId = menuItem.itemId if (itemId == R.id.action_tip) { showTipDialog() return true } return false } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { val view = inflater.inflate(R.layout.activity_filter, container, false) val recyclerView: RecyclerView = ViewUtils.`$$`(view, R.id.recycler_view) as EasyRecyclerView val tip = ViewUtils.`$$`(view, R.id.tip) as TextView mViewTransition = ViewTransition(recyclerView, tip) val fab = view.findViewById(R.id.fab) val drawable = ContextCompat.getDrawable(requireActivity(), R.drawable.big_filter) drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) tip.setCompoundDrawables(null, drawable, null, null) mAdapter.setHasStableIds(true) recyclerView.adapter = mAdapter recyclerView.clipToPadding = false recyclerView.clipChildren = false val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, requireActivity().theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(requireActivity(), 1f), ) decoration.setShowLastDivider(true) recyclerView.addItemDecoration(decoration) recyclerView.layoutManager = LinearLayoutManager(requireContext()) recyclerView.setHasFixedSize(true) val defaultItemAnimator = recyclerView.itemAnimator as DefaultItemAnimator? defaultItemAnimator?.supportsChangeAnimations = false fab.setOnClickListener { showAddFilterDialog() } return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) updateView(false) requireActivity().addMenuProvider(mMenuProvider) } private fun updateView(animation: Boolean) { if (null == mViewTransition) { return } if (0 == mFilterList.size()) { mViewTransition!!.showView(1, animation) } else { mViewTransition!!.showView(0, animation) } } override fun onDestroyView() { super.onDestroyView() mViewTransition = null requireActivity().removeMenuProvider(mMenuProvider) } private fun showTipDialog() { AlertDialog.Builder(requireActivity()) .setTitle(R.string.filter) .setMessage(R.string.filter_tip) .setPositiveButton(android.R.string.ok, null) .show() } private fun showAddFilterDialog() { val dialog = AlertDialog.Builder(requireActivity()) .setTitle(R.string.add_filter) .setView(R.layout.dialog_add_filter) .setPositiveButton(R.string.add, null) .setNegativeButton(android.R.string.cancel, null) .show() AddFilterDialogHelper(dialog) } @SuppressLint("NotifyDataSetChanged") private fun showDeleteFilterDialog(filter: Filter) { val message = getString(R.string.delete_filter, filter.text) AlertDialog.Builder(requireActivity()) .setMessage(message) .setPositiveButton(R.string.delete) { _: DialogInterface?, which: Int -> if (DialogInterface.BUTTON_POSITIVE != which) { return@setPositiveButton } mFilterList.delete(filter) mAdapter.notifyDataSetChanged() updateView(true) } .setNegativeButton(android.R.string.cancel, null) .show() } override fun getFragmentTitle(): Int = R.string.filter private class FilterHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val checkbox: MaterialCheckBox? = itemView.findViewById(R.id.checkbox) val text: TextView? = itemView.findViewById(R.id.text) val delete: ImageView? = itemView.findViewById(R.id.delete) } private inner class AddFilterDialogHelper(dialog: AlertDialog) : View.OnClickListener { private var mDialog: AlertDialog = dialog private var mSpinner: Spinner = ViewUtils.`$$`(dialog, R.id.spinner) as Spinner private var mInputLayout: TextInputLayout = ViewUtils.`$$`(dialog, R.id.text_input_layout) as TextInputLayout private var mEditText: EditText = mInputLayout.editText!! init { val button: View? = dialog.getButton(DialogInterface.BUTTON_POSITIVE) button?.setOnClickListener(this) } @SuppressLint("NotifyDataSetChanged") override fun onClick(v: View) { val text = mEditText.text.toString().trim { it <= ' ' } if (TextUtils.isEmpty(text)) { mInputLayout.error = getString(R.string.text_is_empty) return } else { mInputLayout.error = null } val mode = mSpinner.selectedItemPosition val filter = Filter() filter.mode = mode filter.text = text if (!mFilterList.add(filter)) { mInputLayout.error = getString(R.string.label_text_exist) return } else { mInputLayout.error = null } mAdapter.notifyDataSetChanged() updateView(true) mDialog.dismiss() } } private inner class FilterAdapter : RecyclerView.Adapter() { override fun getItemViewType(position: Int): Int = if (mFilterList[position].mode == MODE_HEADER) { TYPE_HEADER } else { TYPE_ITEM } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FilterHolder { val layoutId: Int = when (viewType) { TYPE_ITEM -> R.layout.item_filter TYPE_HEADER -> R.layout.item_filter_header else -> R.layout.item_filter } return FilterHolder(layoutInflater.inflate(layoutId, parent, false)) } override fun onBindViewHolder(holder: FilterHolder, position: Int) { val filter = mFilterList[position] if (MODE_HEADER == filter.mode) { holder.text?.text = filter.text } else { holder.checkbox?.text = if (Settings.showTagTranslations) EhFilter.applyTranslation(filter) else filter.text holder.checkbox?.isChecked = filter.enable!! holder.itemView.setOnClickListener { mFilterList.trigger(filter) // for updating delete line on filter text mAdapter.notifyItemChanged(position) } holder.delete?.setOnClickListener { showDeleteFilterDialog(filter) } } } override fun getItemCount(): Int = mFilterList.size() override fun getItemId(position: Int): Long = run { val filter = mFilterList[position] if (filter.id != null) { (filter.text.hashCode() shr filter.mode) + filter.id!! } else { (filter.text.hashCode() shr filter.mode).toLong() } } } private inner class FilterList { private val mEhFilter: EhFilter = EhFilter private val mTitleFilterList: List = mEhFilter.titleFilterList private val mUploaderFilterList: List = mEhFilter.uploaderFilterList private val mTagFilterList: List = mEhFilter.tagFilterList private val mTagNamespaceFilterList: List = mEhFilter.tagNamespaceFilterList private val mCommenterFilterList: List = mEhFilter.commenterFilterList private val mCommentFilterList: List = mEhFilter.commentFilterList private var mTitleHeader: Filter? = null private var mUploaderHeader: Filter? = null private var mTagHeader: Filter? = null private var mTagNamespaceHeader: Filter? = null private var mCommenterHeader: Filter? = null private var mCommentHeader: Filter? = null fun size(): Int { var count = 0 var size = mTitleFilterList.size count += if (0 == size) 0 else size + 1 size = mUploaderFilterList.size count += if (0 == size) 0 else size + 1 size = mTagFilterList.size count += if (0 == size) 0 else size + 1 size = mTagNamespaceFilterList.size count += if (0 == size) 0 else size + 1 size = mCommenterFilterList.size count += if (0 == size) 0 else size + 1 size = mCommentFilterList.size count += if (0 == size) 0 else size + 1 return count } private val titleHeader: Filter get() { if (null == mTitleHeader) { mTitleHeader = Filter() mTitleHeader!!.mode = MODE_HEADER mTitleHeader!!.text = getString(R.string.filter_title) } return mTitleHeader!! } private val uploaderHeader: Filter get() { if (null == mUploaderHeader) { mUploaderHeader = Filter() mUploaderHeader!!.mode = MODE_HEADER mUploaderHeader!!.text = getString(R.string.filter_uploader) } return mUploaderHeader!! } private val tagHeader: Filter get() { if (null == mTagHeader) { mTagHeader = Filter() mTagHeader!!.mode = MODE_HEADER mTagHeader!!.text = getString(R.string.filter_tag) } return mTagHeader!! } private val tagNamespaceHeader: Filter get() { if (null == mTagNamespaceHeader) { mTagNamespaceHeader = Filter() mTagNamespaceHeader!!.mode = MODE_HEADER mTagNamespaceHeader!!.text = getString(R.string.filter_tag_namespace) } return mTagNamespaceHeader!! } private val commenterHeader: Filter get() { if (null == mCommenterHeader) { mCommenterHeader = Filter() mCommenterHeader!!.mode = MODE_HEADER mCommenterHeader!!.text = getString(R.string.filter_commenter) } return mCommenterHeader!! } private val commentHeader: Filter get() { if (null == mCommentHeader) { mCommentHeader = Filter() mCommentHeader!!.mode = MODE_HEADER mCommentHeader!!.text = getString(R.string.filter_comment) } return mCommentHeader!! } operator fun get(index: Int): Filter { var index1 = index var size = mTitleFilterList.size if (0 != size) { index1 -= if (index1 == 0) { return titleHeader } else if (index1 <= size) { return mTitleFilterList[index1 - 1] } else { size + 1 } } size = mUploaderFilterList.size if (0 != size) { index1 -= if (index1 == 0) { return uploaderHeader } else if (index1 <= size) { return mUploaderFilterList[index1 - 1] } else { size + 1 } } size = mTagFilterList.size if (0 != size) { index1 -= if (index1 == 0) { return tagHeader } else if (index1 <= size) { return mTagFilterList[index1 - 1] } else { size + 1 } } size = mTagNamespaceFilterList.size if (0 != size) { index1 -= if (index1 == 0) { return tagNamespaceHeader } else if (index1 <= size) { return mTagNamespaceFilterList[index1 - 1] } else { size + 1 } } size = mCommenterFilterList.size if (0 != size) { index1 -= if (index1 == 0) { return commenterHeader } else if (index1 <= size) { return mCommenterFilterList[index1 - 1] } else { size + 1 } } size = mCommentFilterList.size if (0 != size) { if (index1 == 0) { return commentHeader } else if (index1 <= size) { return mCommentFilterList[index1 - 1] } } throw IndexOutOfBoundsException() } fun add(filter: Filter): Boolean = mEhFilter.addFilter(filter) fun delete(filter: Filter) { mEhFilter.deleteFilter(filter) } fun trigger(filter: Filter) { mEhFilter.triggerFilter(filter) } } companion object { private const val MODE_HEADER = -1 private const val TYPE_ITEM = 0 private const val TYPE_HEADER = 1 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/MyTagsFragment.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import android.graphics.Bitmap import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import com.google.android.material.progressindicator.CircularProgressIndicator import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.util.setDefaultSettings import com.hippo.ehviewer.widget.DialogWebChromeClient import okhttp3.HttpUrl.Companion.toHttpUrl import rikka.core.res.resolveColor class MyTagsFragment : BaseFragment() { private val url = EhUrl.myTagsUrl private var webView: WebView? = null private var progress: CircularProgressIndicator? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.activity_webview, container, false) webView = view.findViewById(R.id.webview) webView!!.run { setBackgroundColor(requireActivity().theme.resolveColor(android.R.attr.colorBackground)) setDefaultSettings() webViewClient = MyTagsWebViewClient() webChromeClient = DialogWebChromeClient(requireContext()) } progress = view.findViewById(R.id.progress) return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) progress!!.visibility = View.VISIBLE webView!!.loadUrl(url) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // http://stackoverflow.com/questions/32284642/how-to-handle-an-uncatched-exception val cookieManager = CookieManager.getInstance() cookieManager.flush() cookieManager.removeAllCookies(null) cookieManager.removeSessionCookies(null) // Copy cookies from okhttp cookie store to CookieManager for (cookie in EhCookieStore.getCookies(url.toHttpUrl())) { cookieManager.setCookie(url, cookie.toString()) } } override fun getFragmentTitle(): Int = R.string.my_tags private inner class MyTagsWebViewClient : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { // Never load other urls return !request.url.toString().startsWith(this@MyTagsFragment.url) } override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { progress!!.visibility = View.VISIBLE } override fun onPageFinished(view: WebView, url: String) { progress!!.visibility = View.GONE } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/PrivacyFragment.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import android.os.Bundle import android.text.TextUtils import androidx.annotation.StringRes import androidx.preference.Preference import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings class PrivacyFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.privacy_settings) } override fun onStart() { super.onStart() val patternProtection = findPreference(KEY_PATTERN_PROTECTION) patternProtection!!.summary = if (TextUtils.isEmpty(Settings.security)) { getString(R.string.settings_privacy_pattern_protection_not_set) } else { getString(R.string.settings_privacy_pattern_protection_set) } } @get:StringRes override val fragmentTitle: Int get() = R.string.settings_privacy companion object { private const val KEY_PATTERN_PROTECTION = "security" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/ReadFragment.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.fragment import android.os.Bundle import androidx.annotation.StringRes import com.hippo.ehviewer.R class ReadFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.read_settings) } @get:StringRes override val fragmentTitle: Int get() = R.string.settings_read } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/SetSecurityFragment.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import android.os.Bundle import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.CheckBox import androidx.biometric.BiometricManager import androidx.core.view.isVisible import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.widget.lockpattern.LockPatternUtils import com.hippo.widget.lockpattern.LockPatternView import com.hippo.yorozuya.ViewUtils class SetSecurityFragment : BaseFragment(), View.OnClickListener { private var mPatternView: LockPatternView? = null private var mCancel: View? = null private var mSet: View? = null private var mFingerprint: CheckBox? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { val view = inflater.inflate(R.layout.activity_set_security, container, false) mPatternView = ViewUtils.`$$`(view, R.id.pattern_view) as LockPatternView mCancel = ViewUtils.`$$`(view, R.id.cancel) mSet = ViewUtils.`$$`(view, R.id.set) mFingerprint = ViewUtils.`$$`(view, R.id.fingerprint_checkbox) as CheckBox val pattern = Settings.security if (!TextUtils.isEmpty(pattern)) { mPatternView!!.setPattern( LockPatternView.DisplayMode.Correct, LockPatternUtils.stringToPattern(pattern), ) } if (BiometricManager.from(requireContext()).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS) { mFingerprint!!.visibility = View.VISIBLE mFingerprint!!.isChecked = Settings.enableFingerprint } mCancel!!.setOnClickListener(this) mSet!!.setOnClickListener(this) return view } override fun onDestroyView() { super.onDestroyView() mPatternView = null } @Suppress("DEPRECATION") override fun onClick(v: View) { if (v == mCancel) { requireActivity().onBackPressed() } else if (v == mSet) { if (null != mPatternView && null != mFingerprint) { val security = if (mPatternView!!.cellSize <= 1) { "" } else { mPatternView!!.patternString } Settings.putSecurity(security) Settings.putEnableFingerprint( mFingerprint!!.isVisible && mFingerprint!!.isChecked && security.isNotEmpty(), ) } requireActivity().onBackPressed() } } override fun getFragmentTitle(): Int = R.string.set_pattern_protection } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/SettingsFragment.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import android.os.Bundle import androidx.annotation.StringRes import com.hippo.ehviewer.R class SettingsFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.settings_headers) } @get:StringRes override val fragmentTitle: Int get() = R.string.settings } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/fragment/UConfigFragment.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.ui.fragment import android.graphics.Bitmap import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import com.google.android.material.progressindicator.CircularProgressIndicator import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.ehviewer.util.setDefaultSettings import com.hippo.ehviewer.widget.DialogWebChromeClient import com.hippo.util.launchIO import kotlinx.coroutines.DelicateCoroutinesApi import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl import rikka.core.res.resolveColor class UConfigFragment : BaseFragment() { private val url = EhUrl.uConfigUrl private var webView: WebView? = null private var progress: CircularProgressIndicator? = null private var loaded = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.activity_webview, container, false) webView = view.findViewById(R.id.webview) webView!!.run { setBackgroundColor(requireActivity().theme.resolveColor(android.R.attr.colorBackground)) setDefaultSettings() webViewClient = UConfigWebViewClient() webChromeClient = DialogWebChromeClient(requireContext()) } progress = view.findViewById(R.id.progress) return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) progress!!.visibility = View.VISIBLE webView!!.loadUrl(url) showTip(R.string.apply_tip, BaseScene.LENGTH_LONG) requireActivity().addMenuProvider( object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_u_config, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { if (menuItem.itemId == R.id.action_apply) { if (loaded) apply() return true } return false } }, viewLifecycleOwner, Lifecycle.State.RESUMED, ) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // http://stackoverflow.com/questions/32284642/how-to-handle-an-uncatched-exception val cookieManager = CookieManager.getInstance() cookieManager.flush() cookieManager.removeAllCookies(null) cookieManager.removeSessionCookies(null) // Copy cookies from okhttp cookie store to CookieManager for (cookie in EhCookieStore.getCookies(url.toHttpUrl())) { cookieManager.setCookie(url, cookie.toString()) } } private fun apply() { webView?.loadUrl("javascript: document.getElementById('apply').children[0].click();") } private fun longLive(cookie: Cookie): Cookie = Cookie.Builder() .name(cookie.name) .value(cookie.value) .domain(cookie.domain) .path(cookie.path) .expiresAt(Long.MAX_VALUE) .build() @OptIn(DelicateCoroutinesApi::class) override fun onDestroyView() { super.onDestroyView() webView?.destroy() webView = null val cookiesString = CookieManager.getInstance().getCookie(url) if (!cookiesString.isNullOrEmpty()) { val hostUrl = EhUrl.host.toHttpUrl() launchIO { EhCookieStore.deleteCookie(hostUrl, EhCookieStore.KEY_SETTINGS_PROFILE) for (header in cookiesString.split(";".toRegex()).dropLastWhile { it.isEmpty() }) { Cookie.parse(hostUrl, header)?.let { if (it.name == EhCookieStore.KEY_CLOUDFLARE || it.name == EhCookieStore.KEY_SETTINGS_PROFILE) { EhCookieStore.addCookie(longLive(it)) } } } } } } override fun getFragmentTitle(): Int = R.string.u_config private inner class UConfigWebViewClient : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { // Never load other urls return true } override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { progress!!.visibility = View.VISIBLE loaded = false } override fun onPageFinished(view: WebView, url: String) { progress!!.visibility = View.GONE loaded = true } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/BaseScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.annotation.SuppressLint import android.content.res.Configuration import android.content.res.Resources import android.content.res.Resources.Theme import android.os.Bundle import android.os.Parcelable import android.util.SparseArray import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver.OnPreDrawListener import androidx.annotation.IdRes import androidx.annotation.StringRes import androidx.core.view.GravityCompat import androidx.core.view.SoftwareKeyboardControllerCompat import androidx.core.view.WindowCompat import androidx.drawerlayout.widget.DrawerLayout import com.hippo.ehviewer.ui.MainActivity import com.hippo.scene.SceneFragment import com.hippo.util.getSparseParcelableArrayCompat import com.hippo.util.isAtLeastR abstract class BaseScene : SceneFragment() { private var drawerView: View? = null private var drawerViewState: SparseArray? = null open var needWhiteStatusBar = true fun updateAvatar() { val activity = activity if (activity is MainActivity) { activity.updateProfile() } } fun addAboveSnackView(view: View) { val activity = activity if (activity is MainActivity) { activity.addAboveSnackView(view) } } fun removeAboveSnackView(view: View) { val activity = activity if (activity is MainActivity) { activity.removeAboveSnackView(view) } } fun setDrawerLockMode(lockMode: Int, edgeGravity: Int) { val activity = activity if (activity is MainActivity) { activity.setDrawerLockMode(lockMode, edgeGravity) } } fun openDrawer(drawerGravity: Int) { val activity = activity if (activity is MainActivity) { activity.openDrawer(drawerGravity) } } fun closeDrawer(drawerGravity: Int) { val activity = activity if (activity is MainActivity) { activity.closeDrawer(drawerGravity) } } fun toggleDrawer(drawerGravity: Int) { val activity = activity if (activity is MainActivity) { activity.toggleDrawer(drawerGravity) } } fun showTip(message: CharSequence?, length: Int) { val activity = activity if (activity is MainActivity) { activity.showTip(message!!, length) } } fun showTip(@StringRes id: Int, length: Int) { val activity = activity if (activity is MainActivity) { activity.showTip(id, length) } } open fun needShowLeftDrawer(): Boolean = true open fun getNavCheckedItem(): Int = 0 /** * @param resId 0 for clear */ fun setNavCheckedItem(@IdRes resId: Int) { val activity = activity if (activity is MainActivity) { activity.setNavCheckedItem(resId) } } fun recreateDrawerView() { val activity = mainActivity activity?.createDrawerView(this) } fun createDrawerView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { drawerView = onCreateDrawerView(inflater, container, savedInstanceState) if (drawerView != null) { var saved = drawerViewState if (saved == null && savedInstanceState != null) { saved = savedInstanceState.getSparseParcelableArrayCompat(KEY_DRAWER_VIEW_STATE) } if (saved != null) { drawerView!!.restoreHierarchyState(saved) } } return drawerView } open fun onCreateDrawerView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? = null fun destroyDrawerView() { if (drawerView != null) { drawerViewState = SparseArray() drawerView!!.saveHierarchyState(drawerViewState) } onDestroyDrawerView() drawerView = null } @Suppress("DEPRECATION") fun setLightStatusBar(set: Boolean) { val activity = requireActivity() val decorView = activity.window.decorView val isLight = set && (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_YES) <= 0 // https://github.com/EhViewer-NekoInverter/EhViewer/issues/55 if (isAtLeastR) { WindowCompat.getInsetsController(activity.window, decorView).isAppearanceLightStatusBars = isLight } else { val flags = decorView.systemUiVisibility decorView.systemUiVisibility = if (isLight) { flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR } else { flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() } } needWhiteStatusBar = set } open fun onDestroyDrawerView() {} @SuppressLint("RtlHardcoded") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() view.viewTreeObserver.addOnPreDrawListener( object : OnPreDrawListener { override fun onPreDraw(): Boolean { view.viewTreeObserver.removeOnPreDrawListener(this) startPostponedEnterTransition() return true } }, ) // Update left drawer locked state if (needShowLeftDrawer()) { setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) } else { setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START) } // Update nav checked item setNavCheckedItem(getNavCheckedItem()) // Hide soft ime hideSoftInput() setLightStatusBar(needWhiteStatusBar) } val resourcesOrNull: Resources? get() { val context = context return context?.resources } val mainActivity: MainActivity? get() { val activity = activity return activity as? MainActivity } fun hideSoftInput() = activity?.window?.decorView?.run { SoftwareKeyboardControllerCompat(this) }?.hide() override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) if (drawerView != null) { drawerViewState = SparseArray() drawerView!!.saveHierarchyState(drawerViewState) outState.putSparseParcelableArray(KEY_DRAWER_VIEW_STATE, drawerViewState) } } val theme: Theme get() = requireActivity().theme companion object { const val LENGTH_SHORT = 0 const val LENGTH_LONG = 1 const val KEY_DRAWER_VIEW_STATE = "com.hippo.ehviewer.ui.scene.BaseScene:DRAWER_VIEW_STATE" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/CookieSignInScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.graphics.Paint import android.os.Bundle import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.TextView import android.widget.TextView.OnEditorActionListener import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.exception.CloudflareBypassException import com.hippo.util.ExceptionUtils import com.hippo.util.getClipboardManager import com.hippo.util.getTextFromClipboard import com.hippo.util.launchIO import com.hippo.util.withUIContext import com.hippo.yorozuya.ViewUtils import java.util.Locale import kotlinx.coroutines.Job import okhttp3.Cookie class CookieSignInScene : SolidScene(), OnEditorActionListener, View.OnClickListener { private var mProgress: View? = null private var mIpbMemberIdLayout: TextInputLayout? = null private var mIpbPassHashLayout: TextInputLayout? = null private var mIpbMemberId: EditText? = null private var mIpbPassHash: EditText? = null private var mOk: View? = null private var mFromClipboard: TextView? = null private var mSignInJob: Job? = null override fun needShowLeftDrawer(): Boolean = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_cookie_sign_in, container, false) val loginForm = ViewUtils.`$$`(view, R.id.cookie_signin_form) mProgress = ViewUtils.`$$`(view, R.id.progress) mIpbMemberIdLayout = ViewUtils.`$$`(loginForm, R.id.ipb_member_id_layout) as TextInputLayout mIpbMemberId = mIpbMemberIdLayout!!.editText!! mIpbPassHashLayout = ViewUtils.`$$`(loginForm, R.id.ipb_pass_hash_layout) as TextInputLayout mIpbPassHash = mIpbPassHashLayout!!.editText!! mOk = ViewUtils.`$$`(loginForm, R.id.ok) mFromClipboard = ViewUtils.`$$`(loginForm, R.id.from_clipboard) as TextView mFromClipboard!!.run { paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG } mIpbPassHash!!.setOnEditorActionListener(this) mOk!!.setOnClickListener(this) mFromClipboard!!.setOnClickListener(this) return view } override fun onDestroyView() { super.onDestroyView() mProgress = null mIpbMemberIdLayout = null mIpbPassHashLayout = null mIpbMemberId = null mIpbPassHash = null mSignInJob = null } private fun showProgress() { if (mProgress?.visibility == View.VISIBLE) return mProgress?.apply { alpha = 0f visibility = View.VISIBLE animate().alpha(1f).setDuration(500).start() } } private fun hideProgress() { mProgress?.visibility = View.GONE } override fun onClick(v: View) { when (v) { mOk -> enter() mFromClipboard -> fillCookiesFromClipboard() } } override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean { if (mIpbPassHash === v) { enter() } return true } fun enter() { if (mSignInJob?.isActive == true) return val memberIdField = mIpbMemberId ?: return val passHashField = mIpbPassHash ?: return val memberIdLayout = mIpbMemberIdLayout ?: return val passHashLayout = mIpbPassHashLayout ?: return val memberId = memberIdField.text.toString().trim() val passHash = passHashField.text.toString().trim() if (memberId.isEmpty()) { memberIdLayout.error = getString(R.string.text_is_empty) return } else { memberIdLayout.error = null } if (passHash.isEmpty()) { passHashLayout.error = getString(R.string.text_is_empty) return } else { passHashLayout.error = null } hideSoftInput() showProgress() mSignInJob = viewLifecycleOwner.lifecycleScope.launchIO { EhUtils.signOut() val result = runCatching { storeCookie(memberId, passHash) EhEngine.getProfile().also { Settings.putDisplayName(it.displayName) Settings.putAvatar(it.avatar) } } withUIContext { hideProgress() result.onSuccess { ok() }.onFailure { showResultErrorDialog(it) } } } } private fun ok() { setResult(RESULT_OK, null) finish() } private fun showResultErrorDialog(e: Throwable) { val isCloudflareError = e.cause is CloudflareBypassException val message = buildString { append(ExceptionUtils.getReadableString(e)) append("\n\n") append(getString(R.string.sign_in_failed_tip)) if (isCloudflareError) { append("\n\n") append(getString(R.string.sign_in_failed_tip_2)) } } AlertDialog.Builder(requireContext()) .setTitle(R.string.sign_in_failed) .setMessage(message) .setPositiveButton(R.string.get_it, null) .apply { if (isCloudflareError) { setNegativeButton(R.string.ignore) { _, _ -> ok() } } } .show() } private suspend fun storeCookie(id: String, hash: String) { fun newCookie(name: String, value: String, domain: String): Cookie = Cookie.Builder().name(name).value(value) .domain(domain).expiresAt(Long.MAX_VALUE).build() EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_E)) EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_EX)) EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_E)) EhCookieStore.addCookie(newCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_EX)) } private fun fillCookiesFromClipboard() { val context = requireContext() fun showClipboardError() = showTip(R.string.from_clipboard_error, LENGTH_SHORT) hideSoftInput() val clipboardText = context.getClipboardManager().getTextFromClipboard(context) if (clipboardText.isNullOrBlank()) { showClipboardError() return } val kvs = when { clipboardText.contains(";") -> clipboardText.split(";") clipboardText.contains("\n") -> clipboardText.split("\n") else -> { showClipboardError() return } }.map { it.trim() }.filter { it.isNotEmpty() } val hasRequiredKeys = clipboardText.contains(EhCookieStore.KEY_IPB_MEMBER_ID) && clipboardText.contains(EhCookieStore.KEY_IPB_PASS_HASH) if (!hasRequiredKeys || kvs.size < 2) { showClipboardError() return } try { kvs.forEach { entry -> val kv = when { entry.contains("=") -> entry.split("=") entry.contains(":") -> entry.split(":") else -> return@forEach } if (kv.size != 2) return@forEach val key = kv[0].trim().lowercase(Locale.getDefault()) val value = kv[1].trim().replace(Regex("[^a-zA-Z0-9\\-_.~]"), "") when (key) { EhCookieStore.KEY_IPB_MEMBER_ID -> mIpbMemberId?.setText(value) EhCookieStore.KEY_IPB_PASS_HASH -> mIpbPassHash?.setText(value) } } enter() } catch (e: Exception) { ExceptionUtils.throwIfFatal(e) e.printStackTrace() showClipboardError() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/DownloadsScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.annotation.SuppressLint import android.app.Activity import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.text.TextUtils import android.view.LayoutInflater import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.core.util.size import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.StaggeredGridLayoutManager import com.google.android.material.floatingactionbutton.FloatingActionButton import com.hippo.app.CheckBoxDialogBuilder import com.hippo.app.EditTextDialogBuilder import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.EasyRecyclerView.CustomChoiceListener import com.hippo.easyrecyclerview.FastScroller import com.hippo.easyrecyclerview.FastScroller.OnDragHandlerListener import com.hippo.easyrecyclerview.HandlerDrawable import com.hippo.easyrecyclerview.LinearDividerItemDecoration import com.hippo.easyrecyclerview.MarginItemDecoration import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.getThumbKey import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.download.DownloadManager.DownloadInfoListener import com.hippo.ehviewer.download.DownloadService import com.hippo.ehviewer.download.DownloadService.Companion.clear import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequest import com.hippo.ehviewer.spider.SpiderDen import com.hippo.ehviewer.ui.GalleryActivity import com.hippo.ehviewer.widget.SimpleRatingView import com.hippo.scene.Announcer import com.hippo.unifile.UniFile import com.hippo.util.launchIO import com.hippo.util.launchNonCancellable import com.hippo.util.launchUI import com.hippo.view.ViewTransition import com.hippo.widget.FabLayout import com.hippo.widget.FabLayout.OnClickFabListener import com.hippo.widget.LoadImageView import com.hippo.widget.recyclerview.AutoStaggeredGridLayoutManager import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.ObjectUtils import com.hippo.yorozuya.ViewUtils import com.hippo.yorozuya.collect.LongList import java.util.LinkedList import rikka.core.res.resolveColor @SuppressLint("RtlHardcoded") class DownloadsScene : ToolbarScene(), DownloadInfoListener, OnClickFabListener, OnDragHandlerListener { private lateinit var mLabels: MutableList private var mLabel: String? = null private var mList: MutableList? = null private var mTip: TextView? = null private var mFastScroller: FastScroller? = null private var mRecyclerView: EasyRecyclerView? = null private var mViewTransition: ViewTransition? = null private var mFabLayout: FabLayout? = null private var mAdapter: DownloadAdapter? = null private var mItemTouchHelper: ItemTouchHelper? = null private var mLayoutManager: AutoStaggeredGridLayoutManager? = null private var mLabelAdapter: DownloadLabelAdapter? = null private var mLabelItemTouchHelper: ItemTouchHelper? = null private var mKeyword: String? = null private var mSort = Settings.defaultSortingMethod private var mType = -1 private var mInitPosition = -1 override fun getNavCheckedItem(): Int = R.id.nav_downloads private fun initLabels() { val listLabel = DownloadManager.labelList mLabels = ArrayList(listLabel.size + LABEL_OFFSET) // Add "All" and "Default" label names mLabels.add(getString(R.string.download_all)) mLabels.add(getString(R.string.default_download_label_name)) listLabel.forEach { mLabels.add(it.label!!) } } private fun handleArguments(args: Bundle?): Boolean { if (null == args) { return false } if (ACTION_CLEAR_DOWNLOAD_SERVICE == args.getString(KEY_ACTION)) { clear() } val gid = args.getLong(KEY_GID, -1L) if (-1L != gid) { DownloadManager.getDownloadInfo(gid)?.let { mLabel = it.label updateForLabel() updateView() // Get position if (null != mList) { val position = mList!!.indexOf(it) if (position >= 0 && null != mRecyclerView) { mRecyclerView!!.scrollToPosition(position) } else { mInitPosition = position } } return true } } return false } override fun onNewArguments(args: Bundle) { handleArguments(args) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) DownloadManager.addDownloadInfoListener(this) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } override fun onDestroy() { super.onDestroy() mList = null DownloadManager.removeDownloadInfoListener(this) } @SuppressLint("NotifyDataSetChanged") private fun updateForLabel() { var list: MutableList? if (mLabel == null) { list = DownloadManager.allDownloadInfoList } else if (mLabel == getString(R.string.default_download_label_name)) { list = DownloadManager.defaultDownloadInfoList } else { list = DownloadManager.getLabelDownloadInfoList(mLabel) if (list == null) { mLabel = null list = DownloadManager.allDownloadInfoList } } if (mType != -1) { mList = ArrayList() list.forEach { if (mKeyword != null && EhUtils.getSuitableTitle(it).contains(mKeyword!!, true) || it.state == mType) { mList!!.add(it) } } } else { mList = list } if (mSort == 10) { mList = ArrayList(mList!!.shuffled()) } else { mList!!.sortWith { o1, o2 -> val title1 = EhUtils.getSuitableTitle(o1) val title2 = EhUtils.getSuitableTitle(o2) when (mSort) { 0 -> o2.time.compareTo(o1.time) 1 -> o1.time.compareTo(o2.time) 2 -> title1.compareTo(title2, true) 3 -> title2.compareTo(title1, true) 4 -> getAuthor(title1).compareTo(getAuthor(title2), true) 5 -> getAuthor(title2).compareTo(getAuthor(title1), true) 6 -> getName(title1).compareTo(getName(title2), true) 7 -> getName(title2).compareTo(getName(title1), true) 8 -> o1.category.compareTo(o2.category) 9 -> o2.category.compareTo(o1.category) else -> 0 } } } mAdapter?.notifyDataSetChanged() updateTitle() Settings.putRecentDownloadLabel(mLabel) } private fun updateTitle() { setTitle( getString( R.string.scene_download_title, if (mLabel != null) mLabel else getString(R.string.download_all), ), ) } private fun onInit() { if (!handleArguments(arguments)) { mLabel = Settings.recentDownloadLabel updateForLabel() } } private fun onRestore(savedInstanceState: Bundle) { mLabel = savedInstanceState.getString(KEY_LABEL) updateForLabel() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_LABEL, mLabel) } override fun onCreateViewWithToolbar( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_download, container, false) val content = ViewUtils.`$$`(view, R.id.content) mRecyclerView = ViewUtils.`$$`(content, R.id.recycler_view) as EasyRecyclerView mFastScroller = ViewUtils.`$$`(content, R.id.fast_scroller) as FastScroller mFabLayout = ViewUtils.`$$`(view, R.id.fab_layout) as FabLayout mTip = ViewUtils.`$$`(view, R.id.tip) as TextView mViewTransition = ViewTransition(content, mTip) val context = context val resources = context!!.resources val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.big_download) drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) mTip!!.setCompoundDrawables(null, drawable, null, null) mAdapter = DownloadAdapter() mAdapter!!.setHasStableIds(true) mRecyclerView!!.adapter = mAdapter mLayoutManager = AutoStaggeredGridLayoutManager(0, StaggeredGridLayoutManager.VERTICAL) mLayoutManager!!.setColumnSize(Settings.detailSize) mLayoutManager!!.setStrategy(AutoStaggeredGridLayoutManager.STRATEGY_MIN_SIZE) mRecyclerView!!.layoutManager = mLayoutManager mRecyclerView!!.clipToPadding = false mRecyclerView!!.clipChildren = false mRecyclerView!!.setChoiceMode(EasyRecyclerView.CHOICE_MODE_MULTIPLE_CUSTOM) mRecyclerView!!.setCustomCheckedListener(DownloadChoiceListener()) // Cancel change animation val itemAnimator = mRecyclerView!!.itemAnimator if (itemAnimator is SimpleItemAnimator) { itemAnimator.supportsChangeAnimations = false } val interval = resources.getDimensionPixelOffset(R.dimen.gallery_list_interval) val paddingH = resources.getDimensionPixelOffset(R.dimen.gallery_list_margin_h) val paddingV = resources.getDimensionPixelOffset(R.dimen.gallery_list_margin_v) val decoration = MarginItemDecoration(interval, paddingH, paddingV, paddingH, paddingV) mRecyclerView!!.addItemDecoration(decoration) if (mInitPosition >= 0) { mRecyclerView!!.scrollToPosition(mInitPosition) mInitPosition = -1 } mItemTouchHelper = ItemTouchHelper(DownloadItemTouchHelperCallback()) mItemTouchHelper!!.attachToRecyclerView(mRecyclerView) mFastScroller!!.attachToRecyclerView(mRecyclerView) val handlerDrawable = HandlerDrawable() handlerDrawable.setColor(theme.resolveColor(R.attr.widgetColorThemeAccent)) mFastScroller!!.setHandlerDrawable(handlerDrawable) mFastScroller!!.setOnDragHandlerListener(this) mFabLayout!!.setExpanded(expanded = false, animation = false) mFabLayout!!.setHidePrimaryFab(true) mFabLayout!!.setAutoCancel(false) mFabLayout!!.setOnClickFabListener(this) addAboveSnackView(mFabLayout!!) updateView() return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) updateTitle() setNavigationIcon(R.drawable.ic_baseline_menu_24) } override fun onDestroyView() { super.onDestroyView() if (null != mRecyclerView) { mRecyclerView!!.stopScroll() mRecyclerView = null } if (null != mFabLayout) { removeAboveSnackView(mFabLayout!!) mFabLayout = null } mRecyclerView = null mViewTransition = null mAdapter = null mLayoutManager = null } override fun onNavigationClick() { toggleDrawer(GravityCompat.START) } override fun getMenuResId(): Int = R.menu.scene_download override fun onMenuItemClick(item: MenuItem): Boolean { // Skip when in choice mode val activity: Activity? = mainActivity if (null == activity || null == mRecyclerView || mRecyclerView!!.isInCustomChoice) { return false } when (item.itemId) { R.id.action_filter -> { AlertDialog.Builder(requireActivity()) .setSingleChoiceItems( R.array.download_state, mType + 1, ) { dialog: DialogInterface, which: Int -> dialog.dismiss() if (which == 6) { showFilterTitleDialog() } else { mType = which - 1 mKeyword = null updateForLabel() updateView() } } .show() return true } R.id.action_start_all -> { val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START_ALL ContextCompat.startForegroundService(activity, intent) return true } R.id.action_stop_all -> { // DownloadManager Actions DownloadManager.stopAllDownload() return true } R.id.action_open_download_labels -> { openDrawer(GravityCompat.END) return true } R.id.action_reset_reading_progress -> { AlertDialog.Builder(requireContext()) .setMessage(R.string.reset_reading_progress_message) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> lifecycleScope.launchNonCancellable { // DownloadManager Actions DownloadManager.resetAllReadingProgress() } }.show() return true } R.id.action_start_all_reversed -> { val list = mList ?: return true val gidList = LongList() for (i in list.size - 1 downTo 0) { val info = list[i] if (info.state != DownloadInfo.STATE_FINISH) { gidList.add(info.gid) } } val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START_RANGE intent.putExtra(DownloadService.KEY_GID_LIST, gidList) ContextCompat.startForegroundService(activity, intent) return true } R.id.action_sort -> { AlertDialog.Builder(requireActivity()) .setSingleChoiceItems( R.array.download_sort, mSort, ) { dialog: DialogInterface, which: Int -> mSort = which Settings.putDefaultSortingMethod(which) dialog.dismiss() updateForLabel() updateView() } .show() return true } else -> return false } } private fun showFilterTitleDialog() { val builder = EditTextDialogBuilder( requireActivity(), null, getString(R.string.download_filter_title), ) builder.setTitle(R.string.search) builder.setPositiveButton(android.R.string.ok, null) val dialog = builder.show() val button: View? = dialog.getButton(DialogInterface.BUTTON_POSITIVE) button?.setOnClickListener { val text = builder.text.trim { it <= ' ' } if (TextUtils.isEmpty(text)) { builder.setError(getString(R.string.text_is_empty)) } else { builder.setError(null) dialog.dismiss() mType = 5 mKeyword = text.lowercase() updateForLabel() updateView() } } } fun updateView() { if (mList.isNullOrEmpty()) { mViewTransition?.showView(1) } else { mViewTransition?.showView(0) } } override fun onCreateDrawerView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.drawer_list_rv, container, false) val toolbar = view.findViewById(R.id.toolbar) toolbar.setTitle(R.string.download_labels) toolbar.inflateMenu(R.menu.drawer_download) toolbar.setOnMenuItemClickListener { item: MenuItem -> val id = item.itemId if (id == R.id.action_add) { val builder = EditTextDialogBuilder(requireContext(), null, getString(R.string.download_labels)) builder.setTitle(R.string.new_label_title) builder.setPositiveButton(android.R.string.ok, null) val dialog = builder.show() NewLabelDialogHelper(builder, dialog) return@setOnMenuItemClickListener true } else if (id == R.id.action_default_download_label) { val list = DownloadManager.labelList val items = arrayOfNulls(list.size + 2) items[0] = getString(R.string.let_me_select) items[1] = getString(R.string.default_download_label_name) var i = 0 val n = list.size while (i < n) { items[i + 2] = list[i].label i++ } AlertDialog.Builder(requireContext()) .setTitle(R.string.default_download_label) .setItems(items) { _: DialogInterface?, which: Int -> if (which == 0) { Settings.putHasDefaultDownloadLabel(false) } else { Settings.putHasDefaultDownloadLabel(true) val label: String? = if (which == 1) { null } else { items[which] } Settings.putDefaultDownloadLabel(label) } }.show() return@setOnMenuItemClickListener true } false } initLabels() mLabelAdapter = DownloadLabelAdapter(inflater) val recyclerView = view.findViewById(R.id.recycler_view_drawer) recyclerView.layoutManager = LinearLayoutManager(context) val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(context, 1f), ) decoration.setShowLastDivider(true) mLabelAdapter!!.setHasStableIds(true) mLabelItemTouchHelper = ItemTouchHelper(DownloadLabelItemTouchHelperCallback()) mLabelItemTouchHelper!!.attachToRecyclerView(recyclerView) recyclerView.adapter = mLabelAdapter return view } override fun onBackPressed() { if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.outOfCustomChoiceMode() } else { super.onBackPressed() } } override fun onStartDragHandler() { // Lock right drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END) } override fun onEndDragHandler() { // Restore right drawer if (null != mRecyclerView && !mRecyclerView!!.isInCustomChoice) { setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) } } override fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton) { if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.outOfCustomChoiceMode() } } override fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int) { val context = context val activity = mainActivity val recyclerView = mRecyclerView if (null == context || null == activity || null == recyclerView) { return } if (0 == position) { recyclerView.checkAll() } else { val list = mList ?: return var gidList: LongList? = null var downloadInfoList: MutableList? = null val collectGid = position == 2 || position == 3 || position == 4 // Start, Stop, Delete val collectDownloadInfo = position == 1 || position == 4 || position == 5 // Pin, Delete, Move if (collectGid) { gidList = LongList() } if (collectDownloadInfo) { downloadInfoList = LinkedList() } recyclerView.checkedItemPositions?.let { for (i in 0 until it.size) { if (it.valueAt(i)) { val info = list[it.keyAt(i)] if (collectDownloadInfo) { downloadInfoList!!.add(info) } if (collectGid) { gidList!!.add(info.gid) } } } } when (position) { // Pin to top 1 -> { val pinList = downloadInfoList!!.reversed() val nowTimeStamp = System.currentTimeMillis() for (i in pinList.indices) { pinList[i].time = nowTimeStamp + i // DB Actions EhDB.putDownloadInfo(pinList[i]) } recyclerView.outOfCustomChoiceMode() updateForLabel() } // Start 2 -> { val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START_RANGE intent.putExtra(DownloadService.KEY_GID_LIST, gidList) ContextCompat.startForegroundService(activity, intent) // Cancel check mode recyclerView.outOfCustomChoiceMode() } // Stop 3 -> { // DownloadManager Actions DownloadManager.stopRangeDownload(gidList!!) // Cancel check mode recyclerView.outOfCustomChoiceMode() } // Delete 4 -> { val builder = CheckBoxDialogBuilder( context, getString(R.string.download_remove_dialog_message_2, gidList!!.size), getString(R.string.download_remove_dialog_check_text), Settings.removeImageFiles, ) val helper = DeleteRangeDialogHelper( downloadInfoList!!, gidList, builder, ) builder.setTitle(R.string.download_remove_dialog_title) .setPositiveButton(android.R.string.ok, helper) .show() } // Move 5 -> { val labelRawList = DownloadManager.labelList val labelList: MutableList = ArrayList(labelRawList.size + 1) labelList.add(getString(R.string.default_download_label_name)) labelRawList.forEach { labelList.add(it.label!!) } val labels = labelList.toTypedArray() val helper = MoveDialogHelper(labels, downloadInfoList!!) AlertDialog.Builder(context) .setTitle(R.string.download_move_dialog_title) .setItems(labels, helper) .show() } } } } override fun onAdd(info: DownloadInfo, list: List, position: Int) { if (mList !== list) { return } mAdapter?.notifyItemInserted(position) updateView() } override fun onUpdate(info: DownloadInfo, list: List) { if (null == mList) { return } val index = mList!!.indexOf(info) if (index >= 0) { mAdapter?.notifyItemChanged(index, PAYLOAD_STATE) } } @SuppressLint("NotifyDataSetChanged") override fun onUpdateAll() { mAdapter?.notifyDataSetChanged() } @SuppressLint("NotifyDataSetChanged") override fun onReload() { mAdapter?.notifyDataSetChanged() updateView() } override fun onChange() { lifecycleScope.launchUI { mLabel = null updateForLabel() updateView() } } override fun onRenameLabel(from: String, to: String) { if (!ObjectUtils.equal(mLabel, from)) { return } mLabel = to updateForLabel() updateView() } override fun onRemove(info: DownloadInfo, list: List, position: Int) { if (mList !== list) { return } mAdapter?.notifyItemRemoved(position) updateView() } override fun onUpdateLabels() { // TODO } private fun bindForState(holder: DownloadHolder, info: DownloadInfo) { val context = context ?: return when (info.state) { DownloadInfo.STATE_NONE -> bindState( holder, info, context.getString(R.string.download_state_none), ) DownloadInfo.STATE_WAIT -> bindState( holder, info, context.getString(R.string.download_state_wait), ) DownloadInfo.STATE_DOWNLOAD -> bindProgress(holder, info) DownloadInfo.STATE_FAILED -> { val text: String = if (info.legacy <= 0) { context.getString(R.string.download_state_failed) } else { context.getString(R.string.download_state_failed_2, info.legacy) } bindState(holder, info, text) } DownloadInfo.STATE_FINISH -> bindState( holder, info, context.getString(R.string.download_state_finish), ) } } private fun bindState(holder: DownloadHolder, info: DownloadInfo, state: String) { holder.uploader.visibility = View.VISIBLE holder.rating.visibility = View.VISIBLE holder.category.visibility = View.VISIBLE holder.state.visibility = View.VISIBLE holder.progressBar.visibility = View.GONE holder.percent.visibility = View.GONE holder.speed.visibility = View.GONE if (info.state == DownloadInfo.STATE_WAIT || info.state == DownloadInfo.STATE_DOWNLOAD) { holder.start.visibility = View.GONE holder.stop.visibility = View.VISIBLE } else { holder.start.visibility = View.VISIBLE holder.stop.visibility = View.GONE } holder.state.text = state if (mSort == 0 && mType == -1) { holder.move.visibility = View.VISIBLE } else { holder.move.visibility = View.GONE } } @SuppressLint("SetTextI18n") private fun bindProgress(holder: DownloadHolder, info: DownloadInfo) { holder.uploader.visibility = View.GONE holder.rating.visibility = View.GONE holder.category.visibility = View.GONE holder.state.visibility = View.GONE holder.progressBar.visibility = View.VISIBLE holder.percent.visibility = View.VISIBLE holder.speed.visibility = View.VISIBLE if (info.state == DownloadInfo.STATE_WAIT || info.state == DownloadInfo.STATE_DOWNLOAD) { holder.start.visibility = View.GONE holder.stop.visibility = View.VISIBLE } else { holder.start.visibility = View.VISIBLE holder.stop.visibility = View.GONE } if (info.total <= 0 || info.finished < 0) { holder.percent.text = null holder.progressBar.isIndeterminate = true } else { holder.percent.text = info.finished.toString() + "/" + info.total holder.progressBar.isIndeterminate = false holder.progressBar.max = info.total holder.progressBar.progress = info.finished } var speed = info.speed if (speed < 0) { speed = 0 } holder.speed.text = FileUtils.humanReadableByteCount(speed, false) + "/S" } @SuppressLint("ClickableViewAccessibility") private inner class DownloadLabelHolder( itemView: View, ) : RecyclerView.ViewHolder(itemView), View.OnTouchListener { val label: TextView = ViewUtils.`$$`(itemView, R.id.tv_key) as TextView val option: ImageView = ViewUtils.`$$`(itemView, R.id.iv_option) as ImageView init { option.setOnTouchListener(this) } override fun onTouch(v: View, event: MotionEvent): Boolean { if (mLabelItemTouchHelper != null && event.action == MotionEvent.ACTION_DOWN) { mLabelItemTouchHelper!!.startDrag(this) } return false } } private inner class DownloadLabelAdapter(private val mInflater: LayoutInflater) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadLabelHolder { val holder = DownloadLabelHolder(mInflater.inflate(R.layout.item_drawer_list, parent, false)) holder.itemView.setOnClickListener { val index = holder.bindingAdapterPosition val label1: String? = if (index == 0) { null } else { mLabels[index] } if (!ObjectUtils.equal(label1, mLabel)) { mLabel = label1 updateForLabel() updateView() closeDrawer(GravityCompat.END) } } holder.itemView.setOnLongClickListener { val index = holder.bindingAdapterPosition if (index >= LABEL_OFFSET) { val popupMenu = PopupMenu(requireContext(), holder.option) popupMenu.inflate(R.menu.download_label_option) popupMenu.show() popupMenu.setOnMenuItemClickListener( object : PopupMenu.OnMenuItemClickListener { override fun onMenuItemClick(item: MenuItem): Boolean { val label = mLabels[index] when (item.itemId) { R.id.menu_label_rename -> { val builder = EditTextDialogBuilder( requireContext(), label, getString(R.string.download_labels), ) builder.setTitle(R.string.rename_label_title) builder.setPositiveButton(android.R.string.ok, null) val dialog = builder.show() RenameLabelDialogHelper(builder, dialog, label) return true } R.id.menu_label_remove -> { AlertDialog.Builder(requireContext()) .setTitle(R.string.delete_label_title) .setMessage(getString(R.string.delete_label_message, label)) .setPositiveButton(R.string.delete) { _: DialogInterface?, _: Int -> // DownloadManager Actions DownloadManager.deleteLabel(label) mLabels.removeAt(index) notifyItemRemoved(index) } .setNegativeButton(android.R.string.cancel, null) .show() return true } } return false } }, ) } return@setOnLongClickListener true } return holder } @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: DownloadLabelHolder, position: Int) { val index = holder.bindingAdapterPosition val label = mLabels[index] val list = when (position) { 0 -> { DownloadManager.allDownloadInfoList } 1 -> { DownloadManager.defaultDownloadInfoList } else -> { DownloadManager.getLabelDownloadInfoList(label) } } if (list != null) { holder.label.text = label + " [" + list.size + "]" } else { holder.label.text = label } if (position < LABEL_OFFSET) { holder.option.visibility = View.GONE } else { holder.option.visibility = View.VISIBLE } } override fun getItemId(position: Int): Long = (if (position < LABEL_OFFSET) position else mLabels[position].hashCode()).toLong() override fun getItemCount(): Int = mLabels.size } private inner class DeleteRangeDialogHelper( private val mDownloadInfoList: List, private val mGidList: LongList, private val mBuilder: CheckBoxDialogBuilder, ) : DialogInterface.OnClickListener { override fun onClick(dialog: DialogInterface, which: Int) { if (which != DialogInterface.BUTTON_POSITIVE) { return } // Cancel check mode if (mRecyclerView != null) { mRecyclerView!!.outOfCustomChoiceMode() } // Delete // DownloadManager Actions DownloadManager.deleteRangeDownload(mGidList) // Delete image files val checked = mBuilder.isChecked Settings.putRemoveImageFiles(checked) if (checked) { val files = arrayOfNulls(mDownloadInfoList.size) for ((i, info) in mDownloadInfoList.withIndex()) { // Put file files[i] = SpiderDen.getGalleryDownloadDir(info.gid) // DB Actions DownloadManager.removeDownloadDirname(info.gid) } // Other Actions lifecycleScope.launchIO { runCatching { files.forEach { it?.delete() } } } } } } private inner class MoveDialogHelper( private val mLabels: Array, private val mDownloadInfoList: List, ) : DialogInterface.OnClickListener { @SuppressLint("NotifyDataSetChanged") override fun onClick(dialog: DialogInterface, which: Int) { // Cancel check mode context ?: return if (null != mRecyclerView) { mRecyclerView!!.outOfCustomChoiceMode() } val label: String? = if (which == 0) { null } else { mLabels[which] } // DownloadManager Actions DownloadManager.changeLabel(mDownloadInfoList, label) mLabelAdapter?.notifyDataSetChanged() } } @SuppressLint("ClickableViewAccessibility") private inner class DownloadHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnTouchListener { val thumb: LoadImageView = itemView.findViewById(R.id.thumb) val title: TextView = itemView.findViewById(R.id.title) val uploader: TextView = itemView.findViewById(R.id.uploader) val rating: SimpleRatingView = itemView.findViewById(R.id.rating) val category: TextView = itemView.findViewById(R.id.category) val start: View = itemView.findViewById(R.id.start) val stop: View = itemView.findViewById(R.id.stop) val move: View = itemView.findViewById(R.id.move) val state: TextView = itemView.findViewById(R.id.state) val progressBar: ProgressBar = itemView.findViewById(R.id.progress_bar) val percent: TextView = itemView.findViewById(R.id.percent) val speed: TextView = itemView.findViewById(R.id.speed) init { // TODO cancel on click listener when select items thumb.setOnClickListener(this) start.setOnClickListener(this) stop.setOnClickListener(this) move.setOnTouchListener(this) } override fun onClick(v: View) { val context = context val activity: Activity? = mainActivity val recyclerView = mRecyclerView if (null == context || null == activity || null == recyclerView || recyclerView.isInCustomChoice) { return } val list = mList ?: return val size = list.size val index = recyclerView.getChildAdapterPosition(itemView) if (index < 0 || index >= size) { return } if (thumb === v) { val args = Bundle() args.putString( GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GALLERY_INFO, ) args.putParcelable(GalleryDetailScene.KEY_GALLERY_INFO, list[index]) val announcer = Announcer(GalleryDetailScene::class.java).setArgs(args) announcer.setTranHelper(EnterGalleryDetailTransaction(thumb)) startScene(announcer) } else if (start === v) { val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START intent.putExtra(DownloadService.KEY_GALLERY_INFO, list[index]) ContextCompat.startForegroundService(activity, intent) } else if (stop === v) { // DownloadManager Actions DownloadManager.stopDownload(list[index].gid) } } override fun onTouch(v: View, event: MotionEvent): Boolean { if (mItemTouchHelper != null && event.action == MotionEvent.ACTION_DOWN) { mItemTouchHelper!!.startDrag(this) } return false } } private inner class DownloadAdapter : RecyclerView.Adapter() { private val mInflater: LayoutInflater = layoutInflater private val mListThumbWidth: Int private val mListThumbHeight: Int init { @SuppressLint("InflateParams") val calculator = mInflater.inflate(R.layout.item_gallery_list_thumb_height, null) ViewUtils.measureView(calculator, 1024, ViewGroup.LayoutParams.WRAP_CONTENT) mListThumbHeight = calculator.measuredHeight mListThumbWidth = mListThumbHeight * 2 / 3 } override fun getItemId(position: Int): Long = if (mList == null || position < 0 || position >= mList!!.size) { 0 } else { mList!![position].gid } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadHolder { val holder = DownloadHolder(mInflater.inflate(R.layout.item_download, parent, false)) val lp = holder.thumb.layoutParams lp.width = mListThumbWidth lp.height = mListThumbHeight holder.thumb.layoutParams = lp return holder } override fun onBindViewHolder(holder: DownloadHolder, position: Int) { if (mList == null) { return } val info = mList!![holder.bindingAdapterPosition] info.thumb?.let { holder.thumb.load( getThumbKey(info.gid), encodeMagicRequest(info), hardware = false, ) } holder.title.text = EhUtils.getSuitableTitle(info) holder.uploader.text = info.uploader holder.rating.rating = info.rating val category = holder.category val newCategoryText = EhUtils.getCategory(info.category) if (!newCategoryText.contentEquals(category.text)) { category.text = newCategoryText category.setBackgroundColor(EhUtils.getCategoryColor(info.category)) } bindForState(holder, info) // Update transition name ViewCompat.setTransitionName( holder.thumb, TransitionNameFactory.getThumbTransitionName(info.gid), ) holder.itemView.setOnClickListener { if (mainActivity != null && mRecyclerView != null && mList != null) { val index = holder.bindingAdapterPosition if (mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.toggleItemChecked(index) } else { if (index in 0 until mList!!.size) { val intent = Intent(mainActivity!!, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, mList!![index]) startActivity(intent) } } } } holder.itemView.setOnLongClickListener { if (mRecyclerView != null) { if (!mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.intoCustomChoiceMode() } mRecyclerView!!.toggleItemChecked(holder.bindingAdapterPosition) return@setOnLongClickListener true } return@setOnLongClickListener false } } override fun onBindViewHolder( holder: DownloadHolder, position: Int, payloads: MutableList, ) { if (payloads.any { it == PAYLOAD_STATE }) { mList?.let { bindForState(holder, it[position]) } } else { super.onBindViewHolder(holder, position, payloads) } } override fun getItemCount(): Int = if (mList == null) 0 else mList!!.size } private inner class DownloadChoiceListener : CustomChoiceListener { override fun onIntoCustomChoice(view: EasyRecyclerView) { if (mFabLayout != null) { mFabLayout!!.isExpanded = true } // Lock drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END) } override fun onOutOfCustomChoice(view: EasyRecyclerView) { if (mFabLayout != null) { mFabLayout!!.isExpanded = false } // Unlock drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) } override fun onItemCheckedStateChanged( view: EasyRecyclerView, position: Int, id: Long, checked: Boolean, ) { if (view.checkedItemCount == 0) { view.outOfCustomChoiceMode() } } } private inner class RenameLabelDialogHelper( private val mBuilder: EditTextDialogBuilder, private val mDialog: AlertDialog, private val mOriginalLabel: String?, ) : View.OnClickListener { init { val button: Button = mDialog.getButton(DialogInterface.BUTTON_POSITIVE) button.setOnClickListener(this) } @SuppressLint("NotifyDataSetChanged") override fun onClick(v: View) { context ?: return val text = mBuilder.text if (TextUtils.isEmpty(text)) { mBuilder.setError(getString(R.string.label_text_is_empty)) } else if (getString(R.string.download_all) == text || getString(R.string.default_download_label_name) == text) { mBuilder.setError(getString(R.string.label_text_is_invalid)) } else if (DownloadManager.containLabel(text)) { mBuilder.setError(getString(R.string.label_text_exist)) } else { mBuilder.setError(null) mDialog.dismiss() // DownloadManager Actions DownloadManager.renameLabel(mOriginalLabel!!, text) if (mLabelAdapter != null) { initLabels() mLabelAdapter!!.notifyDataSetChanged() } } } } private inner class NewLabelDialogHelper( private val mBuilder: EditTextDialogBuilder, private val mDialog: AlertDialog, ) : View.OnClickListener { init { val button: Button = mDialog.getButton(DialogInterface.BUTTON_POSITIVE) button.setOnClickListener(this) } override fun onClick(v: View) { context ?: return val text = mBuilder.text if (TextUtils.isEmpty(text)) { mBuilder.setError(getString(R.string.label_text_is_empty)) } else if (getString(R.string.download_all) == text || getString(R.string.default_download_label_name) == text) { mBuilder.setError(getString(R.string.label_text_is_invalid)) } else if (DownloadManager.containLabel(text)) { mBuilder.setError(getString(R.string.label_text_exist)) } else { mBuilder.setError(null) mDialog.dismiss() // DownloadManager Actions DownloadManager.addLabel(text) initLabels() mLabelAdapter?.notifyItemInserted(mLabels.size - 1) } } } private inner class DownloadLabelItemTouchHelperCallback : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int { val position = viewHolder.bindingAdapterPosition return if (position < LABEL_OFFSET) { makeMovementFlags(0, 0) } else { makeMovementFlags( ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0, ) } } override fun isLongPressDragEnabled(): Boolean = false override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { val fromPosition = viewHolder.bindingAdapterPosition val toPosition = target.bindingAdapterPosition if (fromPosition == toPosition || toPosition < LABEL_OFFSET) { return false } // DownloadManager Actions DownloadManager.moveLabel(fromPosition - LABEL_OFFSET, toPosition - LABEL_OFFSET) val item = mLabels.removeAt(fromPosition) mLabels.add(toPosition, item) mLabelAdapter?.notifyItemMoved(fromPosition, toPosition) return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} } private inner class DownloadItemTouchHelperCallback : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int = makeMovementFlags( ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0, ) override fun isLongPressDragEnabled(): Boolean = false override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { val fromPosition = viewHolder.bindingAdapterPosition val toPosition = target.bindingAdapterPosition if (fromPosition == toPosition) { return false } // DownloadManager Actions when (mLabel) { null -> { DownloadManager.moveDownload(fromPosition, toPosition) } getString(R.string.default_download_label_name) -> { DownloadManager.moveDownload(null, fromPosition, toPosition) } else -> { DownloadManager.moveDownload(mLabel, fromPosition, toPosition) } } mAdapter?.notifyItemMoved(fromPosition, toPosition) return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} } companion object { const val KEY_GID = "gid" const val KEY_ACTION = "action" const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service" private val PATTERN_AUTHOR = Regex("^(?:\\([^\\[\\]()]+\\))?\\s*\\[([^\\[\\]]+)]") private val PATTERN_NAME = Regex("^(?:\\([^\\[\\]()]+\\))?\\s*(?:\\[[^\\[\\]]+])?\\s*(.+)") private const val KEY_LABEL = "label" private const val LABEL_OFFSET = 2 private const val PAYLOAD_STATE = 0 private fun getAuthor(title: String): String { val matcher = PATTERN_AUTHOR.find(title) ?: return "" return matcher.groupValues[1].trim { it <= ' ' } } private fun getName(title: String): String { val matcher = PATTERN_NAME.find(title) ?: return title return matcher.groupValues[1].trim { it <= ' ' } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/EhCallback.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.content.Context import android.widget.Toast import androidx.annotation.StringRes import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.ui.MainActivity import com.hippo.scene.SceneFragment abstract class EhCallback( context: Context, ) : EhClient.Callback { val application: EhApplication = context.applicationContext as EhApplication val content: Context get() { val context = application.topActivity return context ?: application } fun showTip(@StringRes id: Int, length: Int) { val activity = content if (activity is MainActivity) { activity.showTip(id, length) } else { Toast.makeText( application, id, if (length == BaseScene.LENGTH_LONG) Toast.LENGTH_LONG else Toast.LENGTH_SHORT, ).show() } } fun showTip(tip: String, length: Int) { val activity = content if (activity is MainActivity) { activity.showTip(tip, length) } else { Toast.makeText( application, tip, if (length == BaseScene.LENGTH_LONG) Toast.LENGTH_LONG else Toast.LENGTH_SHORT, ).show() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/EnterGalleryDetailTransaction.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.content.Context import android.view.View import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.transition.TransitionInflater import com.hippo.ehviewer.R import com.hippo.scene.TransitionHelper class EnterGalleryDetailTransaction( private val mThumb: View?, ) : TransitionHelper { override fun onTransition( context: Context, transaction: FragmentTransaction, exit: Fragment, enter: Fragment, ): Boolean { if (mThumb == null || enter !is GalleryDetailScene) { return false } ViewCompat.getTransitionName(mThumb)?.let { exit.sharedElementReturnTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_move) exit.exitTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_fade) enter.sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_move) enter.enterTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_fade) transaction.addSharedElement(mThumb, it) } return true } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/FavoritesScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.DialogInterface import android.content.res.Resources import android.net.Uri import android.os.Bundle import android.text.TextUtils import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.Toolbar import androidx.core.util.size import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.CalendarConstraints.DateValidator import com.google.android.material.datepicker.CompositeDateValidator import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.DateValidatorPointForward import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.floatingactionbutton.FloatingActionButton import com.hippo.drawable.AddDeleteDrawable import com.hippo.drawable.DrawerArrowDrawable import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.EasyRecyclerView.CustomChoiceListener import com.hippo.easyrecyclerview.FastScroller.OnDragHandlerListener import com.hippo.easyrecyclerview.LinearDividerItemDecoration import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.WindowInsetsAnimationHelper import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhRequest import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.data.FavListUrlBuilder import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.parser.FavoritesParser import com.hippo.ehviewer.ui.CommonOperations import com.hippo.ehviewer.widget.GalleryInfoContentHelper import com.hippo.ehviewer.widget.SearchBar import com.hippo.scene.Announcer import com.hippo.util.getParcelableCompat import com.hippo.util.toEpochMillis import com.hippo.widget.ContentLayout import com.hippo.widget.FabLayout import com.hippo.widget.FabLayout.OnClickFabListener import com.hippo.widget.FabLayout.OnExpandListener import com.hippo.widget.SearchBarMover import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.ObjectUtils import com.hippo.yorozuya.SimpleHandler import com.hippo.yorozuya.ViewUtils import kotlin.time.Clock import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.todayIn import rikka.core.res.resolveColor @SuppressLint("NotifyDataSetChanged", "RtlHardcoded") class FavoritesScene : BaseScene(), OnDragHandlerListener, SearchBarMover.Helper, SearchBar.Helper, OnClickFabListener, OnExpandListener, CustomChoiceListener { // For modify action private val mModifyGiList: MutableList = ArrayList() var current = 0 // -1 for error var limit = 0 // -1 for error private var mRecyclerView: EasyRecyclerView? = null private var mSearchBar: SearchBar? = null private var mFabLayout: FabLayout? = null private var mAdapter: FavoritesAdapter? = null private var mHelper: FavoritesHelper? = null private var mSearchBarMover: SearchBarMover? = null private var mLeftDrawable: DrawerArrowDrawable? = null private var mActionFabDrawable: AddDeleteDrawable? = null private var mDrawerLayout: DrawerLayout? = null private var mDrawerAdapter: FavDrawerAdapter? = null private var mClient: EhClient? = null private var mFavCatArray: Array? = Settings.favCat private var mFavCountArray: IntArray? = Settings.favCount private var mUrlBuilder: FavListUrlBuilder? = null private val showNormalFabsRunnable = Runnable { if (mFabLayout != null) { updateJumpFab() // Index: 1, 2 mFabLayout!!.setSecondaryFabVisibilityAt(0, true) mFabLayout!!.setSecondaryFabVisibilityAt(3, true) mFabLayout!!.setSecondaryFabVisibilityAt(4, false) mFabLayout!!.setSecondaryFabVisibilityAt(5, false) mFabLayout!!.setSecondaryFabVisibilityAt(6, false) mFabLayout!!.setSecondaryFabVisibilityAt(7, false) } } private var mFavLocalCount = 0 private var mFavCountSum = 0 private var mHasFirstRefresh = false private var mSearchMode = false // Avoid unnecessary search bar update private var mOldFavCat: String? = null private var mOldKeyword: String? = null // For modify action private var mEnableModify = false private var mModifyAdd = false private var mModifyFavCat = 0 private var mFavSlot = -2 override fun getNavCheckedItem(): Int = R.id.nav_favourite override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mClient = EhClient mFavLocalCount = Settings.favLocalCount mFavCountSum = Settings.favCloudCount if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } override fun onResume() { super.onResume() mAdapter?.type = Settings.listMode } private fun onInit() { mUrlBuilder = FavListUrlBuilder() mUrlBuilder!!.favCat = Settings.recentFavCat mSearchMode = false } private fun onRestore(savedInstanceState: Bundle) { mUrlBuilder = savedInstanceState.getParcelableCompat(KEY_URL_BUILDER) if (mUrlBuilder == null) { mUrlBuilder = FavListUrlBuilder() } mSearchMode = savedInstanceState.getBoolean(KEY_SEARCH_MODE) mHasFirstRefresh = savedInstanceState.getBoolean(KEY_HAS_FIRST_REFRESH) mFavCountArray = savedInstanceState.getIntArray(KEY_FAV_COUNT_ARRAY) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val hasFirstRefresh: Boolean = if (mHelper != null && 1 == mHelper!!.shownViewIndex) { false } else { mHasFirstRefresh } outState.putBoolean(KEY_HAS_FIRST_REFRESH, hasFirstRefresh) outState.putParcelable(KEY_URL_BUILDER, mUrlBuilder) outState.putBoolean(KEY_SEARCH_MODE, mSearchMode) outState.putIntArray(KEY_FAV_COUNT_ARRAY, mFavCountArray) } override fun onDestroy() { super.onDestroy() mClient = null mFavCatArray = null mFavCountArray = null mFavCountSum = 0 mUrlBuilder = null } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_favorites, container, false) val context = requireContext() val mContentLayout = view.findViewById(R.id.content_layout) val activity = mainActivity!! mDrawerLayout = ViewUtils.`$$`(activity, R.id.draw_view) as DrawerLayout mRecyclerView = mContentLayout.recyclerView val fastScroller = mContentLayout.fastScroller mSearchBar = ViewUtils.`$$`(view, R.id.search_bar) as SearchBar mFabLayout = ViewUtils.`$$`(view, R.id.fab_layout) as FabLayout ViewCompat.setWindowInsetsAnimationCallback( view, WindowInsetsAnimationHelper( WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP, mFabLayout, ), ) val paddingTopSB = resources.getDimensionPixelOffset(R.dimen.gallery_padding_top_search_bar) mHelper = FavoritesHelper() mHelper!!.setEmptyString(resources.getString(R.string.gallery_list_empty_hit)) mContentLayout.setHelper(mHelper!!) mContentLayout.fastScroller.setOnDragHandlerListener(this) mContentLayout.setFitPaddingTop(paddingTopSB) mAdapter = FavoritesAdapter(inflater, resources, mRecyclerView!!, Settings.listMode) mRecyclerView!!.clipToPadding = false mRecyclerView!!.clipChildren = false mRecyclerView!!.setChoiceMode(EasyRecyclerView.CHOICE_MODE_MULTIPLE_CUSTOM) mRecyclerView!!.setCustomCheckedListener(this) fastScroller.setPadding( fastScroller.paddingLeft, fastScroller.paddingTop + paddingTopSB, fastScroller.paddingRight, fastScroller.paddingBottom, ) mLeftDrawable = DrawerArrowDrawable(context, theme.resolveColor(android.R.attr.colorControlNormal)) mSearchBar!!.setLeftDrawable(mLeftDrawable!!) mSearchBar!!.setRightDrawable(AppCompatResources.getDrawable(context, R.drawable.v_magnify_x24)!!) mSearchBar!!.setHelper(this) mSearchBar!!.setAllowEmptySearch(false) updateSearchBar() updateJumpFab() mSearchBarMover = SearchBarMover(this, mSearchBar, mRecyclerView) mActionFabDrawable = AddDeleteDrawable(context, context.getColor(R.color.primary_drawable_dark)) mFabLayout!!.primaryFab!!.setImageDrawable(mActionFabDrawable) mFabLayout!!.setExpanded(expanded = false, animation = false) mFabLayout!!.setAutoCancel(true) mFabLayout!!.setHidePrimaryFab(false) mFabLayout!!.setOnClickFabListener(this) mFabLayout!!.setOnExpandListener(this) addAboveSnackView(mFabLayout!!) // Restore search mode if (mSearchMode) { mSearchMode = false enterSearchMode(false) } // Only refresh for the first time if (!mHasFirstRefresh) { mHasFirstRefresh = true mHelper!!.firstRefresh() } return view } // keyword of mUrlBuilder, fav cat of mUrlBuilder, mFavCatArray. // They changed, call it private fun updateSearchBar() { val context = context if (null == context || null == mUrlBuilder || null == mSearchBar || null == mFavCatArray) { return } // Update title val favCatName: String = when (val favCat = mUrlBuilder!!.favCat) { in 0..9 -> { mFavCatArray!![favCat] } FavListUrlBuilder.FAV_CAT_LOCAL -> { getString(R.string.local_favorites) } else -> { getString(R.string.cloud_favorites) } } val keyword = mUrlBuilder!!.keyword if (TextUtils.isEmpty(keyword)) { if (!ObjectUtils.equal(favCatName, mOldFavCat)) { mSearchBar!!.setTitle(getString(R.string.favorites_title, favCatName)) } } else { if (!ObjectUtils.equal(favCatName, mOldFavCat) || !ObjectUtils.equal( keyword, mOldKeyword, ) ) { mSearchBar!!.setTitle(getString(R.string.favorites_title_2, favCatName, keyword)) } } // Update hint if (!ObjectUtils.equal(favCatName, mOldFavCat)) { mSearchBar!!.setEditTextHint(getString(R.string.favorites_search_bar_hint, favCatName)) } mOldFavCat = favCatName mOldKeyword = keyword // Save recent fav cat Settings.putRecentFavCat(mUrlBuilder!!.favCat) } // Hide jump fab on local fav cat private fun updateJumpFab() { if (mFabLayout != null && mUrlBuilder != null) { mFabLayout!!.setSecondaryFabVisibilityAt( 1, mUrlBuilder!!.favCat != FavListUrlBuilder.FAV_CAT_LOCAL, ) mFabLayout!!.setSecondaryFabVisibilityAt( 2, mUrlBuilder!!.favCat != FavListUrlBuilder.FAV_CAT_LOCAL, ) } } override fun onDestroyView() { super.onDestroyView() if (null != mHelper) { mHelper!!.destroy() if (1 == mHelper!!.shownViewIndex) { mHasFirstRefresh = false } } if (null != mRecyclerView) { mRecyclerView!!.stopScroll() mRecyclerView = null } if (null != mFabLayout) { removeAboveSnackView(mFabLayout!!) mFabLayout = null } mAdapter = null mSearchBar = null mSearchBarMover = null mLeftDrawable = null mOldFavCat = null mOldKeyword = null } override fun onCreateDrawerView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { val view = inflater.inflate(R.layout.drawer_list_rv, container, false) val context = requireContext() val toolbar = ViewUtils.`$$`(view, R.id.toolbar) as Toolbar toolbar.setTitle(R.string.collections) toolbar.inflateMenu(R.menu.drawer_favorites) toolbar.setOnMenuItemClickListener { item: MenuItem -> val id = item.itemId if (id == R.id.action_default_favorites_slot) { val items = arrayOfNulls(12) items[0] = getString(R.string.let_me_select) items[1] = getString(R.string.local_favorites) val favCat = Settings.favCat System.arraycopy(favCat, 0, items, 2, 10) AlertDialog.Builder(context) .setTitle(R.string.default_favorites_collection) .setItems(items) { _: DialogInterface?, which: Int -> Settings.putDefaultFavSlot( which - 2, ) if (which == 0) { Settings.putNeverAddFavNotes(false) } } .show() return@setOnMenuItemClickListener true } false } val recyclerView = view.findViewById(R.id.recycler_view_drawer) recyclerView.layoutManager = LinearLayoutManager(context) val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(context, 1f), ) decoration.setShowLastDivider(true) recyclerView.addItemDecoration(decoration) mDrawerAdapter = FavDrawerAdapter(inflater) recyclerView.adapter = mDrawerAdapter return view } override fun onDestroyDrawerView() { super.onDestroyDrawerView() mDrawerAdapter = null } override fun onBackPressed() { if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.outOfCustomChoiceMode() } else if (mFabLayout != null && mFabLayout!!.isExpanded) { mFabLayout!!.toggle() } else if (mSearchMode) { exitSearchMode() } else { finish() } } override fun onStartDragHandler() { // Lock right drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END) } override fun onEndDragHandler() { // Restore right drawer if (null != mRecyclerView && !mRecyclerView!!.isInCustomChoice) { setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) } mSearchBarMover?.returnSearchBarPosition() } fun onItemClick(view: View, position: Int): Boolean { if (mDrawerLayout != null && mDrawerLayout!!.isDrawerOpen(GravityCompat.END)) { // Skip if in search mode if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { return false } if (mUrlBuilder == null || mHelper == null) { return false } // Local favorite position is 0, All favorite position is 1, so position - 2 is OK val newFavCat = position - 2 // Check is the same if (mUrlBuilder!!.favCat == newFavCat) { return true } exitSearchMode() mUrlBuilder!!.keyword = null mUrlBuilder!!.favCat = newFavCat updateSearchBar() updateJumpFab() mHelper!!.refresh() closeDrawer(GravityCompat.END) } else { if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.toggleItemChecked(position) } else if (mHelper != null) { val gi = mHelper!!.getDataAtEx(position) ?: return false val args = Bundle() args.putString( GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GALLERY_INFO, ) args.putParcelable(GalleryDetailScene.KEY_GALLERY_INFO, gi) val announcer = Announcer(GalleryDetailScene::class.java).setArgs(args) view.findViewById(R.id.thumb)?.let { announcer.setTranHelper(EnterGalleryDetailTransaction(it)) } startScene(announcer) } } return true } fun onItemLongClick(position: Int): Boolean { // Can not into if (mRecyclerView != null && !mSearchMode) { if (!mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.intoCustomChoiceMode() } mRecyclerView!!.toggleItemChecked(position) } return true } // SearchBarMover.Helper override fun isValidView(recyclerView: RecyclerView): Boolean = recyclerView == mRecyclerView // SearchBarMover.Helper override fun getValidRecyclerView(): RecyclerView? = mRecyclerView // SearchBarMover.Helper override fun forceShowSearchBar(): Boolean = false // SearchBar.Helper override fun onClickTitle() { // Skip if in search mode if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { return } if (!mSearchMode) { enterSearchMode(true) } } // SearchBar.Helper override fun onClickLeftIcon() { // Skip if in search mode if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { return } if (mSearchMode) { exitSearchMode() } else { toggleDrawer(GravityCompat.START) } } // SearchBar.Helper override fun onClickRightIcon() { // Skip if in search mode if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { return } if (!mSearchMode) { enterSearchMode(true) } else if (mSearchBar != null) { if (mSearchBar!!.getEditText().length() == 0) { exitSearchMode() } else { mSearchBar!!.applySearch() } } } // SearchBar.Helper override fun onSearchEditTextClick() {} // SearchBar.Helper override fun onApplySearch(query: String) { // Skip if in search mode if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { return } if (mUrlBuilder == null || mHelper == null) { return } exitSearchMode() mUrlBuilder!!.keyword = query updateSearchBar() mHelper!!.refresh() } // SearchBar.Helper override fun onSearchEditTextBackPressed() { onBackPressed() } // SearchBar.Helper override fun onReceiveContent(uri: Uri?) {} override fun onExpand(expanded: Boolean) { if (expanded) { mActionFabDrawable!!.setDelete(ANIMATE_TIME) } else { mActionFabDrawable!!.setAdd(ANIMATE_TIME) } } override fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton) { if (mRecyclerView != null && mFabLayout != null) { if (mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.outOfCustomChoiceMode() } else { mFabLayout!!.toggle() } } } private fun showGoToDialog() { val context = context if (null == context || null == mHelper) { return } val initial = LocalDate(2007, 3, 21) val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY) val initialMillis = initial.toEpochMillis() val yesterdayMillis = yesterday.toEpochMillis() val listValidators = ArrayList() listValidators.add(DateValidatorPointForward.from(initialMillis)) listValidators.add(DateValidatorPointBackward.before(yesterdayMillis)) val constraintsBuilder = CalendarConstraints.Builder() .setStart(initialMillis) .setEnd(yesterdayMillis) .setValidator(CompositeDateValidator.allOf(listValidators)) val datePicker = MaterialDatePicker.Builder.datePicker() .setCalendarConstraints(constraintsBuilder.build()) .setTitleText(R.string.go_to) .setSelection(yesterdayMillis) .build() datePicker.show(requireActivity().supportFragmentManager, "date-picker") datePicker.addOnPositiveButtonClickListener { v: Long? -> mHelper!!.goTo( v!!, true, ) } } override fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int) { val context = context if (null == context || null == mRecyclerView || null == mHelper) { return } if (!mRecyclerView!!.isInCustomChoice) { when (position) { // Open right 0 -> openDrawer(GravityCompat.END) // Go to 1 -> showGoToDialog() // Last page 2 -> mHelper!!.goTo("1-0", false) // Refresh 3 -> mHelper!!.refresh() } view.isExpanded = false return } mModifyGiList.clear() mRecyclerView!!.checkedItemPositions?.let { for (i in 0 until it.size) { if (it.valueAt(i)) { mHelper!!.getDataAtEx(it.keyAt(i))?.let { gi -> mModifyGiList.add(gi) } } } } when (position) { // Check all 4 -> mRecyclerView!!.checkAll() // Download 5 -> { val activity: Activity? = mainActivity if (activity != null) { // CommonOperations Actions CommonOperations.startDownload(mainActivity!!, mModifyGiList, false) } mModifyGiList.clear() if (mRecyclerView != null && mRecyclerView!!.isInCustomChoice) { mRecyclerView!!.outOfCustomChoiceMode() } } // Delete 6 -> { val helper = DeleteDialogHelper() AlertDialog.Builder(context) .setTitle(R.string.delete_favorites_dialog_title) .setMessage( getString( R.string.delete_favorites_dialog_message, mModifyGiList.size, ), ) .setPositiveButton(android.R.string.ok, helper) .setOnCancelListener(helper) .show() } // Move 7 -> { val helper = MoveDialogHelper() // First is local favorite, the other 10 is cloud favorite val array = arrayOfNulls(11) array[0] = getString(R.string.local_favorites) System.arraycopy(Settings.favCat, 0, array, 1, 10) AlertDialog.Builder(context) .setTitle(R.string.move_favorites_dialog_title) .setItems(array, helper) .setOnCancelListener(helper) .show() } } } private fun showNormalFabs() { // Delay showing normal fabs to avoid mutation SimpleHandler.getInstance().removeCallbacks(showNormalFabsRunnable) SimpleHandler.getInstance().postDelayed(showNormalFabsRunnable, 300) } private fun showSelectionFabs() { SimpleHandler.getInstance().removeCallbacks(showNormalFabsRunnable) if (mFabLayout != null) { mFabLayout!!.setSecondaryFabVisibilityAt(0, false) mFabLayout!!.setSecondaryFabVisibilityAt(1, false) mFabLayout!!.setSecondaryFabVisibilityAt(2, false) mFabLayout!!.setSecondaryFabVisibilityAt(3, false) mFabLayout!!.setSecondaryFabVisibilityAt(4, true) mFabLayout!!.setSecondaryFabVisibilityAt(5, true) mFabLayout!!.setSecondaryFabVisibilityAt(6, true) mFabLayout!!.setSecondaryFabVisibilityAt(7, true) } } override fun onIntoCustomChoice(view: EasyRecyclerView) { if (mFabLayout != null) { showSelectionFabs() mFabLayout!!.setAutoCancel(false) // Delay expanding action to make layout work fine SimpleHandler.getInstance().post { mFabLayout!!.isExpanded = true } } mHelper?.setRefreshLayoutEnable(false) // Lock drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END) } override fun onOutOfCustomChoice(view: EasyRecyclerView) { if (mFabLayout != null) { showNormalFabs() mFabLayout!!.setAutoCancel(true) mFabLayout!!.isExpanded = false } mHelper?.setRefreshLayoutEnable(true) // Unlock drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) } override fun onItemCheckedStateChanged( view: EasyRecyclerView, position: Int, id: Long, checked: Boolean, ) { if (view.checkedItemCount == 0) { view.outOfCustomChoiceMode() } } private fun enterSearchMode(animation: Boolean) { if (mSearchMode || mSearchBar == null || mSearchBarMover == null || mLeftDrawable == null) { return } mSearchMode = true mSearchBar!!.setState(SearchBar.STATE_SEARCH_LIST, animation) mSearchBarMover!!.returnSearchBarPosition(animation) mLeftDrawable!!.setArrow(ANIMATE_TIME) } private fun exitSearchMode() { if (!mSearchMode || mSearchBar == null || mSearchBarMover == null || mLeftDrawable == null) { return } mSearchMode = false mSearchBar!!.setState(SearchBar.STATE_NORMAL, true) mSearchBarMover!!.returnSearchBarPosition() mLeftDrawable!!.setMenu(ANIMATE_TIME) } private fun onGetFavoritesSuccess(result: FavoritesParser.Result, taskId: Int) { if (mHelper != null && mHelper!!.isCurrentTask(taskId)) { if (mFavCatArray != null) { System.arraycopy(result.catArray, 0, mFavCatArray!!, 0, 10) } mFavCountArray = result.countArray mFavCountSum = 0 for (i in 0..9) { mFavCountSum += mFavCountArray!![i] } Settings.putFavCloudCount(mFavCountSum) updateSearchBar() mHelper!!.onGetPageData(taskId, 0, 0, result.prev, result.next, result.galleryInfoList) mDrawerAdapter?.notifyDataSetChanged() } } private fun onGetFavoritesFailure(e: Exception, taskId: Int) { if (mHelper != null && mHelper!!.isCurrentTask(taskId)) { mHelper!!.onGetException(taskId, e) } } private fun onGetFavoritesLocal(keyword: String?, taskId: Int) { if (mHelper != null && mHelper!!.isCurrentTask(taskId)) { val list: List = if (keyword.isNullOrEmpty()) { // DB Actions EhDB.allLocalFavorites } else { // DB Actions EhDB.searchLocalFavorites(keyword) } if (list.isEmpty()) { mHelper!!.onGetPageData(taskId, 0, 0, null, null, list) } else { mHelper!!.onGetPageData(taskId, 1, 0, null, null, list) } if (TextUtils.isEmpty(keyword)) { mFavLocalCount = list.size Settings.putFavLocalCount(mFavLocalCount) mDrawerAdapter?.notifyDataSetChanged() } } } // Update fav cat on history private fun updateHistoryFavSlot(gidArray: LongArray?, slot: Int) { if (gidArray != null) { for (gid in gidArray) { // DB Actions EhDB.updateHistoryFavSlot(gid, slot) } } } private class FavDrawerHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val key: TextView = ViewUtils.`$$`(itemView, R.id.key) as TextView val value: TextView = ViewUtils.`$$`(itemView, R.id.value) as TextView } private inner class AddFavoritesListener( context: Context, private val mTaskId: Int, private val mKeyword: String?, private val mBackup: List, private val mGidArray: LongArray?, private val mSlot: Int, ) : EhCallback(context) { override fun onSuccess(result: Void?) { val scene = this@FavoritesScene scene.updateHistoryFavSlot(mGidArray, mSlot) scene.onGetFavoritesLocal(mKeyword, mTaskId) } override fun onFailure(e: Exception) { // TODO It's a failure, add all of backup back to db. // But how to known which one is failed? // DB Actions EhDB.putLocalFavorites(mBackup) val scene = this@FavoritesScene scene.onGetFavoritesLocal(mKeyword, mTaskId) } override fun onCancel() {} } private inner class GetFavoritesListener( context: Context, private val mTaskId: Int, // Local fav is shown now, but operation need be done for cloud fav private val mLocal: Boolean, private val mKeyword: String?, private val mGidArray: LongArray?, private val mSlot: Int, ) : EhCallback(context) { override fun onSuccess(result: FavoritesParser.Result) { // Put fav cat Settings.favCat = result.catArray Settings.favCount = result.countArray val scene = this@FavoritesScene scene.updateHistoryFavSlot(mGidArray, mSlot) if (mLocal) { scene.onGetFavoritesLocal(mKeyword, mTaskId) } else { scene.onGetFavoritesSuccess(result, mTaskId) } } override fun onFailure(e: Exception) { val scene = this@FavoritesScene if (mLocal) { e.printStackTrace() scene.onGetFavoritesLocal(mKeyword, mTaskId) } else { scene.onGetFavoritesFailure(e, mTaskId) } } override fun onCancel() {} } private inner class FavDrawerAdapter(private val mInflater: LayoutInflater) : RecyclerView.Adapter() { override fun getItemViewType(position: Int): Int = position override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavDrawerHolder = FavDrawerHolder(mInflater.inflate(R.layout.item_drawer_favorites, parent, false)) @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: FavDrawerHolder, position: Int) { when (position) { 0 -> { holder.key.setText(R.string.local_favorites) holder.value.text = mFavLocalCount.toString() holder.itemView.isEnabled = true } 1 -> { holder.key.setText(R.string.cloud_favorites) holder.value.text = mFavCountSum.toString() holder.itemView.isEnabled = true } else -> { if (null == mFavCatArray || null == mFavCountArray || mFavCatArray!!.size < position - 1 || mFavCountArray!!.size < position - 1) { return } holder.key.text = mFavCatArray!![position - 2] holder.value.text = mFavCountArray!![position - 2].toString() holder.itemView.isEnabled = true } } holder.itemView.setOnClickListener { onItemClick(holder.itemView, position) } } override fun getItemCount(): Int = if (null == mFavCatArray) { 2 } else { 12 } } private inner class DeleteDialogHelper : DialogInterface.OnClickListener, DialogInterface.OnCancelListener { override fun onClick(dialog: DialogInterface, which: Int) { if (which != DialogInterface.BUTTON_POSITIVE) { return } if (mRecyclerView == null || mHelper == null || mUrlBuilder == null) { return } mRecyclerView!!.outOfCustomChoiceMode() mFavSlot = -2 if (mUrlBuilder!!.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav val gidArray = LongArray(mModifyGiList.size) var i = 0 val n = mModifyGiList.size while (i < n) { gidArray[i] = mModifyGiList[i].gid i++ } // DB Actions EhDB.removeLocalFavorites(gidArray) updateHistoryFavSlot(gidArray, mFavSlot) mModifyGiList.clear() mHelper!!.refresh() } else { // Delete cloud fav mEnableModify = true mModifyFavCat = -1 mModifyAdd = false mHelper!!.refresh() } } override fun onCancel(dialog: DialogInterface) { mModifyGiList.clear() } } private inner class MoveDialogHelper : DialogInterface.OnClickListener, DialogInterface.OnCancelListener { override fun onClick(dialog: DialogInterface, which: Int) { if (mRecyclerView == null || mHelper == null || mUrlBuilder == null) { return } val srcCat = mUrlBuilder!!.favCat val dstCat: Int = if (which == 0) { FavListUrlBuilder.FAV_CAT_LOCAL } else { which - 1 } if (srcCat == dstCat) { return } mRecyclerView!!.outOfCustomChoiceMode() if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Move from local to cloud val gidArray = LongArray(mModifyGiList.size) var i = 0 val n = mModifyGiList.size while (i < n) { gidArray[i] = mModifyGiList[i].gid i++ } // DB Actions EhDB.removeLocalFavorites(gidArray) mEnableModify = true mModifyFavCat = dstCat mModifyAdd = true mFavSlot = dstCat mHelper!!.refresh() } else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Move from cloud to local // DB Actions EhDB.putLocalFavorites(mModifyGiList) mEnableModify = true mModifyFavCat = -1 mModifyAdd = false mFavSlot = -1 mHelper!!.refresh() } else { mEnableModify = true mModifyFavCat = dstCat mModifyAdd = false mFavSlot = dstCat mHelper!!.refresh() } } override fun onCancel(dialog: DialogInterface) { mModifyGiList.clear() } } private inner class FavoritesAdapter( inflater: LayoutInflater, resources: Resources, recyclerView: RecyclerView, type: Int, ) : GalleryAdapter(inflater, resources, recyclerView, type, false) { override fun getItemCount(): Int = if (null != mHelper) mHelper!!.size() else 0 override fun onItemClick(view: View, position: Int) { this@FavoritesScene.onItemClick(view, position) } override fun onItemLongClick(view: View, position: Int): Boolean = this@FavoritesScene.onItemLongClick(position) override fun getDataAt(position: Int): GalleryInfo? = if (null != mHelper) mHelper!!.getDataAtEx(position) else null } private inner class FavoritesHelper : GalleryInfoContentHelper() { override fun getPageData( taskId: Int, type: Int, page: Int, index: String?, isNext: Boolean, ) { val activity = mainActivity if (null == activity || null == mUrlBuilder || null == mClient) { return } if (mEnableModify) { mEnableModify = false val local = mUrlBuilder!!.favCat == FavListUrlBuilder.FAV_CAT_LOCAL val gidArray = LongArray(mModifyGiList.size) if (mModifyAdd) { val tokenArray = arrayOfNulls(mModifyGiList.size) var i = 0 val n = mModifyGiList.size while (i < n) { val gi = mModifyGiList[i] gidArray[i] = gi.gid tokenArray[i] = gi.token i++ } val modifyGiListBackup: List = ArrayList(mModifyGiList) mModifyGiList.clear() val request = EhRequest() request.setMethod(EhClient.METHOD_ADD_FAVORITES_RANGE) request.setCallback( AddFavoritesListener( context, taskId, mUrlBuilder!!.keyword, modifyGiListBackup, gidArray, mFavSlot, ), ) request.setArgs(gidArray, tokenArray, mModifyFavCat) request.enqueue(this@FavoritesScene) } else { var i = 0 val n = mModifyGiList.size while (i < n) { gidArray[i] = mModifyGiList[i].gid i++ } mModifyGiList.clear() val url: String = if (local) { // Local fav is shown now, but operation need be done for cloud fav EhUrl.favoritesUrl } else { mUrlBuilder!!.build() } mUrlBuilder!!.setIndex(index, true) val request = EhRequest() request.setMethod(EhClient.METHOD_MODIFY_FAVORITES) request.setCallback( GetFavoritesListener( context, taskId, local, mUrlBuilder!!.keyword, gidArray, mFavSlot, ), ) request.setArgs(url, gidArray, mModifyFavCat) request.enqueue(this@FavoritesScene) } } else if (mUrlBuilder!!.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) { val keyword = mUrlBuilder!!.keyword SimpleHandler.getInstance().post { onGetFavoritesLocal(keyword, taskId) } } else { mUrlBuilder!!.setIndex(index, isNext) mUrlBuilder!!.jumpTo = jumpTo val url = mUrlBuilder!!.build() val request = EhRequest() request.setMethod(EhClient.METHOD_GET_FAVORITES) request.setCallback( GetFavoritesListener( context, taskId, false, mUrlBuilder!!.keyword, null, -2, ), ) request.setArgs(url) request.enqueue(this@FavoritesScene) } } override val context get() = this@FavoritesScene.requireContext() override fun notifyDataSetChanged() { // Ensure outOfCustomChoiceMode to avoid error mRecyclerView?.outOfCustomChoiceMode() mAdapter?.notifyDataSetChanged() } override fun notifyItemRangeInserted(positionStart: Int, itemCount: Int) { mAdapter?.notifyItemRangeInserted(positionStart, itemCount) } override fun onShowView(hiddenView: View, shownView: View) { mSearchBarMover?.showSearchBar() } override fun isDuplicate(d1: GalleryInfo?, d2: GalleryInfo?): Boolean = d1?.gid == d2?.gid && d1 != null && d2 != null override fun onScrollToPosition(position: Int) { if (0 == position) { mSearchBarMover?.showSearchBar() } } } companion object { private const val ANIMATE_TIME = 300L private const val KEY_URL_BUILDER = "url_builder" private const val KEY_SEARCH_MODE = "search_mode" private const val KEY_HAS_FIRST_REFRESH = "has_first_refresh" private const val KEY_FAV_COUNT_ARRAY = "fav_count_array" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryAdapter.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.annotation.SuppressLint import android.content.res.Resources import android.text.TextUtils import android.util.TypedValue.COMPLEX_UNIT_PX import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.IntDef import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import com.hippo.drawable.TriangleDrawable import com.hippo.easyrecyclerview.MarginItemDecoration import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.getThumbKey import com.hippo.ehviewer.client.thumbUrl import com.hippo.ehviewer.download.DownloadManager as downloadManager import com.hippo.ehviewer.widget.TileThumb import com.hippo.widget.recyclerview.AutoStaggeredGridLayoutManager import com.hippo.yorozuya.ViewUtils @SuppressLint("InflateParams") internal abstract class GalleryAdapter( private val mInflater: LayoutInflater, private val mResources: Resources, private val mRecyclerView: RecyclerView, type: Int, showFavourited: Boolean, ) : RecyclerView.Adapter() { private val mLayoutManager: AutoStaggeredGridLayoutManager = AutoStaggeredGridLayoutManager(0, StaggeredGridLayoutManager.VERTICAL) private val mPaddingTopSB: Int = mResources.getDimensionPixelOffset(R.dimen.gallery_padding_top_search_bar) private val mListThumbWidth: Int private val mListThumbHeight: Int private val mShowFavourited: Boolean = showFavourited private var mListDecoration: MarginItemDecoration? = null private var mGirdDecoration: MarginItemDecoration? = null private var mType = TYPE_INVALID var type: Int get() = mType @SuppressLint("NotifyDataSetChanged") set(type) { if (type == mType) { return } mType = type val recyclerView = mRecyclerView when (type) { TYPE_LIST -> { mLayoutManager.setColumnSize(Settings.detailSize) mLayoutManager.setStrategy(AutoStaggeredGridLayoutManager.STRATEGY_MIN_SIZE) if (null != mGirdDecoration) { recyclerView.removeItemDecoration(mGirdDecoration!!) } if (null == mListDecoration) { val interval = mResources.getDimensionPixelOffset(R.dimen.gallery_list_interval) val paddingH = mResources.getDimensionPixelOffset(R.dimen.gallery_list_margin_h) val paddingV = mResources.getDimensionPixelOffset(R.dimen.gallery_list_margin_v) mListDecoration = MarginItemDecoration(interval, paddingH, paddingV, paddingH, paddingV) } recyclerView.addItemDecoration(mListDecoration!!) notifyDataSetChanged() } TYPE_GRID -> { mLayoutManager.setColumnSize(Settings.thumbSize) mLayoutManager.setStrategy(AutoStaggeredGridLayoutManager.STRATEGY_SUITABLE_SIZE) if (null != mListDecoration) { recyclerView.removeItemDecoration(mListDecoration!!) } if (null == mGirdDecoration) { val interval = mResources.getDimensionPixelOffset(R.dimen.gallery_grid_interval) val paddingH = mResources.getDimensionPixelOffset(R.dimen.gallery_grid_margin_h) val paddingV = mResources.getDimensionPixelOffset(R.dimen.gallery_grid_margin_v) mGirdDecoration = MarginItemDecoration(interval, paddingH, paddingV, paddingH, paddingV) } recyclerView.addItemDecoration(mGirdDecoration!!) notifyDataSetChanged() } } } init { mRecyclerView.adapter = this mRecyclerView.layoutManager = mLayoutManager val calculator = mInflater.inflate(R.layout.item_gallery_list_thumb_height, null) ViewUtils.measureView(calculator, 1024, ViewGroup.LayoutParams.WRAP_CONTENT) mListThumbHeight = calculator.measuredHeight mListThumbWidth = mListThumbHeight * 2 / 3 this.type = type adjustPaddings() } private fun adjustPaddings() { val recyclerView = mRecyclerView recyclerView.setPadding( recyclerView.paddingLeft, recyclerView.paddingTop + mPaddingTopSB, recyclerView.paddingRight, recyclerView.paddingBottom, ) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GalleryHolder { val layoutId = when (viewType) { TYPE_LIST -> R.layout.item_gallery_list TYPE_GRID -> R.layout.item_gallery_grid else -> throw IllegalStateException("Unexpected value: $viewType") } val holder = GalleryHolder(mInflater.inflate(layoutId, parent, false)) when (viewType) { TYPE_LIST -> { val lp = holder.thumb.layoutParams lp.width = mListThumbWidth lp.height = mListThumbHeight holder.thumb.layoutParams = lp holder.title.maxLines = if (Settings.listTitleSingleLine) 1 else 2 } TYPE_GRID -> { val columnWidth = Settings.thumbSize val textSize = columnWidth / 14 val lp = holder.category.layoutParams lp.width = columnWidth / 5 lp.height = (lp.width * 0.75).toInt() holder.category.layoutParams = lp holder.simpleLanguage.setTextSize(COMPLEX_UNIT_PX, textSize.toFloat()) holder.pages.setTextSize(COMPLEX_UNIT_PX, textSize.toFloat()) holder.title.setTextSize(COMPLEX_UNIT_PX, textSize.toFloat()) holder.title.maxLines = 2 } } holder.card.setOnClickListener { onItemClick( holder.itemView, holder.bindingAdapterPosition, ) } holder.card.setOnLongClickListener { onItemLongClick( holder.itemView, holder.bindingAdapterPosition, ) } return holder } abstract fun onItemClick(view: View, position: Int) abstract fun onItemLongClick(view: View, position: Int): Boolean override fun getItemViewType(position: Int): Int = mType abstract fun getDataAt(position: Int): GalleryInfo? @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: GalleryHolder, position: Int) { val gi = getDataAt(position) ?: return when (mType) { TYPE_LIST -> { holder.thumb.load(getThumbKey(gi.gid), gi.thumbUrl!!, hardware = false) holder.title.text = EhUtils.getSuitableTitle(gi) holder.uploader!!.alpha = if (gi.disowned) .5f else 1f if (TextUtils.isEmpty(gi.uploader)) { holder.uploader.text = null holder.uploader.visibility = View.GONE } else { holder.uploader.text = gi.uploader holder.uploader.visibility = View.VISIBLE } holder.note!!.text = gi.favoriteNote holder.rating.rating = gi.rating val category = holder.category val newCategoryText = EhUtils.getCategory(gi.category) if (newCategoryText != category.text.toString()) { category.text = newCategoryText category.setBackgroundColor(EhUtils.getCategoryColor(gi.category)) } holder.posted!!.text = gi.posted if (gi.pages == 0 || !Settings.showGalleryPages) { holder.pages.text = null holder.pages.visibility = View.GONE } else { holder.pages.text = gi.pages.toString() + "P" holder.pages.visibility = View.VISIBLE } if (TextUtils.isEmpty(gi.simpleLanguage)) { holder.simpleLanguage.text = null holder.simpleLanguage.visibility = View.GONE } else { holder.simpleLanguage.text = gi.simpleLanguage holder.simpleLanguage.visibility = View.VISIBLE } holder.favourited!!.visibility = if (mShowFavourited && gi.favoriteSlot >= -1 && gi.favoriteSlot <= 10) View.VISIBLE else View.GONE holder.downloaded!!.visibility = if (downloadManager.containDownloadInfo(gi.gid)) View.VISIBLE else View.GONE } TYPE_GRID -> { (holder.thumb as TileThumb).setThumbSize(gi.thumbWidth, gi.thumbHeight) holder.thumb.load(getThumbKey(gi.gid), gi.thumbUrl!!, hardware = false) if (Settings.thumbShowTitle) { holder.title.text = EhUtils.getSuitableTitle(gi) holder.title.visibility = View.VISIBLE holder.rating.rating = gi.rating holder.rating.visibility = View.VISIBLE if (gi.pages == 0 || !Settings.showGalleryPages) { holder.pages.text = null holder.pages.visibility = View.GONE } else { holder.pages.text = gi.pages.toString() + "P" holder.pages.visibility = View.VISIBLE } } else { holder.title.text = null holder.title.visibility = View.GONE holder.rating.visibility = View.GONE holder.pages.text = null holder.pages.visibility = View.GONE } val category: View = holder.category var drawable = category.background val color = EhUtils.getCategoryColor(gi.category) if (drawable !is TriangleDrawable) { drawable = TriangleDrawable(color) category.background = drawable } else { drawable.setColor(color) } holder.simpleLanguage.text = gi.simpleLanguage } } // Update transition name ViewCompat.setTransitionName( holder.thumb, TransitionNameFactory.getThumbTransitionName(gi.gid), ) } @IntDef(TYPE_LIST, TYPE_GRID) @Retention(AnnotationRetention.SOURCE) annotation class Type companion object { const val TYPE_INVALID = -1 const val TYPE_LIST = 0 const val TYPE_GRID = 1 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryCommentsScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.animation.Animator import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.graphics.Typeface import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Looper import android.text.Spannable import android.text.SpannableStringBuilder import android.text.TextUtils import android.text.style.CharacterStyle import android.text.style.ForegroundColorSpan import android.text.style.RelativeSizeSpan import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.text.style.URLSpan import android.text.style.UnderlineSpan import android.util.Log import android.view.ActionMode import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewAnimationUtils import android.view.ViewGroup import android.widget.EditText import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.text.getSpans import androidx.core.text.inSpans import androidx.core.text.set import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import com.google.android.material.floatingactionbutton.FloatingActionButton import com.hippo.app.EditTextDialogBuilder import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.LinearDividerItemDecoration import com.hippo.ehviewer.R import com.hippo.ehviewer.UrlOpener import com.hippo.ehviewer.WindowInsetsAnimationHelper import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhFilter import com.hippo.ehviewer.client.EhRequest import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.data.GalleryComment import com.hippo.ehviewer.client.data.GalleryCommentList import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.data.ListUrlBuilder import com.hippo.ehviewer.client.parser.VoteCommentParser import com.hippo.ehviewer.dao.Filter import com.hippo.ehviewer.ui.MainActivity import com.hippo.util.ExceptionUtils import com.hippo.util.ReadableTime import com.hippo.util.TextUrl import com.hippo.util.addTextToClipboard import com.hippo.util.getParcelableCompat import com.hippo.util.loadHtml import com.hippo.util.toBBCode import com.hippo.view.ViewTransition import com.hippo.widget.FabLayout import com.hippo.widget.LinkifyTextView import com.hippo.widget.ObservedTextView import com.hippo.yorozuya.AnimationUtils import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.ResourcesUtils import com.hippo.yorozuya.SimpleAnimatorListener import com.hippo.yorozuya.StringUtils import com.hippo.yorozuya.ViewUtils import com.hippo.yorozuya.collect.IntList import kotlin.math.hypot import rikka.core.res.resolveColor class GalleryCommentsScene : ToolbarScene(), View.OnClickListener, OnRefreshListener { private var mGalleryDetail: GalleryDetail? = null private var mRecyclerView: EasyRecyclerView? = null private var mFabLayout: FabLayout? = null private var mFab: FloatingActionButton? = null private var mEditPanel: View? = null private var mSendImage: ImageView? = null private var mEditText: EditText? = null private var mAdapter: CommentAdapter? = null private var mViewTransition: ViewTransition? = null private var mRefreshLayout: SwipeRefreshLayout? = null private var mSendDrawable: Drawable? = null private var mPencilDrawable: Drawable? = null private var mCommentId: Long = 0 private var mInAnimation = false private var mShowAllComments = false private var mRefreshingComments = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } private fun handleArgs(args: Bundle?) { if (args == null) { return } mGalleryDetail = args.getParcelableCompat(KEY_GALLERY_DETAIL) mShowAllComments = mGalleryDetail != null && mGalleryDetail!!.comments != null && !mGalleryDetail!!.comments!!.hasMore } private fun onInit() { handleArgs(arguments) } private fun onRestore(savedInstanceState: Bundle) { mGalleryDetail = savedInstanceState.getParcelableCompat(KEY_GALLERY_DETAIL) mShowAllComments = mGalleryDetail != null && mGalleryDetail!!.comments != null && !mGalleryDetail!!.comments!!.hasMore } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putParcelable(KEY_GALLERY_DETAIL, mGalleryDetail) } override fun onCreateViewWithToolbar( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_gallery_comments, container, false) as View mRecyclerView = ViewUtils.`$$`(view, R.id.recycler_view) as EasyRecyclerView val tip = ViewUtils.`$$`(view, R.id.tip) as TextView mEditPanel = ViewUtils.`$$`(view, R.id.edit_panel) mSendImage = ViewUtils.`$$`(mEditPanel, R.id.send) as ImageView mEditText = ViewUtils.`$$`(mEditPanel, R.id.edit_text) as EditText mFabLayout = ViewUtils.`$$`(view, R.id.fab_layout) as FabLayout mFab = ViewUtils.`$$`(view, R.id.fab) as FloatingActionButton mRefreshLayout = ViewUtils.`$$`(view, R.id.refresh_layout) as SwipeRefreshLayout ViewCompat.setWindowInsetsAnimationCallback( view, WindowInsetsAnimationHelper( WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP, mEditPanel, mFabLayout, ), ) mRefreshLayout!!.setColorSchemeResources( R.color.loading_indicator_red, R.color.loading_indicator_purple, R.color.loading_indicator_blue, R.color.loading_indicator_cyan, R.color.loading_indicator_green, R.color.loading_indicator_yellow, ) mRefreshLayout!!.setOnRefreshListener(this) val context = requireContext() val drawable = ContextCompat.getDrawable(context, R.drawable.big_sad_pandroid) drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) tip.setCompoundDrawables(null, drawable, null, null) mSendDrawable = ContextCompat.getDrawable(context, R.drawable.v_send_dark_x24) mPencilDrawable = ContextCompat.getDrawable(context, R.drawable.v_pencil_dark_x24) mAdapter = CommentAdapter() mRecyclerView!!.adapter = mAdapter mRecyclerView!!.layoutManager = LinearLayoutManager( context, RecyclerView.VERTICAL, false, ) val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(context, 1.0f), ) decoration.setShowLastDivider(true) mRecyclerView!!.addItemDecoration(decoration) mRecyclerView!!.setHasFixedSize(true) // Cancel change animator val itemAnimator = mRecyclerView!!.itemAnimator if (itemAnimator is DefaultItemAnimator) { itemAnimator.supportsChangeAnimations = false } mSendImage!!.setOnClickListener(this) mEditText!!.customSelectionActionModeCallback = object : ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { requireActivity().menuInflater.inflate(R.menu.context_comment, menu) return true } override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean = true override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { item?.let { val text = mEditText!!.editableText val start = mEditText!!.selectionStart val end = mEditText!!.selectionEnd when (item.itemId) { R.id.action_bold -> text[start, end] = StyleSpan(Typeface.BOLD) R.id.action_italic -> text[start, end] = StyleSpan(Typeface.ITALIC) R.id.action_underline -> text[start, end] = UnderlineSpan() R.id.action_strikethrough -> text[start, end] = StrikethroughSpan() R.id.action_url -> { val oldSpans = text.getSpans(start, end) var oldUrl = "https://" oldSpans.forEach { if (!TextUtils.isEmpty(it.url)) { oldUrl = it.url } } val builder = EditTextDialogBuilder( context, oldUrl, getString(R.string.format_url), ) builder.setTitle(getString(R.string.format_url)) builder.setPositiveButton(android.R.string.ok, null) val dialog = builder.show() val button: View? = dialog.getButton(DialogInterface.BUTTON_POSITIVE) button?.setOnClickListener( View.OnClickListener { val url = builder.text.trim() if (TextUtils.isEmpty(url)) { builder.setError(getString(R.string.text_is_empty)) return@OnClickListener } else { builder.setError(null) } text.clearSpan(start, end, true) text[start, end] = URLSpan(url) dialog.dismiss() }, ) } R.id.action_clear -> { text.clearSpan(start, end, false) } else -> return false } mode?.finish() } return true } override fun onDestroyActionMode(mode: ActionMode?) { } } mFab!!.setOnClickListener(this) addAboveSnackView(mEditPanel!!) addAboveSnackView(mFabLayout!!) mViewTransition = ViewTransition(mRecyclerView, tip) updateView(false) return view } fun Spannable.clearSpan(start: Int, end: Int, url: Boolean) { val spans = if (url) getSpans(start, end) else getSpans(start, end) spans.forEach { val spanStart = getSpanStart(it) val spanEnd = getSpanEnd(it) removeSpan(it) if (spanStart < start) { this[spanStart, start] = it } if (spanEnd > end) { this[end, spanEnd] = it } } } override fun onDestroyView() { super.onDestroyView() if (null != mRecyclerView) { mRecyclerView!!.stopScroll() mRecyclerView = null } if (null != mEditPanel) { removeAboveSnackView(mEditPanel!!) mEditPanel = null } if (null != mFabLayout) { removeAboveSnackView(mFabLayout!!) mFabLayout = null } mFab = null mSendImage = null mEditText = null mAdapter = null mViewTransition = null } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setTitle(R.string.gallery_comments) setNavigationIcon(R.drawable.v_arrow_left_dark_x24) } override fun onNavigationClick() { onBackPressed() } private fun showFilterCommenterDialog(commenter: String?, position: Int) { val context = context if (context == null || commenter == null) { return } AlertDialog.Builder(context) .setMessage(getString(R.string.filter_the_commenter, commenter)) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> val filter = Filter() filter.mode = EhFilter.MODE_COMMENTER filter.text = commenter EhFilter.addFilter(filter) hideComment(position) showTip(R.string.filter_added, LENGTH_SHORT) } .setNegativeButton(android.R.string.cancel, null) .show() } @SuppressLint("NotifyDataSetChanged") private fun hideComment(position: Int) { if (mGalleryDetail == null || mGalleryDetail!!.comments == null || mGalleryDetail!!.comments!!.comments == null) { return } val oldCommentsList = mGalleryDetail!!.comments!!.comments val newCommentsList = arrayOfNulls( oldCommentsList!!.size - 1, ) var i = 0 var j = 0 while (i < oldCommentsList.size) { if (i != position) { newCommentsList[j] = oldCommentsList[i] j++ } i++ } mGalleryDetail!!.comments!!.comments = newCommentsList.requireNoNulls() mAdapter!!.notifyDataSetChanged() updateView(true) } private fun voteComment(id: Long, vote: Int) { val context = context val activity = mainActivity if (null == context || null == activity) { return } val request = EhRequest() .setMethod(EhClient.METHOD_VOTE_COMMENT) .setArgs( mGalleryDetail!!.apiUid, mGalleryDetail!!.apiKey, mGalleryDetail!!.gid, mGalleryDetail!!.token, id, vote, ) .setCallback(VoteCommentListener(context)) request.enqueue(this) } @SuppressLint("InflateParams") fun showVoteStatusDialog(context: Context?, voteStatus: String?) { var mContext = context val temp = StringUtils.split(voteStatus, ',') val length = temp.size val userArray = arrayOfNulls(length) val voteArray = arrayOfNulls(length) for (i in 0 until length) { val str = StringUtils.trim(temp[i]) val index = str.lastIndexOf(' ') if (index < 0) { Log.d(TAG, "Something wrong happened about vote state") userArray[i] = str voteArray[i] = "" } else { userArray[i] = StringUtils.trim(str.substring(0, index)) voteArray[i] = StringUtils.trim(str.substring(index + 1)) } } val builder = AlertDialog.Builder(mContext!!) mContext = builder.context val inflater = LayoutInflater.from(mContext) val rv = inflater.inflate(R.layout.dialog_recycler_view, null) as EasyRecyclerView rv.adapter = object : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InfoHolder = InfoHolder(inflater.inflate(R.layout.item_drawer_favorites, parent, false)) override fun onBindViewHolder(holder: InfoHolder, position: Int) { holder.key.text = userArray[position] holder.value.text = voteArray[position] } override fun getItemCount(): Int = length } rv.layoutManager = LinearLayoutManager(mContext) val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(mContext, 1.0f), ) decoration.setPadding(ResourcesUtils.getAttrDimensionPixelOffset(mContext, androidx.appcompat.R.attr.dialogPreferredPadding)) rv.addItemDecoration(decoration) rv.clipToPadding = false builder.setView(rv).show() } private fun showCommentDialog(position: Int, text: CharSequence) { val context = context if (context == null || mGalleryDetail == null || mGalleryDetail!!.comments == null || mGalleryDetail!!.comments!!.comments == null || position >= mGalleryDetail!!.comments!!.comments!!.size || position < 0) { return } val comment = mGalleryDetail!!.comments!!.comments!![position] val menu: MutableList = ArrayList() val menuId = IntList() val resources = context.resources menu.add(resources.getString(R.string.copy_comment_text)) menuId.add(R.id.copy) if (!comment.uploader && !comment.editable) { menu.add(resources.getString(R.string.block_commenter)) menuId.add(R.id.block_commenter) } if (comment.editable) { menu.add(resources.getString(R.string.edit_comment)) menuId.add(R.id.edit_comment) } if (comment.voteUpAble) { menu.add(resources.getString(if (comment.voteUpEd) R.string.cancel_vote_up else R.string.vote_up)) menuId.add(R.id.vote_up) } if (comment.voteDownAble) { menu.add(resources.getString(if (comment.voteDownEd) R.string.cancel_vote_down else R.string.vote_down)) menuId.add(R.id.vote_down) } if (!TextUtils.isEmpty(comment.voteState)) { menu.add(resources.getString(R.string.check_vote_status)) menuId.add(R.id.check_vote_status) } AlertDialog.Builder(context) .setItems(menu.toTypedArray()) { _: DialogInterface?, which: Int -> if (which < 0 || which >= menuId.size) { return@setItems } val id = menuId[which] if (id == R.id.copy) { requireActivity().addTextToClipboard(text, false) } else if (id == R.id.block_commenter) { showFilterCommenterDialog(comment.user, position) } else if (id == R.id.vote_up) { voteComment(comment.id, 1) } else if (id == R.id.vote_down) { voteComment(comment.id, -1) } else if (id == R.id.check_vote_status) { showVoteStatusDialog(context, comment.voteState) } else if (id == R.id.edit_comment) { prepareEditComment(comment.id, text) if (!mInAnimation && mEditPanel != null && mEditPanel!!.visibility != View.VISIBLE) { showEditPanel() } } }.show() } fun onItemClick(parent: EasyRecyclerView?, view2: View?, position: Int): Boolean { val activity = mainActivity ?: return false val holder = parent!!.getChildViewHolder(view2!!) if (holder is ActualCommentHolder) { val span = holder.comment.currentSpan holder.comment.clearCurrentSpan() if (span is URLSpan) { UrlOpener.openUrl(activity, span.url, true, mGalleryDetail) } else { showCommentDialog(position, holder.sp) } } else if (holder is MoreCommentHolder && !mRefreshingComments && mAdapter != null) { mRefreshingComments = true mShowAllComments = true mAdapter!!.notifyItemChanged(position) val url = galleryDetailUrl if (url != null) { // Request val request = EhRequest() .setMethod(EhClient.METHOD_GET_GALLERY_DETAIL) .setArgs(url) .setCallback(RefreshCommentListener(activity)) request.enqueue(this) } } return true } private fun updateView(animation: Boolean) { if (null == mViewTransition) { return } if (mGalleryDetail == null || mGalleryDetail!!.comments == null || mGalleryDetail!!.comments!!.comments == null || mGalleryDetail!!.comments!!.comments!!.isEmpty()) { mViewTransition!!.showView(1, animation) } else { mViewTransition!!.showView(0, animation) } } private fun prepareNewComment() { mCommentId = 0 if (mSendImage != null) { mSendImage!!.setImageDrawable(mSendDrawable) } } private fun prepareEditComment(commentId: Long, text: CharSequence) { mCommentId = commentId mEditText?.setText(text) if (mSendImage != null) { mSendImage!!.setImageDrawable(mPencilDrawable) } } private fun showEditPanelWithAnimation() { if (null == mFab || null == mEditPanel) { return } mInAnimation = true mFab!!.translationX = 0.0f mFab!!.translationY = 0.0f mFab!!.scaleX = 1.0f mFab!!.scaleY = 1.0f val fabEndX = mEditPanel!!.left + mEditPanel!!.width / 2 - mFab!!.width / 2 val fabEndY = mEditPanel!!.top + mEditPanel!!.height / 2 - mFab!!.height / 2 mFab!!.animate().x(fabEndX.toFloat()).y(fabEndY.toFloat()).scaleX(0.0f).scaleY(0.0f) .setInterpolator(AnimationUtils.SLOW_FAST_SLOW_INTERPOLATOR) .setDuration(300L).setListener(object : SimpleAnimatorListener() { override fun onAnimationEnd(animation: Animator) { if (null == mFab || null == mEditPanel) { return } (mFab as View).visibility = View.INVISIBLE mEditPanel!!.visibility = View.VISIBLE val halfW = mEditPanel!!.width / 2 val halfH = mEditPanel!!.height / 2 val animator = ViewAnimationUtils.createCircularReveal( mEditPanel, halfW, halfH, 0f, hypot(halfW.toDouble(), halfH.toDouble()).toFloat(), ).setDuration(300L) animator.addListener(object : SimpleAnimatorListener() { override fun onAnimationEnd(a: Animator) { mInAnimation = false } }) animator.start() } }).start() } private fun showEditPanel() { showEditPanelWithAnimation() } private fun hideEditPanelWithAnimation() { if (null == mFab || null == mEditPanel) { return } mInAnimation = true val halfW = mEditPanel!!.width / 2 val halfH = mEditPanel!!.height / 2 val animator = ViewAnimationUtils.createCircularReveal( mEditPanel, halfW, halfH, hypot(halfW.toDouble(), halfH.toDouble()).toFloat(), 0.0f, ).setDuration(300L) animator.addListener(object : SimpleAnimatorListener() { override fun onAnimationEnd(a: Animator) { if (null == mFab || null == mEditPanel) { return } if (Looper.myLooper() != Looper.getMainLooper()) { // Some devices may run this block in non-UI thread. // It might be a bug of Android OS. // Check it here to avoid crash. return } mEditPanel!!.visibility = View.GONE (mFab as View).visibility = View.VISIBLE val fabStartX = mEditPanel!!.left + mEditPanel!!.width / 2 - mFab!!.width / 2 val fabStartY = mEditPanel!!.top + mEditPanel!!.height / 2 - mFab!!.height / 2 mFab!!.x = fabStartX.toFloat() mFab!!.y = fabStartY.toFloat() mFab!!.scaleX = 0.0f mFab!!.scaleY = 0.0f mFab!!.rotation = -45.0f mFab!!.animate().translationX(0.0f).translationY(0.0f).scaleX(1.0f).scaleY(1.0f) .rotation(0.0f) .setInterpolator(AnimationUtils.SLOW_FAST_SLOW_INTERPOLATOR) .setDuration(300L).setListener(object : SimpleAnimatorListener() { override fun onAnimationEnd(animation: Animator) { mInAnimation = false } }).start() } }) animator.start() } private fun hideEditPanel() { hideSoftInput() hideEditPanelWithAnimation() } private val galleryDetailUrl: String? get() = if (mGalleryDetail != null && mGalleryDetail!!.gid != -1L && mGalleryDetail!!.token != null) { EhUrl.getGalleryDetailUrl( mGalleryDetail!!.gid, mGalleryDetail!!.token, 0, mShowAllComments, ) } else { null } override fun onClick(v: View) { val context = context val activity = mainActivity if (null == context || null == activity || null == mEditText) { return } if (mFab === v) { if (!mInAnimation) { prepareNewComment() showEditPanel() } } else if (mSendImage === v) { if (!mInAnimation) { val comment = mEditText!!.text.toBBCode() if (TextUtils.isEmpty(comment)) { // Comment is empty return } val url = galleryDetailUrl ?: return // Request val request = EhRequest() .setMethod(EhClient.METHOD_GET_COMMENT_GALLERY) .setArgs( url, comment, if (mCommentId != 0L) mCommentId.toString() else null, ) .setCallback(CommentGalleryListener(context, mCommentId)) request.enqueue(this) hideSoftInput() hideEditPanel() } } } override fun onBackPressed() { if (mInAnimation) { return } if (null != mEditPanel && mEditPanel!!.isVisible) { hideEditPanel() } else { finish() } } @SuppressLint("NotifyDataSetChanged") private fun onRefreshGallerySuccess(result: GalleryCommentList?) { if (mGalleryDetail == null || mAdapter == null) { return } mRefreshLayout!!.isRefreshing = false mRefreshingComments = false mGalleryDetail!!.comments = result mAdapter!!.notifyDataSetChanged() updateView(true) val re = Bundle() re.putParcelable(KEY_COMMENT_LIST, result) setResult(RESULT_OK, re) } private fun onRefreshGalleryFailure() { if (mAdapter == null) { return } mRefreshLayout!!.isRefreshing = false mRefreshingComments = false val position = mAdapter!!.itemCount - 1 if (position >= 0) { mAdapter!!.notifyItemChanged(position) } } @SuppressLint("NotifyDataSetChanged") private fun onCommentGallerySuccess(result: GalleryCommentList) { if (mGalleryDetail == null || mAdapter == null) { return } mGalleryDetail!!.comments = result mAdapter!!.notifyDataSetChanged() val re = Bundle() re.putParcelable(KEY_COMMENT_LIST, result) setResult(RESULT_OK, re) // Remove text if (mEditText != null) { mEditText!!.setText("") } updateView(true) } private fun onVoteCommentSuccess(result: VoteCommentParser.Result) { if (mAdapter == null || mGalleryDetail!!.comments == null || mGalleryDetail!!.comments!!.comments == null) { return } var position = -1 var i = 0 val n = mGalleryDetail!!.comments!!.comments!!.size while (i < n) { val comment = mGalleryDetail!!.comments!!.comments!![i] if (comment.id == result.id) { position = i break } i++ } if (-1 == position) { Log.d(TAG, "Can't find comment with id " + result.id) return } // Update comment val comment = mGalleryDetail!!.comments!!.comments!![position] comment.score = result.score if (result.expectVote > 0) { comment.voteUpEd = 0 != result.vote comment.voteDownEd = false } else { comment.voteDownEd = 0 != result.vote comment.voteUpEd = false } mAdapter!!.notifyItemChanged(position) val re = Bundle() val comments = mGalleryDetail!!.comments re.putParcelable(KEY_COMMENT_LIST, comments) setResult(RESULT_OK, re) } override fun onRefresh() { if (!mRefreshingComments && mAdapter != null) { val activity = requireActivity() as MainActivity mRefreshingComments = true val url = galleryDetailUrl if (url != null) { // Request val request = EhRequest() .setMethod(EhClient.METHOD_GET_GALLERY_DETAIL) .setArgs(url) .setCallback(RefreshCommentListener(activity)) request.enqueue(this) } } } private inner class RefreshCommentListener(context: Context) : EhCallback(context) { override fun onSuccess(result: GalleryDetail) { val scene = this@GalleryCommentsScene scene.onRefreshGallerySuccess(result.comments) } override fun onFailure(e: Exception) { val scene = this@GalleryCommentsScene scene.onRefreshGalleryFailure() } override fun onCancel() {} } private inner class CommentGalleryListener(context: Context, private val mCommentId: Long) : EhCallback(context) { override fun onSuccess(result: GalleryCommentList) { showTip( if (mCommentId != 0L) R.string.edit_comment_successfully else R.string.comment_successfully, LENGTH_SHORT, ) val scene = this@GalleryCommentsScene scene.onCommentGallerySuccess(result) } override fun onFailure(e: Exception) { showTip( """ ${content.getString(if (mCommentId != 0L) R.string.edit_comment_failed else R.string.comment_failed)} ${ExceptionUtils.getReadableString(e)} """.trimIndent(), LENGTH_LONG, ) } override fun onCancel() {} } private inner class VoteCommentListener(context: Context) : EhCallback(context) { override fun onSuccess(result: VoteCommentParser.Result) { showTip( if (result.expectVote > 0) { if (0 != result.vote) R.string.vote_up_successfully else R.string.cancel_vote_up_successfully } else if (0 != result.vote) { R.string.vote_down_successfully } else { R.string.cancel_vote_down_successfully }, LENGTH_SHORT, ) val scene = this@GalleryCommentsScene scene.onVoteCommentSuccess(result) } override fun onFailure(e: Exception) { showTip(R.string.vote_failed, LENGTH_LONG) } override fun onCancel() {} } private class InfoHolder(itemView: View?) : RecyclerView.ViewHolder( itemView!!, ) { val key: TextView = ViewUtils.`$$`(itemView, R.id.key) as TextView val value: TextView = ViewUtils.`$$`(itemView, R.id.value) as TextView } private abstract class CommentHolder(inflater: LayoutInflater, resId: Int, parent: ViewGroup?) : RecyclerView.ViewHolder(inflater.inflate(resId, parent, false)) private class MoreCommentHolder(inflater: LayoutInflater, parent: ViewGroup?) : CommentHolder(inflater, R.layout.item_gallery_comment_more, parent) private class ProgressCommentHolder(inflater: LayoutInflater, parent: ViewGroup?) : CommentHolder(inflater, R.layout.item_gallery_comment_progress, parent) private inner class ActualCommentHolder(inflater: LayoutInflater, parent: ViewGroup?) : CommentHolder(inflater, R.layout.item_gallery_comment, parent) { private val user: TextView = itemView.findViewById(R.id.user) private val time: TextView = itemView.findViewById(R.id.time) val comment: LinkifyTextView = itemView.findViewById(R.id.comment) lateinit var sp: CharSequence private fun generateComment( context: Context, textView: ObservedTextView, comment: GalleryComment, ): CharSequence { sp = loadHtml(comment.comment, textView) val ssb = SpannableStringBuilder(sp) if (0L != comment.id && 0 != comment.score) { val score = comment.score val scoreString = if (score > 0) "+$score" else score.toString() ssb.append(" ").inSpans( RelativeSizeSpan(0.8f), StyleSpan(Typeface.BOLD), ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)), ) { append(scoreString) } } if (comment.lastEdited != 0L) { val str = context.getString( R.string.last_edited, ReadableTime.getTimeAgo(comment.lastEdited), ) ssb.append("\n\n").inSpans( RelativeSizeSpan(0.8f), StyleSpan(Typeface.BOLD), ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)), ) { append(str) } } return TextUrl.handleTextUrl(ssb) } fun bind(value: GalleryComment) { user.text = if (value.uploader) { getString( R.string.comment_user_uploader, value.user, ) } else { value.user } user.setOnClickListener { if ("Anonymous" != value.user) { val lub = ListUrlBuilder() lub.mode = ListUrlBuilder.MODE_UPLOADER lub.keyword = value.user GalleryListScene.startScene(this@GalleryCommentsScene, lub) } } time.text = ReadableTime.getTimeAgo(value.time) comment.text = generateComment(comment.context, comment, value) } } private inner class CommentAdapter : RecyclerView.Adapter() { private val mInflater: LayoutInflater = layoutInflater override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentHolder = when (viewType) { TYPE_COMMENT -> ActualCommentHolder(mInflater, parent) TYPE_MORE -> MoreCommentHolder(mInflater, parent) TYPE_PROGRESS -> ProgressCommentHolder(mInflater, parent) else -> throw IllegalStateException("Invalid view type: $viewType") } override fun onBindViewHolder(holder: CommentHolder, position: Int) { val context = context if (context == null || mGalleryDetail == null || mGalleryDetail!!.comments == null) { return } holder.itemView.setOnClickListener { onItemClick( mRecyclerView, holder.itemView, position, ) } holder.itemView.isClickable = true holder.itemView.isFocusable = true if (holder is ActualCommentHolder) { holder.bind(mGalleryDetail!!.comments!!.comments!![position]) } } override fun getItemCount(): Int = if (mGalleryDetail == null || mGalleryDetail!!.comments == null || mGalleryDetail!!.comments!!.comments == null) { 0 } else if (mGalleryDetail!!.comments!!.hasMore) { mGalleryDetail!!.comments!!.comments!!.size + 1 } else { mGalleryDetail!!.comments!!.comments!!.size } override fun getItemViewType(position: Int): Int = if (position >= mGalleryDetail!!.comments!!.comments!!.size) { if (mRefreshingComments) TYPE_PROGRESS else TYPE_MORE } else { TYPE_COMMENT } } companion object { val TAG: String = GalleryCommentsScene::class.java.simpleName const val KEY_API_UID = "api_uid" const val KEY_API_KEY = "api_key" const val KEY_GID = "gid" const val KEY_TOKEN = "token" const val KEY_COMMENT_LIST = "comment_list" const val KEY_GALLERY_DETAIL = "gallery_detail" private const val TYPE_COMMENT = 0 private const val TYPE_MORE = 1 private const val TYPE_PROGRESS = 2 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog import android.app.DownloadManager import android.app.ForegroundServiceStartNotAllowedException import android.app.assist.AssistContent import android.content.ActivityNotFoundException import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.content.res.ColorStateList import android.graphics.Color import android.graphics.Typeface import android.os.Bundle import android.os.Environment import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.View.OnLongClickListener import android.view.ViewAnimationUtils import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import android.widget.RatingBar import android.widget.ScrollView import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.annotation.IntDef import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.lifecycleScope import androidx.transition.TransitionInflater import com.google.android.material.progressindicator.CircularProgressIndicator import com.google.android.material.snackbar.Snackbar import com.hippo.app.CheckBoxDialogBuilder import com.hippo.app.EditTextDialogBuilder import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache import com.hippo.ehviewer.EhApplication.Companion.imageCache import com.hippo.ehviewer.EhApplication.Companion.okHttpClient import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.UrlOpener import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhFilter import com.hippo.ehviewer.client.EhRequest import com.hippo.ehviewer.client.EhRequestBuilder import com.hippo.ehviewer.client.EhTagDatabase import com.hippo.ehviewer.client.EhTagDatabase.isTranslatable import com.hippo.ehviewer.client.EhTagDatabase.namespaceToPrefix import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.GalleryComment import com.hippo.ehviewer.client.data.GalleryCommentList import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.data.GalleryTagGroup import com.hippo.ehviewer.client.data.ListUrlBuilder import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.getImageKey import com.hippo.ehviewer.client.getThumbKey import com.hippo.ehviewer.client.parser.ArchiveParser import com.hippo.ehviewer.client.parser.GalleryDetailParser import com.hippo.ehviewer.client.parser.HomeParser import com.hippo.ehviewer.client.parser.RateGalleryParser import com.hippo.ehviewer.client.parser.TorrentParser import com.hippo.ehviewer.client.thumbUrl import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.dao.Filter import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager import com.hippo.ehviewer.download.DownloadManager.DownloadInfoListener import com.hippo.ehviewer.download.DownloadService import com.hippo.ehviewer.spider.SpiderDen import com.hippo.ehviewer.spider.SpiderInfo import com.hippo.ehviewer.spider.SpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.Companion.GET_FULL_HASH import com.hippo.ehviewer.spider.SpiderQueen.Companion.MODE_READ import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME import com.hippo.ehviewer.spider.saveToUniFile import com.hippo.ehviewer.ui.CommonOperations import com.hippo.ehviewer.ui.GalleryActivity import com.hippo.ehviewer.widget.GalleryRatingBar import com.hippo.ehviewer.widget.GalleryRatingBar.OnUserRateListener import com.hippo.scene.Announcer import com.hippo.scene.TransitionHelper import com.hippo.util.AppHelper import com.hippo.util.ExceptionUtils import com.hippo.util.ReadableTime import com.hippo.util.addTextToClipboard import com.hippo.util.getParcelableCompat import com.hippo.util.isAtLeastQ import com.hippo.util.isAtLeastS import com.hippo.util.launchIO import com.hippo.util.loadHtml import com.hippo.util.withUIContext import com.hippo.view.ViewTransition import com.hippo.widget.AutoWrapLayout import com.hippo.widget.LoadImageView import com.hippo.widget.ObservedTextView import com.hippo.widget.SimpleGridAutoSpanLayout import com.hippo.yorozuya.FileUtils import com.hippo.yorozuya.IntIdGenerator import com.hippo.yorozuya.SimpleHandler import com.hippo.yorozuya.ViewUtils import com.hippo.yorozuya.collect.IntList import kotlin.math.abs import kotlin.math.hypot import kotlin.math.max import kotlin.math.roundToInt import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.coroutines.executeAsync import rikka.core.res.resolveBoolean import rikka.core.res.resolveColor class GalleryDetailScene : BaseScene(), View.OnClickListener, DownloadInfoListener, OnLongClickListener { private var mTip: TextView? = null private var mViewTransition: ViewTransition? = null // Header private var mHeader: FrameLayout? = null private var mColorBg: View? = null private var mThumb: LoadImageView? = null private var mTitle: TextView? = null private var mUploader: TextView? = null private var mCategory: TextView? = null private var mBackAction: ImageView? = null private var mOtherActions: ImageView? = null private var mActionGroup: ViewGroup? = null private var mDownload: TextView? = null private var mRead: TextView? = null // Below header private var mBelowHeader: View? = null // Info private var mInfo: View? = null private var mLanguage: TextView? = null private var mPages: TextView? = null private var mSize: TextView? = null private var mPosted: TextView? = null private var mFavoredTimes: TextView? = null private var mNewerVersion: TextView? = null // Actions private var mActions: View? = null private var mRatingText: TextView? = null private var mRating: RatingBar? = null private var mHeartGroup: View? = null private var mHeart: TextView? = null private var mHeartOutline: TextView? = null private var mTorrent: TextView? = null private var mArchive: TextView? = null private var mShare: TextView? = null private var mRate: View? = null private var mSimilar: TextView? = null // Tags private var mTags: LinearLayout? = null private var mNoTags: TextView? = null // Comments private var mComments: LinearLayout? = null private var mCommentsText: TextView? = null // Previews private var mPreviews: View? = null private var mGridLayout: SimpleGridAutoSpanLayout? = null private var mPreviewText: TextView? = null // Progress private var mProgress: View? = null private var mViewTransition2: ViewTransition? = null private var mPopupMenu: PopupMenu? = null private var mDownloadState = 0 private var mAction: String? = null private var mGalleryInfo: GalleryInfo? = null private var mGid: Long = 0 private var mToken: String? = null private var mPage = 0 private var mGalleryDetail: GalleryDetail? = null private var mRequestId = IntIdGenerator.INVALID_ID private var mTorrentList: List? = null private var requestStoragePermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { result: Boolean -> if (result && mGalleryDetail != null) { val helper = TorrentListDialogHelper() val dialog = AlertDialog.Builder(requireActivity()) .setTitle(R.string.torrents) .setView(R.layout.dialog_torrent_list) .setOnDismissListener(helper) .show() helper.setDialog(dialog, mGalleryDetail!!.torrentUrl) } } private var mArchiveList: List? = null private var mCurrentFunds: HomeParser.Funds? = null @State private var mState = STATE_INIT private var mModifyingFavorites = false @StringRes private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) { 0 -> R.string.rating0 1 -> R.string.rating1 2 -> R.string.rating2 3 -> R.string.rating3 4 -> R.string.rating4 5 -> R.string.rating5 6 -> R.string.rating6 7 -> R.string.rating7 8 -> R.string.rating8 9 -> R.string.rating9 10 -> R.string.rating10 else -> R.string.rating_none } private fun handleArgs(args: Bundle?) { val action = args?.getString(KEY_ACTION) ?: return mAction = action if (ACTION_GALLERY_INFO == action) { mGalleryInfo = args.getParcelableCompat(KEY_GALLERY_INFO) // Add history // DB Actions mGalleryInfo?.let { EhDB.putHistoryInfo(it) } } else if (ACTION_GID_TOKEN == action) { mGid = args.getLong(KEY_GID) mToken = args.getString(KEY_TOKEN) mPage = args.getInt(KEY_PAGE) } } private val galleryDetailUrl: String? get() { val gid: Long val token: String? if (mGalleryDetail != null) { gid = mGalleryDetail!!.gid token = mGalleryDetail!!.token } else if (mGalleryInfo != null) { gid = mGalleryInfo!!.gid token = mGalleryInfo!!.token } else if (ACTION_GID_TOKEN == mAction) { gid = mGid token = mToken } else { return null } return EhUrl.getGalleryDetailUrl(gid, token, 0, false) } // -1 for error private val gid: Long get() = if (mGalleryDetail != null) { mGalleryDetail!!.gid } else if (mGalleryInfo != null) { mGalleryInfo!!.gid } else if (ACTION_GID_TOKEN == mAction) { mGid } else { -1 } private val uploader: String? get() = if (mGalleryDetail != null) { mGalleryDetail!!.uploader } else if (mGalleryInfo != null) { mGalleryInfo!!.uploader } else { null } // Judging by the uploader to exclude the cooldown period private val disowned: Boolean get() = uploader == "(Disowned)" // -1 for error private val category: Int get() = if (mGalleryDetail != null) { mGalleryDetail!!.category } else if (mGalleryInfo != null) { mGalleryInfo!!.category } else { -1 } private val galleryInfo: GalleryInfo? get() = if (null != mGalleryDetail) { mGalleryDetail } else if (null != mGalleryInfo) { mGalleryInfo } else { null } override var needWhiteStatusBar = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } override fun onResume() { super.onResume() mRead ?: return mGalleryInfo?.let { // Other Actions viewLifecycleOwner.lifecycleScope.launchIO { runCatching { val queen = SpiderQueen.obtainSpiderQueen(it, MODE_READ) val startPage = queen.awaitStartPage() SpiderQueen.releaseSpiderQueen(queen, MODE_READ) withUIContext { mRead!!.text = if (startPage == 0) { getString(R.string.read) } else { getString(R.string.read_from, startPage + 1) } } }.onFailure { e -> e.printStackTrace() } } } } private fun onInit() { handleArgs(arguments) } private fun onRestore(savedInstanceState: Bundle) { mAction = savedInstanceState.getString(KEY_ACTION) mGalleryInfo = savedInstanceState.getParcelableCompat(KEY_GALLERY_INFO) mGid = savedInstanceState.getLong(KEY_GID) mToken = savedInstanceState.getString(KEY_TOKEN) mGalleryDetail = savedInstanceState.getParcelableCompat(KEY_GALLERY_DETAIL) mRequestId = savedInstanceState.getInt(KEY_REQUEST_ID) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) if (mAction != null) { outState.putString(KEY_ACTION, mAction) } if (mGalleryInfo != null) { outState.putParcelable(KEY_GALLERY_INFO, mGalleryInfo) } outState.putLong(KEY_GID, mGid) if (mToken != null) { outState.putString(KEY_TOKEN, mAction) } if (mGalleryDetail != null) { outState.putParcelable(KEY_GALLERY_DETAIL, mGalleryDetail) } outState.putInt(KEY_REQUEST_ID, mRequestId) } private fun ensurePopMenu() { if (mPopupMenu != null || mOtherActions == null) { return } val popup = PopupMenu(requireContext(), mOtherActions!! as View) mPopupMenu = popup popup.menuInflater.inflate(R.menu.scene_gallery_detail, popup.menu) popup.setOnMenuItemClickListener( object : PopupMenu.OnMenuItemClickListener { override fun onMenuItemClick(item: MenuItem): Boolean { when (item.itemId) { R.id.action_refresh -> { if (mState != STATE_REFRESH && mState != STATE_REFRESH_HEADER) { adjustViewVisibility(STATE_REFRESH, true) request() } return true } R.id.action_add_tag -> { if (mGalleryDetail == null) { return false } if (mGalleryDetail!!.apiUid < 0) { showTip(R.string.error_please_login_first, LENGTH_LONG) return false } val builder = EditTextDialogBuilder(requireContext(), "", getString(R.string.action_add_tag_tip)) builder.setPositiveButton(android.R.string.ok, null) val dialog = builder.setTitle(R.string.action_add_tag) .show() dialog.getButton(DialogInterface.BUTTON_POSITIVE) .setOnClickListener { voteTag(builder.text.trim { it <= ' ' }, 1) dialog.dismiss() } return true } R.id.action_clear_image_cache -> { if (mGalleryDetail == null) { return false } SpiderQueen.reset(mGalleryDetail!!.gid) (0.. { val url = galleryDetailUrl val activity: Activity? = mainActivity if (null != url && null != activity) { UrlOpener.openUrl(activity, url, false) } return true } } return false } }, ) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { // Get download state val gid = gid mDownloadState = if (gid != -1L) { EhDownloadManager.getDownloadState(gid) } else { DownloadInfo.STATE_INVALID } val view = inflater.inflate(R.layout.scene_gallery_detail, container, false) val main = ViewUtils.`$$`(view, R.id.main) as ViewGroup val mainView = ViewUtils.`$$`(main, R.id.scroll_view) as ScrollView mainView.setOnScrollChangeListener { _, _, scrollY, _, _ -> if (mActionGroup != null && mHeader != null) { setLightStatusBar( ( mActionGroup!!.y - mHeader!!.findViewById(R.id.header_content) .paddingTop / 2f ).toInt() < scrollY, ) } } val progressView = ViewUtils.`$$`(main, R.id.progress_view) mTip = ViewUtils.`$$`(main, R.id.tip) as TextView mViewTransition = ViewTransition(mainView, progressView, mTip) val drawable = AppCompatResources.getDrawable(requireContext(), R.drawable.big_sad_pandroid) drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) mTip!!.setCompoundDrawables(null, drawable, null, null) mTip!!.setOnClickListener(this) mHeader = ViewUtils.`$$`(mainView, R.id.header) as FrameLayout mColorBg = ViewUtils.`$$`(mHeader, R.id.color_bg) mThumb = ViewUtils.`$$`(mHeader, R.id.thumb) as LoadImageView mTitle = ViewUtils.`$$`(mHeader, R.id.title) as TextView mUploader = ViewUtils.`$$`(mHeader, R.id.uploader) as TextView mCategory = ViewUtils.`$$`(mHeader, R.id.category) as TextView mBackAction = ViewUtils.`$$`(mHeader, R.id.back_action) as ImageView mOtherActions = ViewUtils.`$$`(mHeader, R.id.other_actions) as ImageView mActionGroup = ViewUtils.`$$`(mHeader, R.id.action_card) as ViewGroup mDownload = ViewUtils.`$$`(mActionGroup, R.id.download) as TextView mRead = ViewUtils.`$$`(mActionGroup, R.id.read) as TextView mUploader!!.setOnClickListener(this) mCategory!!.setOnClickListener(this) mBackAction!!.setOnClickListener(this) mOtherActions!!.setOnClickListener(this) mDownload!!.setOnClickListener(this) mDownload!!.setOnLongClickListener(this) mRead!!.setOnClickListener(this) mUploader!!.setOnLongClickListener(this) mBelowHeader = mainView.findViewById(R.id.below_header) val belowHeader = mBelowHeader mInfo = ViewUtils.`$$`(belowHeader, R.id.info) mLanguage = ViewUtils.`$$`(mInfo, R.id.language) as TextView mPages = ViewUtils.`$$`(mInfo, R.id.pages) as TextView mSize = ViewUtils.`$$`(mInfo, R.id.size) as TextView mPosted = ViewUtils.`$$`(mInfo, R.id.posted) as TextView mFavoredTimes = ViewUtils.`$$`(mInfo, R.id.favoredTimes) as TextView mInfo!!.setOnClickListener(this) mActions = ViewUtils.`$$`(belowHeader, R.id.actions) mNewerVersion = ViewUtils.`$$`(mActions, R.id.newerVersion) as TextView mRatingText = ViewUtils.`$$`(mActions, R.id.rating_text) as TextView mRating = ViewUtils.`$$`(mActions, R.id.rating) as RatingBar mHeartGroup = ViewUtils.`$$`(mActions, R.id.heart_group) mHeart = ViewUtils.`$$`(mHeartGroup, R.id.heart) as TextView mHeartOutline = ViewUtils.`$$`(mHeartGroup, R.id.heart_outline) as TextView mTorrent = ViewUtils.`$$`(mActions, R.id.torrent) as TextView mArchive = ViewUtils.`$$`(mActions, R.id.archive) as TextView mShare = ViewUtils.`$$`(mActions, R.id.share) as TextView mRate = ViewUtils.`$$`(mActions, R.id.rate) mSimilar = ViewUtils.`$$`(mActions, R.id.similar) as TextView mNewerVersion!!.setOnClickListener(this) mHeartGroup!!.setOnClickListener(this) mHeartGroup!!.setOnLongClickListener(this) mTorrent!!.setOnClickListener(this) mArchive!!.setOnClickListener(this) mShare!!.setOnClickListener(this) mRate!!.setOnClickListener(this) mSimilar!!.setOnClickListener(this) ensureActionDrawable() mTags = ViewUtils.`$$`(belowHeader, R.id.tags) as LinearLayout mNoTags = ViewUtils.`$$`(mTags, R.id.no_tags) as TextView mComments = ViewUtils.`$$`(belowHeader, R.id.comments) as LinearLayout if (Settings.showComments) { mCommentsText = ViewUtils.`$$`(mComments, R.id.comments_text) as TextView mComments!!.setOnClickListener(this) } else { mComments!!.visibility = View.GONE } mPreviews = ViewUtils.`$$`(belowHeader, R.id.previews) mGridLayout = ViewUtils.`$$`(mPreviews, R.id.grid_layout) as SimpleGridAutoSpanLayout mPreviewText = ViewUtils.`$$`(mPreviews, R.id.preview_text) as TextView mPreviews!!.setOnClickListener(this) mProgress = ViewUtils.`$$`(mainView, R.id.progress) mViewTransition2 = ViewTransition(mBelowHeader, mProgress) if (prepareData()) { if (mGalleryDetail != null) { bindViewSecond() setTransitionName() adjustViewVisibility(STATE_NORMAL, false) } else if (mGalleryInfo != null) { bindViewFirst() setTransitionName() adjustViewVisibility(STATE_REFRESH_HEADER, false) } else { adjustViewVisibility(STATE_REFRESH, false) } } else { mTip!!.setText(R.string.error_cannot_find_gallery) adjustViewVisibility(STATE_FAILED, false) } EhDownloadManager.addDownloadInfoListener(this) return view } override fun onDestroyView() { super.onDestroyView() EhDownloadManager.removeDownloadInfoListener(this) mTip = null mViewTransition = null mHeader = null mColorBg = null mThumb = null mTitle = null mUploader = null mCategory = null mBackAction = null mOtherActions = null mActionGroup = null mDownload = null mRead = null mBelowHeader = null mInfo = null mLanguage = null mPages = null mSize = null mPosted = null mFavoredTimes = null mActions = null mNewerVersion = null mRatingText = null mRating = null mHeartGroup = null mHeart = null mHeartOutline = null mTorrent = null mArchive = null mShare = null mRate = null mSimilar = null mTags = null mNoTags = null mComments = null mCommentsText = null mPreviews = null mGridLayout = null mPreviewText = null mProgress = null mViewTransition2 = null mPopupMenu = null } private fun prepareData(): Boolean { if (mGalleryDetail != null) { return true } val gid = gid if (gid == -1L) { return false } // Get from cache mGalleryDetail = galleryDetailCache[gid] if (mGalleryDetail != null) { return true } val application = requireContext().applicationContext as EhApplication return if (application.containGlobalStuff(mRequestId)) { // request exist true } else { request() } } private fun request(): Boolean { val context = context val activity = mainActivity val url = galleryDetailUrl if (null == context || null == activity || null == url) { return false } val callback: EhClient.Callback<*> = GetGalleryDetailListener(context) mRequestId = (context.applicationContext as EhApplication).putGlobalStuff(callback) val request = EhRequest() .setMethod(EhClient.METHOD_GET_GALLERY_DETAIL) .setArgs(url) .setCallback(callback) request.enqueue(this) return true } private fun setActionDrawable(text: TextView?, @DrawableRes resId: Int) { text ?: return val drawable = AppCompatResources.getDrawable(text.context, resId) ?: return drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) text.setCompoundDrawables(null, drawable, null, null) } private fun ensureActionDrawable() { setActionDrawable(mHeart, R.drawable.v_heart_primary_x48) setActionDrawable(mHeartOutline, R.drawable.v_heart_outline_primary_x48) setActionDrawable(mTorrent, R.drawable.v_utorrent_primary_x48) setActionDrawable(mArchive, R.drawable.v_archive_primary_x48) setActionDrawable(mShare, R.drawable.v_share_primary_x48) setActionDrawable(mSimilar, R.drawable.v_similar_primary_x48) } private fun createCircularReveal(): Boolean { val context = context if (null == context || null == mColorBg) { return false } val w = mColorBg!!.width val h = mColorBg!!.height return if (mColorBg!!.isAttachedToWindow && w != 0 && h != 0) { val resources = context.resources val keylineMargin = resources.getDimensionPixelSize(R.dimen.keyline_margin) val thumbWidth = resources.getDimensionPixelSize(R.dimen.gallery_detail_thumb_width) val thumbHeight = resources.getDimensionPixelSize(R.dimen.gallery_detail_thumb_height) val x = thumbWidth / 2 + keylineMargin val y = thumbHeight / 2 + keylineMargin val radiusX = max(abs(x), abs(w - x)).toDouble() val radiusY = max(abs(y), abs(h - y)).toDouble() val radius = hypot(radiusX, radiusY).toFloat() ViewAnimationUtils.createCircularReveal(mColorBg!!, x, y, 0f, radius).setDuration(300).start() true } else { false } } @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") private fun adjustViewVisibility(state: Int, animation: Boolean) { if (state == mState || mViewTransition == null || mViewTransition2 == null) { return } val oldState = mState mState = state val doAnimation = !TRANSITION_ANIMATION_DISABLED && animation when (state) { STATE_NORMAL -> { setLightStatusBar(false) // Show mMainView mViewTransition!!.showView(0, doAnimation) // Show mBelowHeader mViewTransition2!!.showView(0, doAnimation) } STATE_REFRESH -> { setLightStatusBar(true) // Show mProgressView mViewTransition!!.showView(1, doAnimation) } STATE_REFRESH_HEADER -> { setLightStatusBar(false) // Show mMainView mViewTransition!!.showView(0, doAnimation) // Show mProgress mViewTransition2!!.showView(1, doAnimation) } STATE_INIT, STATE_FAILED -> { setLightStatusBar(true) // Show mFailedView mViewTransition!!.showView(2, doAnimation) } } if ((oldState == STATE_INIT || oldState == STATE_FAILED || oldState == STATE_REFRESH) && (state == STATE_NORMAL || state == STATE_REFRESH_HEADER) && theme.resolveBoolean(androidx.appcompat.R.attr.isLightTheme, false) ) { if (!createCircularReveal()) { SimpleHandler.getInstance().post(this::createCircularReveal) } } } private fun bindViewFirst() { if (mGalleryDetail != null || mThumb == null || mTitle == null || mUploader == null || mCategory == null) { return } if (ACTION_GALLERY_INFO == mAction && mGalleryInfo != null) { val gi: GalleryInfo = mGalleryInfo!! mThumb!!.load(getThumbKey(gi.gid), gi.thumbUrl!!, hardware = false) mTitle!!.text = EhUtils.getSuitableTitle(gi) mUploader!!.text = gi.uploader mUploader!!.alpha = if (gi.disowned) .5f else 1f mCategory!!.text = EhUtils.getCategory(gi.category) mCategory!!.setTextColor(EhUtils.getCategoryColor(gi.category)) updateDownloadText() } } private fun updateFavoriteDrawable() { val gd = mGalleryDetail ?: return if (mHeart == null || mHeartOutline == null) { return } // DB Actions if (gd.isFavorited || EhDB.containLocalFavorites(gd.gid)) { mHeart!!.visibility = View.VISIBLE if (gd.favoriteName == null) { mHeart!!.setText(R.string.local_favorites) } else { mHeart!!.text = gd.favoriteName } mHeartOutline!!.visibility = View.GONE } else { mHeart!!.visibility = View.GONE mHeartOutline!!.visibility = View.VISIBLE } } private fun bindViewSecond() { context ?: return val gd = mGalleryDetail ?: return if (mPage != 0) { Snackbar.make( requireActivity().findViewById(R.id.snackbar), getString(R.string.read_from, mPage + 1), Snackbar.LENGTH_LONG, ) .setAction(R.string.read) { val intent = Intent(context, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, mGalleryDetail) intent.putExtra(GalleryActivity.KEY_PAGE, mPage) startActivity(intent) } .show() } if (mThumb == null || mTitle == null || mUploader == null || mCategory == null || mLanguage == null || mPages == null || mSize == null || mPosted == null || mFavoredTimes == null || mRatingText == null || mRating == null || mTorrent == null || mNewerVersion == null) { return } val resources = resources mThumb!!.load(getThumbKey(gd.gid), gd.thumbUrl!!, false, hardware = false) mTitle!!.text = EhUtils.getSuitableTitle(gd) mUploader!!.text = gd.uploader mUploader!!.alpha = if (gd.disowned) .5f else 1f mCategory!!.text = EhUtils.getCategory(gd.category) mCategory!!.setTextColor(EhUtils.getCategoryColor(gd.category)) updateDownloadText() mLanguage!!.text = gd.language mPages!!.text = resources.getQuantityString( R.plurals.page_count, gd.pages, gd.pages, ) mSize!!.text = gd.size mPosted!!.text = gd.posted mFavoredTimes!!.text = resources.getString(R.string.favored_times, gd.favoriteCount) if (gd.newerVersions.isNotEmpty()) { mNewerVersion!!.visibility = View.VISIBLE } mRatingText!!.text = getAllRatingText(gd.rating, gd.ratingCount) mRating!!.rating = gd.rating updateFavoriteDrawable() mTorrent!!.text = resources.getString(R.string.torrent_count, gd.torrentCount) bindTags(gd.tags) bindComments(gd.comments!!.comments) bindPreviews(gd) } private fun bindTags(tagGroups: Array?) { val context = context if (null == context || null == mTags || null == mNoTags) { return } mTags!!.removeViews(1, mTags!!.childCount - 1) if (tagGroups.isNullOrEmpty()) { mNoTags!!.visibility = View.VISIBLE return } else { mNoTags!!.visibility = View.GONE } val ehTags = if (Settings.showTagTranslations && isTranslatable(context)) EhTagDatabase else null val colorTag = theme.resolveColor(R.attr.tagBackgroundColor) val colorName = theme.resolveColor(R.attr.tagGroupBackgroundColor) for (tgs in tagGroups) { val ll = layoutInflater.inflate(R.layout.gallery_tag_group, mTags, false) as LinearLayout ll.orientation = LinearLayout.HORIZONTAL mTags!!.addView(ll) var readableTagName: String? = null if (ehTags != null && ehTags.isInitialized()) { readableTagName = ehTags.getTranslation(tag = tgs.groupName) } val tgName = layoutInflater.inflate(R.layout.item_gallery_tag, ll, false) as TextView ll.addView(tgName) tgName.text = readableTagName ?: tgs.groupName tgName.backgroundTintList = ColorStateList.valueOf(colorName) val prefix = namespaceToPrefix(tgs.groupName!!) val awl = AutoWrapLayout(context) ll.addView( awl, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) for (tg in tgs) { val tag = layoutInflater.inflate(R.layout.item_gallery_tag, awl, false) as TextView awl.addView(tag) var tagStr = tg var status: String? = null while (tagStr.startsWith("_")) { when (tagStr.substring(1, 2)) { "W" -> tag.alpha = 0.5f "L" -> tag.setTypeface(tag.typeface, Typeface.ITALIC) "U" -> status = TAG_STATUS_UP "D" -> status = TAG_STATUS_DN } tagStr = tagStr.substring(2) } var readableTag: String? = null if (ehTags != null && ehTags.isInitialized()) { readableTag = ehTags.getTranslation(prefix, tagStr) } var tagText = readableTag ?: tagStr if (status != null) { tagText += status } else if (tag.typeface.isItalic) { tagText += " " } tag.text = tagText tag.backgroundTintList = ColorStateList.valueOf(colorTag) tag.setTag(R.id.tag, tgs.groupName + ":" + tagStr) tag.setOnClickListener(this) tag.setOnLongClickListener(this) } } } private fun bindComments(comments: Array?) { val context = context val inflater = layoutInflater if (null == context || null == mComments || null == mCommentsText) { return } mComments!!.removeViews(0, mComments!!.childCount - 1) val maxShowCount = 2 if (comments.isNullOrEmpty()) { mCommentsText!!.setText(R.string.no_comments) return } else if (comments.size <= maxShowCount) { mCommentsText!!.setText(R.string.no_more_comments) } else { mCommentsText!!.setText(R.string.more_comment) } val length = maxShowCount.coerceAtMost(comments.size) for (i in 0 until length) { val comment = comments[i] val v = inflater.inflate(R.layout.item_gallery_comment, mComments, false) mComments!!.addView(v, i) val user = v.findViewById(R.id.user) user.text = comment.user user.setBackgroundColor(Color.TRANSPARENT) val time = v.findViewById(R.id.time) time.text = ReadableTime.getTimeAgo(comment.time) val c = v.findViewById(R.id.comment) c.maxLines = 5 c.text = loadHtml(comment.comment, c) v.setBackgroundColor(Color.TRANSPARENT) } } @SuppressLint("SetTextI18n") private fun bindPreviews(gd: GalleryDetail) { val inflater = layoutInflater val resources = resourcesOrNull if (null == resources || null == mGridLayout || null == mPreviewText) { return } mGridLayout!!.removeAllViews() val previewSet = gd.previewSet val previewNum = Settings.previewNum if (gd.previewPages <= 0 || previewSet == null || previewSet.size() == 0) { mPreviewText!!.setText(R.string.no_previews) return } else if (gd.previewPages == 1 && previewSet.size() <= previewNum) { mPreviewText!!.setText(R.string.no_more_previews) } else { mPreviewText!!.setText(R.string.more_previews) } mGridLayout!!.setColumnSize(Settings.previewSize) mGridLayout!!.setStrategy(SimpleGridAutoSpanLayout.STRATEGY_SUITABLE_SIZE) val size = previewNum.coerceAtMost(previewSet.size()) for (i in 0 until size) { val view = inflater.inflate(R.layout.item_gallery_preview, mGridLayout, false) val image = view.findViewById(R.id.image) mGridLayout!!.addView(view) image.setTag(R.id.index, i) image.setOnClickListener(this) val text = view.findViewById(R.id.text) text.text = (previewSet.getPosition(i) + 1).toString() previewSet.load(image, gd.gid, i) } } private fun getAllRatingText(rating: Float, ratingCount: Int): String = getString( R.string.rating_text, getString(getRatingText(rating)), rating, ratingCount, ) private fun setTransitionName() { val gid = gid if (gid != -1L && mThumb != null && mTitle != null && mUploader != null && mCategory != null ) { ViewCompat.setTransitionName(mThumb!!, TransitionNameFactory.getThumbTransitionName(gid)) ViewCompat.setTransitionName(mTitle!!, TransitionNameFactory.getTitleTransitionName(gid)) ViewCompat.setTransitionName(mUploader!!, TransitionNameFactory.getUploaderTransitionName(gid)) ViewCompat.setTransitionName(mCategory!!, TransitionNameFactory.getCategoryTransitionName(gid)) } } private fun showSimilarGalleryList() { val gd = mGalleryDetail ?: return val keyword = EhUtils.extractTitle(gd.title) if (null != keyword) { val lub = ListUrlBuilder() lub.mode = ListUrlBuilder.MODE_NORMAL lub.keyword = "\"" + keyword + "\"" GalleryListScene.startScene(this, lub) return } val artist = getArtist(gd.tags) if (null != artist) { val lub = ListUrlBuilder() lub.mode = ListUrlBuilder.MODE_TAG lub.keyword = "artist:$artist" GalleryListScene.startScene(this, lub) return } if (null != gd.uploader) { val lub = ListUrlBuilder() lub.mode = ListUrlBuilder.MODE_UPLOADER lub.keyword = gd.uploader GalleryListScene.startScene(this, lub) } } override fun onClick(v: View) { val context = context val activity = mainActivity if (null == context || null == activity) { return } if (v == mTip && request()) { adjustViewVisibility(STATE_REFRESH, true) } val galleryDetail = mGalleryDetail ?: return when (v) { mBackAction -> { onBackPressed() } mOtherActions -> { ensurePopMenu() mPopupMenu?.show() } mUploader -> { if (uploader.isNullOrEmpty() || disowned) { return } val lub = ListUrlBuilder() lub.mode = ListUrlBuilder.MODE_UPLOADER lub.keyword = uploader GalleryListScene.startScene(this, lub) } mCategory -> { val category = category if (category == EhUtils.NONE || category == EhUtils.PRIVATE || category == EhUtils.UNKNOWN) { return } val lub = ListUrlBuilder() lub.category = category GalleryListScene.startScene(this, lub) } mDownload -> { val downloadState = EhDownloadManager.getDownloadState(galleryDetail.gid) when (downloadState) { DownloadInfo.STATE_INVALID -> { // CommonOperations Actions CommonOperations.startDownload(activity, galleryDetail, false) } DownloadInfo.STATE_FINISH if galleryDetail.newerVersions.isNotEmpty() -> { showGalleryUpgradeDialog(galleryDetail) } else -> { val builder = CheckBoxDialogBuilder( context, getString(R.string.download_remove_dialog_message, galleryDetail.title), getString(R.string.download_remove_dialog_check_text), Settings.removeImageFiles, ) val helper = DeleteDialogHelper(galleryDetail, builder) builder.setTitle(R.string.download_remove_dialog_title) .setPositiveButton(android.R.string.ok, helper) .show() } } } mRead -> { val intent = Intent(activity, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, galleryDetail) startActivity(intent) } mNewerVersion -> { val titles = ArrayList() for (newerVersion in galleryDetail.newerVersions) { titles.add( getString( R.string.newer_version_title, newerVersion.title, newerVersion.posted, ), ) } AlertDialog.Builder(requireContext()) .setItems(titles.toTypedArray()) { _: DialogInterface?, which: Int -> val newerVersion = galleryDetail.newerVersions[which] val args = Bundle() args.putString(KEY_ACTION, ACTION_GID_TOKEN) args.putLong(KEY_GID, newerVersion.gid) args.putString(KEY_TOKEN, newerVersion.token) startScene(Announcer(GalleryDetailScene::class.java).setArgs(args)) } .show() } mInfo -> { val args = Bundle() args.putParcelable(GalleryInfoScene.KEY_GALLERY_DETAIL, galleryDetail) startScene(Announcer(GalleryInfoScene::class.java).setArgs(args)) } mHeartGroup -> { // DB Actions // CommonOperations Actions if (!mModifyingFavorites) { mModifyingFavorites = true val isLocalFavorites = EhDB.containLocalFavorites(galleryDetail.gid) val isOnlineFavorites = galleryDetail.isFavorited if (isLocalFavorites || isOnlineFavorites) { CommonOperations.removeFromFavorites( activity, galleryDetail, ModifyFavoritesListener(context, true), isLocalFavorites && !isOnlineFavorites, ) } else { CommonOperations.addToFavorites( activity, galleryDetail, ModifyFavoritesListener(context, false), false, ) } // Update UI updateFavoriteDrawable() } } mShare -> { galleryDetailUrl?.let { AppHelper.share(activity, it) } } mTorrent -> { if (!isAtLeastQ && ContextCompat.checkSelfPermission( activity, Manifest.permission.WRITE_EXTERNAL_STORAGE, ) != PackageManager.PERMISSION_GRANTED ) { requestStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { val helper = TorrentListDialogHelper() val dialog = AlertDialog.Builder(context) .setTitle(R.string.torrents) .setView(R.layout.dialog_torrent_list) .setOnDismissListener(helper) .show() helper.setDialog(dialog, galleryDetail.torrentUrl) } } mArchive -> { if (!isAtLeastQ && ContextCompat.checkSelfPermission( activity, Manifest.permission.WRITE_EXTERNAL_STORAGE, ) != PackageManager.PERMISSION_GRANTED ) { requestStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { if (galleryDetail.apiUid < 0) { showTip(R.string.error_please_login_first, LENGTH_LONG) return } val helper = ArchiveListDialogHelper() val dialog = AlertDialog.Builder(context) .setTitle(R.string.settings_download) .setView(R.layout.dialog_archive_list) .setOnDismissListener(helper) .show() helper.setDialog(dialog, galleryDetail.archiveUrl) } } mRate -> { if (galleryDetail.apiUid < 0) { showTip(R.string.error_please_login_first, LENGTH_LONG) return } val helper = RateDialogHelper() val dialog = AlertDialog.Builder(context) .setTitle(R.string.rate) .setView(R.layout.dialog_rate) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok, helper) .show() helper.setDialog(dialog, galleryDetail.rating) } mSimilar -> { showSimilarGalleryList() } mComments -> { val args = Bundle() args.putLong(GalleryCommentsScene.KEY_API_UID, galleryDetail.apiUid) args.putString(GalleryCommentsScene.KEY_API_KEY, galleryDetail.apiKey) args.putLong(GalleryCommentsScene.KEY_GID, galleryDetail.gid) args.putString(GalleryCommentsScene.KEY_TOKEN, galleryDetail.token) args.putParcelable(GalleryCommentsScene.KEY_COMMENT_LIST, galleryDetail.comments) args.putParcelable(GalleryCommentsScene.KEY_GALLERY_DETAIL, galleryDetail) startScene( Announcer(GalleryCommentsScene::class.java) .setArgs(args) .setRequestCode(this, REQUEST_CODE_COMMENT_GALLERY), ) } mPreviews -> { val previewNum = Settings.previewNum var scrollTo = 0 if (previewNum < (galleryDetail.previewSet?.size() ?: 0)) { scrollTo = previewNum } else if (galleryDetail.previewPages > 1) { scrollTo = -1 } val args = Bundle() args.putParcelable(GalleryPreviewsScene.KEY_GALLERY_INFO, galleryDetail) args.putInt(GalleryPreviewsScene.KEY_SCROLL_TO, scrollTo) startScene(Announcer(GalleryPreviewsScene::class.java).setArgs(args)) } else -> { var o = v.getTag(R.id.tag) if (o is String) { val lub = ListUrlBuilder() lub.mode = ListUrlBuilder.MODE_TAG lub.keyword = o GalleryListScene.startScene(this, lub) return } o = v.getTag(R.id.index) if (o is Int) { val intent = Intent(context, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, galleryDetail) intent.putExtra(GalleryActivity.KEY_PAGE, o) startActivity(intent) } } } } private fun showGalleryUpgradeDialog(gd: GalleryDetail) { val context = context val activity = mainActivity if (null == context || null == activity) { return } val titles = ArrayList() gd.newerVersions.forEach { titles.add(getString(R.string.newer_version_title, it.title, it.posted)) } AlertDialog.Builder(requireContext()) .setItems(titles.toTypedArray()) { _: DialogInterface?, which: Int -> val newerVersion = gd.newerVersions[which] if (EhDownloadManager.containDownloadInfo(newerVersion.gid)) { showTip(R.string.download_upgrade_existed, LENGTH_SHORT) } else { val dialog = AlertDialog.Builder(context) .setTitle(null) .setView(R.layout.preference_dialog_task) .setCancelable(false) .show() lifecycleScope.launchIO { var success = false val url = EhUrl.getGalleryDetailUrl( newerVersion.gid, newerVersion.token, 0, false, GET_FULL_HASH, ) val request = EhRequestBuilder(url, EhUrl.referer).build() runCatching { okHttpClient.newCall(request).executeAsync().use { response -> val body = response.body.string() val result = GalleryDetailParser.parse(body) val spiderInfo = SpiderInfo( result.gid, result.token, result.pages, upgradeFrom = gd.gid, ) SpiderQueen.readPreviews(body, 0, spiderInfo) val dirName = FileUtils.sanitizeFilename("${result.gid}-${EhUtils.getSuitableTitle(result)}") SpiderDen.perSafeDownloadDir(result.gid, dirName)!!.run { createFile(SPIDER_INFO_FILENAME)!!.also { spiderInfo.saveToUniFile(it) } } // Start download val label = EhDownloadManager.getDownloadInfo(gd.gid)?.label val intent = Intent(activity, DownloadService::class.java) intent.action = DownloadService.ACTION_START intent.putExtra(DownloadService.KEY_LABEL, label) intent.putExtra(DownloadService.KEY_GALLERY_INFO, result) runCatching { ContextCompat.startForegroundService(activity, intent) success = true }.onFailure { if (isAtLeastS && it is ForegroundServiceStartNotAllowedException) { // App not in a valid state to start foreground service withUIContext { dialog.dismiss() AlertDialog.Builder(context) .setMessage(R.string.download_upgrade_service_failed) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> ContextCompat.startForegroundService(activity, intent) success = true } .show() } } else { it.printStackTrace() } } } }.onFailure { it.printStackTrace() } withUIContext { dialog.dismiss() if (success) { showTip(R.string.added_to_download_list, LENGTH_SHORT) } else { showTip(R.string.download_state_failed, LENGTH_SHORT) launchIO { SpiderDen.getGalleryDownloadDir(newerVersion.gid)?.takeIf { it.exists() }?.delete() EhDownloadManager.removeDownloadDirname(newerVersion.gid) } } } } } } .show() } private fun showFilterUploaderDialog() { val context = context val uploader = uploader if (context == null || uploader == null) { return } AlertDialog.Builder(context) .setMessage(getString(R.string.filter_the_uploader, uploader)) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> val filter = Filter() filter.mode = EhFilter.MODE_UPLOADER filter.text = uploader EhFilter.addFilter(filter) showTip(R.string.filter_added, LENGTH_SHORT) } .setNegativeButton(android.R.string.cancel, null) .show() } private fun showFilterTagDialog(tag: String) { val context = context ?: return AlertDialog.Builder(context) .setMessage(getString(R.string.filter_the_tag, tag)) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> val filter = Filter() filter.mode = EhFilter.MODE_TAG filter.text = tag EhFilter.addFilter(filter) showTip(R.string.filter_added, LENGTH_SHORT) } .setNegativeButton(android.R.string.cancel, null) .show() } private fun showTagDialog(tv: TextView, tag: String) { val context = context ?: return val temp: String val index = tag.indexOf(':') temp = if (index >= 0) { tag.substring(index + 1) } else { tag } val menu: MutableList = ArrayList() val menuId = IntList() val resources = context.resources menu.add(resources.getString(android.R.string.copy)) menuId.add(R.id.copy) if (temp != tv.text.toString()) { menu.add(resources.getString(R.string.copy_trans)) menuId.add(R.id.copy_trans) } menu.add(resources.getString(R.string.show_definition)) menuId.add(R.id.show_definition) menu.add(resources.getString(R.string.add_filter)) menuId.add(R.id.add_filter) if (mGalleryDetail != null && mGalleryDetail!!.apiUid >= 0) { val isUp = tv.text.endsWith(TAG_STATUS_UP) val isDn = tv.text.endsWith(TAG_STATUS_DN) if (!isUp) { menu.add(resources.getString(if (isDn) R.string.tag_vote_down_cancel else R.string.tag_vote_up)) menuId.add(R.id.vote_up) } if (!isDn) { menu.add(resources.getString(if (isUp) R.string.tag_vote_up_cancel else R.string.tag_vote_down)) menuId.add(R.id.vote_down) } } AlertDialog.Builder(context) .setTitle(tag) .setItems(menu.toTypedArray()) { _: DialogInterface?, which: Int -> if (which < 0 || which >= menuId.size) { return@setItems } when (menuId[which]) { R.id.vote_up -> { voteTag(tag, 1) } R.id.vote_down -> { voteTag(tag, -1) } R.id.show_definition -> { UrlOpener.openUrl(context, EhUrl.getTagDefinitionUrl(temp), false) } R.id.add_filter -> { showFilterTagDialog(tag) } R.id.copy -> { requireActivity().addTextToClipboard(tag, false) } R.id.copy_trans -> { var transText = tv.text.toString().trim() if (transText.endsWith(TAG_STATUS_UP) || transText.endsWith(TAG_STATUS_DN)) { transText = transText.substring(0, transText.length - 1) } requireActivity().addTextToClipboard(transText, false) } } }.show() } private fun voteTag(tag: String, vote: Int) { val context = context val activity = mainActivity if (null == context || null == activity) { return } val request = EhRequest() .setMethod(EhClient.METHOD_VOTE_TAG) .setArgs( mGalleryDetail!!.apiUid, mGalleryDetail!!.apiKey!!, mGalleryDetail!!.gid, mGalleryDetail!!.token!!, tag, vote, ) .setCallback(VoteTagListener(context)) request.enqueue(this) } override fun onLongClick(v: View): Boolean { val activity = mainActivity ?: return false if (mUploader === v) { if (uploader.isNullOrEmpty() || disowned) { return false } showFilterUploaderDialog() } else if (mDownload === v) { val galleryInfo = galleryInfo if (galleryInfo != null) { // CommonOperations Actions CommonOperations.startDownload(activity, galleryInfo, true) } return true } else if (mHeartGroup == v) { // DB Actions // CommonOperations Actions if (mGalleryDetail != null && !mModifyingFavorites) { mModifyingFavorites = true if (EhDB.containLocalFavorites(mGalleryDetail!!.gid)) { CommonOperations.removeFromFavorites( activity, mGalleryDetail!!, ModifyFavoritesListener(activity, true), true, ) } else { CommonOperations.addToFavorites( activity, mGalleryDetail!!, ModifyFavoritesListener(activity, false), true, ) } // Update UI updateFavoriteDrawable() } } else { val tag = v.getTag(R.id.tag) as? String if (null != tag) { showTagDialog(v as TextView, tag) return true } } return false } override fun onBackPressed() { if (mViewTransition != null && mThumb != null && mViewTransition!!.shownViewIndex == 0 && mThumb!!.isShown ) { val location = IntArray(2) mThumb!!.getLocationInWindow(location) // Only show transaction when thumb can be seen if (location[1] + mThumb!!.height > 0) { setTransitionName() finish(ExitTransaction(mThumb!!)) return } } finish() } override fun onSceneResult(requestCode: Int, resultCode: Int, data: Bundle?) { if (requestCode == REQUEST_CODE_COMMENT_GALLERY) { if (resultCode != RESULT_OK || data == null) { return } val comments = data.getParcelableCompat(GalleryCommentsScene.KEY_COMMENT_LIST) if (mGalleryDetail == null && comments == null) { return } mGalleryDetail!!.comments = comments bindComments(comments!!.comments) } else { super.onSceneResult(requestCode, resultCode, data) } } private fun updateDownloadText() { mDownload?.run { when (mDownloadState) { DownloadInfo.STATE_INVALID -> setText(R.string.download) DownloadInfo.STATE_NONE -> setText(R.string.download_state_none) DownloadInfo.STATE_WAIT -> setText(R.string.download_state_wait) DownloadInfo.STATE_DOWNLOAD -> setText(R.string.download_state_downloading) DownloadInfo.STATE_FINISH -> setText( if (mGalleryDetail != null && mGalleryDetail!!.newerVersions.isNotEmpty()) { R.string.download_upgradeable } else { R.string.download_state_downloaded }, ) DownloadInfo.STATE_FAILED -> setText(R.string.download_state_failed) } } } private fun updateDownloadState() { val context = context val gid = gid if (null == context || -1L == gid) { return } val downloadState = EhDownloadManager.getDownloadState(gid) if (downloadState == mDownloadState) { return } mDownloadState = downloadState updateDownloadText() } override fun onAdd(info: DownloadInfo, list: List, position: Int) { updateDownloadState() } override fun onUpdate(info: DownloadInfo, list: List) { updateDownloadState() } override fun onUpdateAll() { updateDownloadState() } override fun onReload() { updateDownloadState() } override fun onChange() { updateDownloadState() } override fun onRemove(info: DownloadInfo, list: List, position: Int) { updateDownloadState() } override fun onRenameLabel(from: String, to: String) {} override fun onUpdateLabels() {} private fun onGetGalleryDetailSuccess(result: GalleryDetail) { mGalleryDetail = result updateDownloadState() adjustViewVisibility(STATE_NORMAL, true) bindViewSecond() } private fun onGetGalleryDetailFailure(e: Exception) { e.printStackTrace() if (null != mTip) { val error = ExceptionUtils.getReadableString(e) mTip!!.text = error adjustViewVisibility(STATE_FAILED, true) } } private fun onRateGallerySuccess(result: RateGalleryParser.Result) { if (mGalleryDetail != null) { mGalleryDetail!!.rating = result.rating mGalleryDetail!!.ratingCount = result.ratingCount } // Update UI if (mRatingText != null && mRating != null) { mRatingText!!.text = getAllRatingText(result.rating, result.ratingCount) mRating!!.rating = result.rating } } private fun onModifyFavoritesSuccess(addOrRemove: Boolean) { mModifyingFavorites = false if (mGalleryDetail != null) { mGalleryDetail!!.isFavorited = !addOrRemove && mGalleryDetail!!.favoriteName != null updateFavoriteDrawable() } } private fun onModifyFavoritesFailure() { mModifyingFavorites = false } private fun onModifyFavoritesCancel() { mModifyingFavorites = false } override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) val url = galleryDetailUrl if (url != null) { outContent.webUri = url.toUri() } } @IntDef(STATE_INIT, STATE_NORMAL, STATE_REFRESH, STATE_REFRESH_HEADER, STATE_FAILED) @Retention(AnnotationRetention.SOURCE) private annotation class State private class ExitTransaction( private val mThumb: View, ) : TransitionHelper { override fun onTransition( context: Context, transaction: FragmentTransaction, exit: Fragment, enter: Fragment, ): Boolean { if (enter !is GalleryListScene && enter !is DownloadsScene && enter !is FavoritesScene && enter !is HistoryScene ) { return false } ViewCompat.getTransitionName(mThumb)?.let { exit.sharedElementReturnTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_move) exit.exitTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_fade) enter.sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_move) enter.enterTransition = TransitionInflater.from(context).inflateTransition(R.transition.trans_fade) transaction.addSharedElement(mThumb, it) } return true } } private inner class VoteTagListener(context: Context) : EhCallback?>>(context) { override fun onSuccess(result: Pair?>) { if (result.first.isNotEmpty()) { showTip(result.first, LENGTH_SHORT) } else { mGalleryDetail?.tags = result.second bindTags(result.second) showTip(R.string.tag_vote_successfully, LENGTH_SHORT) } } override fun onFailure(e: Exception) { showTip(R.string.vote_failed, LENGTH_LONG) } override fun onCancel() {} } private class DownloadArchiveListener( context: Context, private val info: GalleryInfo, ) : EhCallback(context) { override fun onSuccess(result: String?) { result?.let { val uri = it.toUri() val intent = Intent().apply { action = Intent.ACTION_VIEW setDataAndType(uri, "application/zip") } val name = "${info.gid}-${EhUtils.getSuitableTitle(info)}.zip" try { try { application.topActivity!!.startActivity(intent) application.topActivity!!.addTextToClipboard(name, false) } catch (_: ActivityNotFoundException) { val r = DownloadManager.Request(uri) r.setDestinationInExternalPublicDir( Environment.DIRECTORY_DOWNLOADS, FileUtils.sanitizeFilename(name), ) r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) application.getSystemService()!!.enqueue(r) } } catch (e: Throwable) { e.printStackTrace() ExceptionUtils.throwIfFatal(e) } } showTip(R.string.download_archive_started, LENGTH_SHORT) } override fun onFailure(e: Exception) { if (e is EhException) { showTip(ExceptionUtils.getReadableString(e), LENGTH_LONG) } else { showTip(R.string.download_archive_failure, LENGTH_LONG) e.printStackTrace() } } override fun onCancel() {} } private inner class DeleteDialogHelper( private val mGalleryInfo: GalleryInfo, private val mBuilder: CheckBoxDialogBuilder, ) : DialogInterface.OnClickListener { override fun onClick(dialog: DialogInterface, which: Int) { if (which != DialogInterface.BUTTON_POSITIVE) { return } // Delete // DownloadManager Actions EhDownloadManager.deleteDownload(mGalleryInfo.gid) // Delete image files val checked = mBuilder.isChecked Settings.putRemoveImageFiles(checked) if (checked) { val file = SpiderDen.getGalleryDownloadDir(mGalleryInfo.gid) // DB Actions EhDownloadManager.removeDownloadDirname(mGalleryInfo.gid) // Other Actions lifecycleScope.launchIO { runCatching { file?.delete() } } } } } private inner class GetGalleryDetailListener(context: Context) : EhCallback(context) { override fun onSuccess(result: GalleryDetail) { application.removeGlobalStuff(this) // Put gallery detail to cache galleryDetailCache.put(result.gid, result) // Add history // DB Actions EhDB.putHistoryInfo(result) // Notify success val scene = this@GalleryDetailScene scene.onGetGalleryDetailSuccess(result) } override fun onFailure(e: Exception) { application.removeGlobalStuff(this) val scene = this@GalleryDetailScene scene.onGetGalleryDetailFailure(e) } override fun onCancel() { application.removeGlobalStuff(this) } } private inner class RateGalleryListener( context: Context, ) : EhCallback(context) { override fun onSuccess(result: RateGalleryParser.Result) { showTip(R.string.rate_successfully, LENGTH_SHORT) val scene = this@GalleryDetailScene scene.onRateGallerySuccess(result) } override fun onFailure(e: Exception) { e.printStackTrace() showTip(R.string.rate_failed, LENGTH_LONG) } override fun onCancel() {} } private inner class ModifyFavoritesListener( context: Context, private val mAddOrRemove: Boolean, ) : EhCallback(context) { override fun onSuccess(result: Unit) { showTip( if (mAddOrRemove) R.string.remove_from_favorite_success else R.string.add_to_favorite_success, LENGTH_SHORT, ) val scene = this@GalleryDetailScene scene.onModifyFavoritesSuccess(mAddOrRemove) } override fun onFailure(e: Exception) { showTip( if (mAddOrRemove) R.string.remove_from_favorite_failure else R.string.add_to_favorite_failure, LENGTH_LONG, ) val scene = this@GalleryDetailScene scene.onModifyFavoritesFailure() } override fun onCancel() { val scene = this@GalleryDetailScene scene.onModifyFavoritesCancel() } } private inner class ArchiveListDialogHelper : AdapterView.OnItemClickListener, DialogInterface.OnDismissListener, EhClient.Callback { private var mProgressView: CircularProgressIndicator? = null private var mErrorText: TextView? = null private var mListView: ListView? = null private var mRequest: EhRequest? = null private var mDialog: Dialog? = null fun setDialog(dialog: Dialog?, url: String?) { mDialog = dialog mProgressView = ViewUtils.`$$`(dialog, R.id.progress) as CircularProgressIndicator mErrorText = ViewUtils.`$$`(dialog, R.id.text) as TextView mListView = ViewUtils.`$$`(dialog, R.id.list_view) as ListView mListView!!.onItemClickListener = this val context = context if (context != null) { if (mArchiveList == null) { mErrorText!!.visibility = View.GONE mListView!!.visibility = View.GONE mRequest = EhRequest().setMethod(EhClient.METHOD_ARCHIVE_LIST) .setArgs(url!!, mGid, mToken) .setCallback(this) mRequest!!.enqueue(this@GalleryDetailScene) } else { bind(mArchiveList, mCurrentFunds) } } } private fun bind(data: List?, funds: HomeParser.Funds?) { if (null == mDialog || null == mProgressView || null == mErrorText || null == mListView) { return } if (data.isNullOrEmpty()) { mProgressView!!.visibility = View.GONE mErrorText!!.visibility = View.VISIBLE mListView!!.visibility = View.GONE mErrorText!!.setText(R.string.no_archives) } else { val nameArray = data.map { it.run { if (isHAtH) { val costStr = if (cost == "Free") resources.getString(R.string.archive_free) else cost "[H@H] $name [$size] [$costStr]" } else { val nameStr = resources.getString(if (res == "org") R.string.archive_original else R.string.archive_resample) val costStr = if (cost == "Free!") resources.getString(R.string.archive_free) else cost "$nameStr [$size] [$costStr]" } } }.toTypedArray() mProgressView!!.visibility = View.GONE mErrorText!!.visibility = View.GONE mListView!!.visibility = View.VISIBLE mListView!!.adapter = ArrayAdapter(mDialog!!.context, R.layout.item_select_dialog, nameArray) if (funds != null) { var fundsGP = funds.fundsGP.toString() // Ex GP numbers are rounded down to the nearest thousand if (EhUtils.isExHentai) { fundsGP += "+" } mDialog!!.setTitle(getString(R.string.current_funds, fundsGP, funds.fundsC)) } } } override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { val context = context val activity = mainActivity if (null != context && null != activity && null != mArchiveList && position < mArchiveList!!.size) { val res = mArchiveList!![position].res val isHAtH = mArchiveList!![position].isHAtH val request = EhRequest() request.setMethod(EhClient.METHOD_DOWNLOAD_ARCHIVE) request.setArgs( mGalleryDetail!!.gid, mGalleryDetail!!.token!!, res, isHAtH, ) request.setCallback(DownloadArchiveListener(context, mGalleryDetail!!)) request.enqueue(this@GalleryDetailScene) } if (mDialog != null) { mDialog!!.dismiss() mDialog = null } } override fun onDismiss(dialog: DialogInterface) { if (mRequest != null) { mRequest!!.cancel() mRequest = null } mDialog = null mProgressView = null mErrorText = null mListView = null } override fun onSuccess(result: ArchiveParser.Result) { if (mRequest != null) { mRequest = null mArchiveList = result.archiveList mCurrentFunds = result.funds bind(result.archiveList, result.funds) } } override fun onFailure(e: Exception) { mRequest = null val context = context if (null != context && null != mProgressView && null != mErrorText && null != mListView) { mProgressView!!.visibility = View.GONE mErrorText!!.visibility = View.VISIBLE mListView!!.visibility = View.GONE mErrorText!!.text = ExceptionUtils.getReadableString(e) } } override fun onCancel() { mRequest = null } } private inner class TorrentListDialogHelper : AdapterView.OnItemClickListener, DialogInterface.OnDismissListener, EhClient.Callback> { private var mProgressView: CircularProgressIndicator? = null private var mErrorText: TextView? = null private var mListView: ListView? = null private var mRequest: EhRequest? = null private var mDialog: Dialog? = null fun setDialog(dialog: Dialog?, url: String?) { mDialog = dialog mProgressView = ViewUtils.`$$`(dialog, R.id.progress) as CircularProgressIndicator mErrorText = ViewUtils.`$$`(dialog, R.id.text) as TextView mListView = ViewUtils.`$$`(dialog, R.id.list_view) as ListView mListView!!.onItemClickListener = this val context = context if (context != null) { if (mTorrentList == null) { mErrorText!!.visibility = View.GONE mListView!!.visibility = View.GONE mRequest = EhRequest().setMethod(EhClient.METHOD_GET_TORRENT_LIST) .setArgs(url!!, mGid, mToken) .setCallback(this) mRequest!!.enqueue(this@GalleryDetailScene) } else { bind(mTorrentList) } } } private fun bind(data: List?) { if (null == mDialog || null == mProgressView || null == mErrorText || null == mListView) { return } if (data.isNullOrEmpty()) { mProgressView!!.visibility = View.GONE mErrorText!!.visibility = View.VISIBLE mListView!!.visibility = View.GONE mErrorText!!.setText(R.string.no_torrents) } else { val nameArray = data.map { it.format() }.toTypedArray() mProgressView!!.visibility = View.GONE mErrorText!!.visibility = View.GONE mListView!!.visibility = View.VISIBLE mListView!!.adapter = ArrayAdapter(mDialog!!.context, R.layout.item_select_dialog, nameArray) } } override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { val context = context if (null != context && null != mTorrentList && position < mTorrentList!!.size) { val url = mTorrentList!![position].url val name = mTorrentList!![position].name // TODO: Don't use buggy system download service val r = DownloadManager.Request(url.replace("exhentai.org/torrent", "ehtracker.org/get").toUri()) r.setDestinationInExternalPublicDir( Environment.DIRECTORY_DOWNLOADS, FileUtils.sanitizeFilename("$name.torrent"), ) r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) r.addRequestHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl())) val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager try { dm.enqueue(r) showTip(R.string.download_torrent_started, LENGTH_SHORT) } catch (e: Throwable) { e.printStackTrace() ExceptionUtils.throwIfFatal(e) showTip(R.string.download_torrent_failure, LENGTH_SHORT) } } if (mDialog != null) { mDialog!!.dismiss() mDialog = null } } override fun onDismiss(dialog: DialogInterface) { if (mRequest != null) { mRequest!!.cancel() mRequest = null } mDialog = null mProgressView = null mErrorText = null mListView = null } override fun onSuccess(result: List) { if (mRequest != null) { mRequest = null mTorrentList = result bind(result) } } override fun onFailure(e: Exception) { mRequest = null val context = context if (null != context && null != mProgressView && null != mErrorText && null != mListView) { mProgressView!!.visibility = View.GONE mErrorText!!.visibility = View.VISIBLE mListView!!.visibility = View.GONE mErrorText!!.text = ExceptionUtils.getReadableString(e) } } override fun onCancel() { mRequest = null } } private inner class RateDialogHelper : OnUserRateListener, DialogInterface.OnClickListener { private var mRatingBar: GalleryRatingBar? = null private var mRatingText: TextView? = null fun setDialog(dialog: Dialog?, rating: Float) { mRatingText = ViewUtils.`$$`(dialog, R.id.rating_text) as TextView mRatingBar = ViewUtils.`$$`(dialog, R.id.rating_view) as GalleryRatingBar mRatingText!!.setText(getRatingText(rating)) mRatingBar!!.rating = rating mRatingBar!!.setOnUserRateListener(this) } override fun onUserRate(rating: Float) { if (null != mRatingText) { mRatingText!!.setText(getRatingText(rating)) } } override fun onClick(dialog: DialogInterface, which: Int) { val context = context val activity = mainActivity if (null == context || null == activity || which != DialogInterface.BUTTON_POSITIVE || null == mGalleryDetail || null == mRatingBar) { return } val request = EhRequest() .setMethod(EhClient.METHOD_GET_RATE_GALLERY) .setArgs( mGalleryDetail!!.apiUid, mGalleryDetail!!.apiKey!!, mGalleryDetail!!.gid, mGalleryDetail!!.token!!, mRatingBar!!.rating, ) .setCallback( RateGalleryListener(context), ) request.enqueue(this@GalleryDetailScene) } } companion object { const val KEY_ACTION = "action" const val ACTION_GALLERY_INFO = "action_gallery_info" const val ACTION_GID_TOKEN = "action_gid_token" const val KEY_GALLERY_INFO = "gallery_info" const val KEY_GID = "gid" const val KEY_TOKEN = "token" const val KEY_PAGE = "page" private const val REQUEST_CODE_COMMENT_GALLERY = 0 private const val STATE_INIT = -1 private const val STATE_NORMAL = 0 private const val STATE_REFRESH = 1 private const val STATE_REFRESH_HEADER = 2 private const val STATE_FAILED = 3 private const val TAG_STATUS_UP = "↑" private const val TAG_STATUS_DN = "↓" private const val KEY_GALLERY_DETAIL = "gallery_detail" private const val KEY_REQUEST_ID = "request_id" private const val TRANSITION_ANIMATION_DISABLED = true private fun getArtist(tagGroups: Array?): String? { if (null == tagGroups) { return null } for (tagGroup in tagGroups) { if ("artist" == tagGroup.groupName && tagGroup.isNotEmpty()) { var tagStr = tagGroup[0] while (tagStr.startsWith("_")) { tagStr = tagStr.substring(2) } return tagStr } } return null } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryHolder.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.google.android.material.card.MaterialCardView import com.hippo.ehviewer.R import com.hippo.ehviewer.widget.SimpleRatingView import com.hippo.widget.LoadImageView internal class GalleryHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val thumb: LoadImageView = itemView.findViewById(R.id.thumb) val title: TextView = itemView.findViewById(R.id.title) val uploader: TextView? = itemView.findViewById(R.id.uploader) val note: TextView? = itemView.findViewById(R.id.note) val rating: SimpleRatingView = itemView.findViewById(R.id.rating) val category: TextView = itemView.findViewById(R.id.category) val posted: TextView? = itemView.findViewById(R.id.posted) val pages: TextView = itemView.findViewById(R.id.pages) val simpleLanguage: TextView = itemView.findViewById(R.id.simple_language) val favourited: ImageView? = itemView.findViewById(R.id.favourited) val downloaded: ImageView? = itemView.findViewById(R.id.downloaded) val card: MaterialCardView = itemView.findViewById(R.id.card) } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryInfoScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.LinearDividerItemDecoration import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.UrlOpener import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.thumbUrl import com.hippo.util.addTextToClipboard import com.hippo.util.getParcelableCompat import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.ViewUtils import rikka.core.res.resolveColor class GalleryInfoScene : ToolbarScene() { private var mKeys: ArrayList = arrayListOf() private var mValues: ArrayList = arrayListOf() private var mRecyclerView: RecyclerView? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } private fun handlerArgs(args: Bundle?) { args?.getParcelableCompat(KEY_GALLERY_DETAIL)?.let { mKeys.add(getString(R.string.header_key)) mValues.add(getString(R.string.header_value)) mKeys.add(getString(R.string.key_gid)) mValues.add(it.gid.toString()) mKeys.add(getString(R.string.key_token)) mValues.add(it.token) mKeys.add(getString(R.string.key_url)) mValues.add(EhUrl.getGalleryDetailUrl(it.gid, it.token)) mKeys.add(getString(R.string.key_title)) mValues.add(it.title) mKeys.add(getString(R.string.key_title_jpn)) mValues.add(it.titleJpn) mKeys.add(getString(R.string.key_thumb)) mValues.add(it.thumbUrl!!) mKeys.add(getString(R.string.key_category)) mValues.add(EhUtils.getCategory(it.category)) mKeys.add(getString(R.string.key_uploader)) mValues.add(it.uploader) mKeys.add(getString(R.string.key_posted)) mValues.add(it.posted) mKeys.add(getString(R.string.key_parent)) mValues.add(it.parent) mKeys.add(getString(R.string.key_visible)) mValues.add(it.visible) mKeys.add(getString(R.string.key_language)) mValues.add(it.language) mKeys.add(getString(R.string.key_pages)) mValues.add(it.pages.toString()) mKeys.add(getString(R.string.key_size)) mValues.add(it.size) mKeys.add(getString(R.string.key_favorite_count)) mValues.add(it.favoriteCount.toString()) mKeys.add(getString(R.string.key_favorited)) mValues.add(java.lang.Boolean.toString(it.isFavorited)) mKeys.add(getString(R.string.key_favorite_name)) mValues.add(it.favoriteName) mKeys.add(getString(R.string.key_rating_count)) mValues.add(it.ratingCount.toString()) mKeys.add(getString(R.string.key_rating)) mValues.add(it.rating.toString()) mKeys.add(getString(R.string.key_torrents)) mValues.add(it.torrentCount.toString()) mKeys.add(getString(R.string.key_torrent_url)) mValues.add(it.torrentUrl) } } private fun onInit() { handlerArgs(arguments) } private fun onRestore(savedInstanceState: Bundle) { mKeys = savedInstanceState.getStringArrayList(KEY_KEYS) as ArrayList mValues = savedInstanceState.getStringArrayList(KEY_VALUES) as ArrayList } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putStringArrayList(KEY_KEYS, mKeys) outState.putStringArrayList(KEY_VALUES, mValues) } override fun onCreateViewWithToolbar( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_gallery_info, container, false) val context = requireContext() mRecyclerView = ViewUtils.`$$`(view, R.id.recycler_view) as EasyRecyclerView val adapter = InfoAdapter() mRecyclerView!!.adapter = adapter mRecyclerView!!.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(context, 1f), ) val keylineMargin = context.resources.getDimensionPixelOffset(R.dimen.keyline_margin) decoration.setPadding(keylineMargin) mRecyclerView!!.addItemDecoration(decoration) mRecyclerView!!.clipToPadding = false mRecyclerView!!.setHasFixedSize(true) return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setTitle(R.string.gallery_info) setNavigationIcon(R.drawable.v_arrow_left_dark_x24) } override fun onDestroyView() { super.onDestroyView() if (null != mRecyclerView) { mRecyclerView!!.stopScroll() mRecyclerView = null } } fun onItemClick(position: Int): Boolean { val context = context return if (null != context && 0 != position) { if (position == INDEX_PARENT) { UrlOpener.openUrl(context, mValues[position], true) } else { requireActivity().addTextToClipboard(mValues[position], false) if (position == INDEX_URL) { // Save it to avoid detect the gallery Settings.putClipboardTextHashCode(mValues[position].hashCode()) } } true } else { false } } override fun onNavigationClick() { onBackPressed() } private class InfoHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val key: TextView = itemView.findViewById(R.id.key) val value: TextView = itemView.findViewById(R.id.value) } private inner class InfoAdapter : RecyclerView.Adapter() { private val mInflater: LayoutInflater = layoutInflater override fun getItemViewType(position: Int): Int = if (position == 0) { TYPE_HEADER } else { TYPE_DATA } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InfoHolder = InfoHolder( mInflater.inflate( if (viewType == TYPE_HEADER) R.layout.item_gallery_info_header else R.layout.item_gallery_info_data, parent, false, ), ) override fun onBindViewHolder(holder: InfoHolder, position: Int) { holder.key.text = mKeys[position] holder.value.text = mValues[position] holder.itemView.isEnabled = position != 0 holder.itemView.setOnClickListener { onItemClick(position) } } override fun getItemCount(): Int = mKeys.size.coerceAtMost(mValues.size) } companion object { const val KEY_GALLERY_DETAIL = "gallery_detail" const val KEY_KEYS = "keys" const val KEY_VALUES = "values" private const val INDEX_URL = 3 private const val INDEX_PARENT = 10 private const val TYPE_HEADER = 0 private const val TYPE_DATA = 1 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryListScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.animation.Animator import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.res.Resources import android.net.Uri import android.os.Bundle import android.text.InputType import android.text.Spannable import android.text.SpannableStringBuilder import android.text.TextUtils import android.text.style.ImageSpan import android.view.LayoutInflater import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.view.ViewPropertyAnimator import android.widget.ImageView import android.widget.TextView import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly import androidx.annotation.IntDef import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.Toolbar import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.CalendarConstraints.DateValidator import com.google.android.material.datepicker.CompositeDateValidator import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.DateValidatorPointForward import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.floatingactionbutton.FloatingActionButton import com.hippo.app.EditTextCheckBoxDialogBuilder import com.hippo.app.EditTextDialogBuilder import com.hippo.drawable.AddDeleteDrawable import com.hippo.drawable.DrawerArrowDrawable import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.FastScroller.OnDragHandlerListener import com.hippo.easyrecyclerview.LinearDividerItemDecoration import com.hippo.ehviewer.EhApplication.Companion.favouriteStatusRouter import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.FavouriteStatusRouter import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.WindowInsetsAnimationHelper import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhRequest import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.data.ListUrlBuilder import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_SUBSCRIPTION import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TOPLIST import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_WHATS_HOT import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser import com.hippo.ehviewer.client.parser.GalleryListParser import com.hippo.ehviewer.client.parser.GalleryPageUrlParser import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.dao.QuickSearch import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.download.DownloadManager.DownloadInfoListener import com.hippo.ehviewer.ui.CommonOperations import com.hippo.ehviewer.ui.GalleryActivity import com.hippo.ehviewer.ui.dialog.SelectItemWithIconAdapter import com.hippo.ehviewer.widget.GalleryInfoContentHelper import com.hippo.ehviewer.widget.SearchBar import com.hippo.ehviewer.widget.SearchBar.OnStateChangeListener import com.hippo.ehviewer.widget.SearchBar.Suggestion import com.hippo.ehviewer.widget.SearchBar.SuggestionProvider import com.hippo.ehviewer.widget.SearchLayout import com.hippo.scene.Announcer import com.hippo.scene.SceneFragment import com.hippo.util.getParcelableCompat import com.hippo.util.launchIO import com.hippo.util.toEpochMillis import com.hippo.util.withUIContext import com.hippo.view.BringOutTransition import com.hippo.view.ViewTransition import com.hippo.widget.ContentLayout import com.hippo.widget.FabLayout import com.hippo.widget.FabLayout.OnClickFabListener import com.hippo.widget.FabLayout.OnExpandListener import com.hippo.widget.SearchBarMover import com.hippo.yorozuya.AnimationUtils import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.SimpleAnimatorListener import com.hippo.yorozuya.ViewUtils import kotlin.time.Clock import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.todayIn import rikka.core.res.resolveColor class GalleryListScene : BaseScene(), OnDragHandlerListener, OnStateChangeListener, SearchLayout.Helper, SearchBarMover.Helper, SearchBar.Helper, View.OnClickListener, OnClickFabListener, OnExpandListener { private val mDownloadManager = DownloadManager @SuppressLint("NotifyDataSetChanged") private val mDownloadInfoListener: DownloadInfoListener = object : DownloadInfoListener { override fun onAdd(info: DownloadInfo, list: List, position: Int) { mAdapter?.notifyDataSetChanged() } override fun onUpdate(info: DownloadInfo, list: List) {} override fun onUpdateAll() {} override fun onReload() { mAdapter?.notifyDataSetChanged() } override fun onChange() { mAdapter?.notifyDataSetChanged() } override fun onRenameLabel(from: String, to: String) {} override fun onRemove(info: DownloadInfo, list: List, position: Int) { mAdapter?.notifyDataSetChanged() } override fun onUpdateLabels() {} } private val mFavouriteStatusRouter: FavouriteStatusRouter = favouriteStatusRouter @SuppressLint("NotifyDataSetChanged") private val mFavouriteStatusRouterListener: FavouriteStatusRouter.Listener = FavouriteStatusRouter.Listener { _: Long, _: Int -> mAdapter?.notifyDataSetChanged() } private val mOnScrollListener: RecyclerView.OnScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {} override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (dy >= mHideActionFabSlop) { hideActionFab() } else if (dy <= -mHideActionFabSlop / 2) { showActionFab() } } } private val mActionFabAnimatorListener: Animator.AnimatorListener = object : SimpleAnimatorListener() { override fun onAnimationEnd(animation: Animator) { if (null != mFabLayout) { (mFabLayout!!.primaryFab as View?)!!.visibility = View.INVISIBLE } } } private val mSearchFabAnimatorListener: Animator.AnimatorListener = object : SimpleAnimatorListener() { override fun onAnimationEnd(animation: Animator) { if (null != mSearchFab) { mSearchFab!!.visibility = View.INVISIBLE } } } private val selectImageLauncher = registerForActivityResult( ActivityResultContracts.PickVisualMedia(), ) { result: Uri? -> mSearchLayout?.setImageUri(result) } private lateinit var mUrlBuilder: ListUrlBuilder private lateinit var mQuickSearchList: MutableList private var mRecyclerView: EasyRecyclerView? = null private var mAdapter: GalleryListAdapter? = null private var mHelper: GalleryListHelper? = null private var mViewTransition: ViewTransition? = null private var mSearchBar: SearchBar? = null private var mSearchBarMover: SearchBarMover? = null private var mSearchFab: View? = null private var mSearchLayout: SearchLayout? = null private var mLeftDrawable: DrawerArrowDrawable? = null private var mRightDrawable: AddDeleteDrawable? = null private var fabAnimator: ViewPropertyAnimator? = null private var mActionFabDrawable: AddDeleteDrawable? = null private var mFabLayout: FabLayout? = null private var mHideActionFabSlop = 0 private var mShowActionFab = true private var mDrawerViewTransition: ViewTransition? = null private var mItemTouchHelper: ItemTouchHelper? = null @State private var mState = STATE_NORMAL // Double click to exit private var mPressBackTime: Long = 0 private var mNavCheckedId = 0 private var mHasFirstRefresh = false private var mIsTopList = false override fun getNavCheckedItem(): Int = mNavCheckedId private fun handleArgs(args: Bundle?) { args ?: return mUrlBuilder = when (args.getString(KEY_ACTION)) { ACTION_HOMEPAGE -> ListUrlBuilder() ACTION_SUBSCRIPTION -> ListUrlBuilder(MODE_SUBSCRIPTION) ACTION_WHATS_HOT -> ListUrlBuilder(MODE_WHATS_HOT) ACTION_TOP_LIST -> ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.defaultTopList) ACTION_LIST_URL_BUILDER -> args.getParcelableCompat(KEY_LIST_URL_BUILDER) ?.copy() ?: ListUrlBuilder() else -> throw IllegalStateException("Wrong KEY_ACTION:${args.getString(KEY_ACTION)} when handle args!") } } override fun onNewArguments(args: Bundle) { handleArgs(args) onUpdateUrlBuilder() mHelper?.refresh() setState(STATE_NORMAL) mSearchBarMover?.showSearchBar() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mDownloadManager.addDownloadInfoListener(mDownloadInfoListener) mFavouriteStatusRouter.addListener(mFavouriteStatusRouterListener) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } override fun onResume() { super.onResume() mAdapter?.type = Settings.listMode } private fun onInit() { handleArgs(arguments) } private fun onRestore(savedInstanceState: Bundle) { mHasFirstRefresh = savedInstanceState.getBoolean(KEY_HAS_FIRST_REFRESH) mUrlBuilder = savedInstanceState.getParcelableCompat(KEY_LIST_URL_BUILDER)!! mState = savedInstanceState.getInt(KEY_STATE) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val hasFirstRefresh: Boolean = if (mHelper != null && 1 == mHelper!!.shownViewIndex) { false } else { mHasFirstRefresh } outState.putBoolean(KEY_HAS_FIRST_REFRESH, hasFirstRefresh) outState.putParcelable(KEY_LIST_URL_BUILDER, mUrlBuilder) outState.putInt(KEY_STATE, mState) } override fun onDestroy() { super.onDestroy() mDownloadManager.removeDownloadInfoListener(mDownloadInfoListener) mFavouriteStatusRouter.removeListener(mFavouriteStatusRouterListener) } private fun setSearchBarHint(searchBar: SearchBar) { searchBar.setEditTextHint(getString(if (EhUtils.isExHentai) R.string.gallery_list_search_bar_hint_exhentai else R.string.gallery_list_search_bar_hint_e_hentai)) } private fun setSearchBarSuggestionProvider(searchBar: SearchBar) { searchBar.setSuggestionProvider(object : SuggestionProvider { override fun providerSuggestions(text: String): List? { val result1 = GalleryDetailUrlParser.parse(text, false) if (result1 != null) { return listOf( GalleryDetailUrlSuggestion( result1.gid, result1.token, ), ) } val result2 = GalleryPageUrlParser.parse(text, false) if (result2 != null) { return listOf( GalleryPageUrlSuggestion( result2.gid, result2.pToken, result2.page, ), ) } return null } }) } private fun wrapTagKeyword(keyword: String): String = if (keyword.endsWith(':')) { keyword } else if (keyword.contains(" ")) { val tag = keyword.substringAfter(':') val prefix = keyword.dropLast(tag.length) "$prefix\"$tag$\"" } else { "$keyword$" } // Update search bar title, drawer checked item private fun onUpdateUrlBuilder() { val resources = resourcesOrNull if (resources == null || mSearchLayout == null || mFabLayout == null) { return } var keyword = mUrlBuilder.keyword val category = mUrlBuilder.category val mode = mUrlBuilder.mode val isPopular = mode == MODE_WHATS_HOT val isTopList = mode == MODE_TOPLIST if (isTopList != mIsTopList) { mIsTopList = isTopList recreateDrawerView() mFabLayout!!.getSecondaryFabAt(0)!!.setImageResource(if (isTopList) R.drawable.ic_baseline_format_list_numbered_24 else R.drawable.v_magnify_x24) } // Update fab visibility mFabLayout!!.setSecondaryFabVisibilityAt(1, !isPopular) mFabLayout!!.setSecondaryFabVisibilityAt(2, !isTopList && !isPopular) // Update normal search mode mSearchLayout!!.setNormalSearchMode(if (mode == MODE_SUBSCRIPTION) R.id.search_subscription_search else R.id.search_normal_search) // Update search edit text if (!TextUtils.isEmpty(keyword) && null != mSearchBar && !mIsTopList) { if (mode == ListUrlBuilder.MODE_TAG) { keyword = wrapTagKeyword(keyword!!) } mSearchBar!!.setText(keyword!!) mSearchBar!!.cursorToEnd() } // Update title val title = getSuitableTitleForUrlBuilder(resources, mUrlBuilder, true) ?: resources.getString(R.string.search) mSearchBar?.setTitle(title) // Update nav checked item val checkedItemId: Int = when (mode) { ListUrlBuilder.MODE_NORMAL -> if (EhUtils.NONE == category && TextUtils.isEmpty(keyword)) R.id.nav_homepage else 0 MODE_SUBSCRIPTION -> R.id.nav_subscription MODE_WHATS_HOT -> R.id.nav_whats_hot MODE_TOPLIST -> R.id.nav_toplist ListUrlBuilder.MODE_TAG, ListUrlBuilder.MODE_UPLOADER, ListUrlBuilder.MODE_IMAGE_SEARCH -> 0 else -> throw IllegalStateException("Unexpected value: $mode") } setNavCheckedItem(checkedItemId) mNavCheckedId = checkedItemId } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_gallery_list, container, false) val context = requireContext() mHideActionFabSlop = ViewConfiguration.get(context).scaledTouchSlop mShowActionFab = true val mainLayout = ViewUtils.`$$`(view, R.id.main_layout) val mContentLayout = ViewUtils.`$$`(mainLayout, R.id.content_layout) as ContentLayout mRecyclerView = mContentLayout.recyclerView val fastScroller = mContentLayout.fastScroller mSearchLayout = ViewUtils.`$$`(mainLayout, R.id.search_layout) as SearchLayout mSearchBar = ViewUtils.`$$`(mainLayout, R.id.search_bar) as SearchBar mFabLayout = ViewUtils.`$$`(mainLayout, R.id.fab_layout) as FabLayout mSearchFab = ViewUtils.`$$`(mainLayout, R.id.search_fab) ViewCompat.setWindowInsetsAnimationCallback( view, WindowInsetsAnimationHelper( WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP, mFabLayout, mSearchFab!!.parent as View, ), ) val paddingTopSB = resources.getDimensionPixelOffset(R.dimen.gallery_padding_top_search_bar) val paddingBottomFab = resources.getDimensionPixelOffset(R.dimen.gallery_padding_bottom_fab) mViewTransition = BringOutTransition(mContentLayout, mSearchLayout) mHelper = GalleryListHelper() mContentLayout.setHelper(mHelper!!) mContentLayout.fastScroller.setOnDragHandlerListener(this) mContentLayout.setFitPaddingTop(paddingTopSB) mAdapter = GalleryListAdapter( inflater, resources, mRecyclerView!!, Settings.listMode, ) mRecyclerView!!.clipToPadding = false mRecyclerView!!.clipChildren = false mRecyclerView!!.addOnScrollListener(mOnScrollListener) fastScroller.setPadding( fastScroller.paddingLeft, fastScroller.paddingTop + paddingTopSB, fastScroller.paddingRight, fastScroller.paddingBottom, ) mLeftDrawable = DrawerArrowDrawable(context, theme.resolveColor(android.R.attr.colorControlNormal)) mRightDrawable = AddDeleteDrawable(context, theme.resolveColor(android.R.attr.colorControlNormal)) mSearchBar!!.setLeftDrawable(mLeftDrawable!!) mSearchBar!!.setRightDrawable(mRightDrawable!!) mSearchBar!!.setHelper(this) mSearchBar!!.setOnStateChangeListener(this) setSearchBarHint(mSearchBar!!) setSearchBarSuggestionProvider(mSearchBar!!) mSearchLayout!!.setHelper(this) mSearchLayout!!.setPadding( mSearchLayout!!.paddingLeft, mSearchLayout!!.paddingTop + paddingTopSB, mSearchLayout!!.paddingRight, mSearchLayout!!.paddingBottom + paddingBottomFab, ) mFabLayout!!.setAutoCancel(true) mFabLayout!!.isExpanded = false mFabLayout!!.setHidePrimaryFab(false) mFabLayout!!.setOnClickFabListener(this) mFabLayout!!.setOnExpandListener(this) addAboveSnackView(mFabLayout!!) mActionFabDrawable = AddDeleteDrawable(context, context.getColor(R.color.primary_drawable_dark)) mFabLayout!!.primaryFab!!.setImageDrawable(mActionFabDrawable) mSearchFab!!.setOnClickListener(this) mSearchBarMover = SearchBarMover(this, mSearchBar, mRecyclerView, mSearchLayout) // Update list url builder onUpdateUrlBuilder() // Restore state val newState = mState mState = STATE_NORMAL setState(newState, false) // Only refresh for the first time if (!mHasFirstRefresh) { mHasFirstRefresh = true mHelper!!.firstRefresh() } return view } override fun onDestroyView() { super.onDestroyView() if (null != mSearchBarMover) { mSearchBarMover!!.cancelAnimation() mSearchBarMover = null } if (null != mHelper) { mHelper!!.destroy() if (1 == mHelper!!.shownViewIndex) { mHasFirstRefresh = false } } if (null != mRecyclerView) { mRecyclerView!!.stopScroll() mRecyclerView = null } if (null != mFabLayout) { removeAboveSnackView(mFabLayout!!) mFabLayout = null } mAdapter = null mSearchLayout = null mSearchBar = null mSearchFab = null mViewTransition = null mLeftDrawable = null mRightDrawable = null mActionFabDrawable = null } private fun updateDrawerView(animation: Boolean) { if (null == mDrawerViewTransition) { return } if (mIsTopList || mQuickSearchList.isNotEmpty()) { mDrawerViewTransition!!.showView(0, animation) } else { mDrawerViewTransition!!.showView(1, animation) } } private fun showQuickSearchTipDialog() { val context = context ?: return AlertDialog.Builder(context) .setTitle(R.string.readme) .setMessage(R.string.add_quick_search_tip) .setPositiveButton(android.R.string.ok, null) .show() } private fun showAddQuickSearchDialog(adapter: QsDrawerAdapter) { val context = context if (null == context || null == mHelper) { return } // Can't add image search as quick search if (ListUrlBuilder.MODE_IMAGE_SEARCH == mUrlBuilder.mode) { showTip(R.string.image_search_not_quick_search, LENGTH_LONG) return } // Get next gid val gi = mHelper!!.firstVisibleItem val next = if (gi != null) "@" + (gi.gid + 1) else null // Check duplicate for (q in mQuickSearchList) { if (mUrlBuilder.equalsQuickSearch(q)) { val i = q.name!!.lastIndexOf("@") if (i != -1 && q.name!!.substring(i) == next) { showTip(getString(R.string.duplicate_quick_search, q.name), LENGTH_LONG) return } } } val builder = EditTextCheckBoxDialogBuilder( context, getSuitableTitleForUrlBuilder(context.resources, mUrlBuilder, false), getString(R.string.quick_search), getString(R.string.save_progress), Settings.qSSaveProgress, ) builder.setTitle(R.string.add_quick_search_dialog_title) builder.setPositiveButton(android.R.string.ok, null) val dialog = builder.show() dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { lifecycleScope.launchIO { var text = builder.text.trim { it <= ' ' } // Check name empty if (TextUtils.isEmpty(text)) { withUIContext { builder.setError(getString(R.string.name_is_empty)) } return@launchIO } // Add gid val checked = builder.isChecked Settings.putQSSaveProgress(checked) if (checked && next != null) { text += next } // Check name duplicate for ((_, name) in mQuickSearchList) { if (text == name) { withUIContext { builder.setError(getString(R.string.duplicate_name)) } return@launchIO } } withUIContext { builder.setError(null) } dialog.dismiss() val quickSearch = mUrlBuilder.toQuickSearch() quickSearch.name = text mQuickSearchList.add(quickSearch) // DB Actions EhDB.insertQuickSearch(quickSearch) withUIContext { adapter.notifyItemInserted(mQuickSearchList.size - 1) updateDrawerView(true) } } } } override fun onCreateDrawerView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.drawer_list_rv, container, false) val toolbar = ViewUtils.`$$`(view, R.id.toolbar) as Toolbar val tip = ViewUtils.`$$`(view, R.id.tip) as TextView val recyclerView = view.findViewById(R.id.recycler_view_drawer) mDrawerViewTransition = ViewTransition(recyclerView, tip) recyclerView.layoutManager = LinearLayoutManager(requireContext()) val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(requireContext(), 1f), ) decoration.setShowLastDivider(true) recyclerView.addItemDecoration(decoration) val qsDrawerAdapter = QsDrawerAdapter(inflater) qsDrawerAdapter.setHasStableIds(true) mItemTouchHelper = ItemTouchHelper(GalleryListQSItemTouchHelperCallback(qsDrawerAdapter)) mItemTouchHelper!!.attachToRecyclerView(recyclerView) lifecycleScope.launchIO { // DB Actions mQuickSearchList = EhDB.allQuickSearch.toMutableList() withUIContext { recyclerView.adapter = qsDrawerAdapter updateDrawerView(false) } } tip.setText(R.string.quick_search_tip) toolbar.setTitle(if (mIsTopList) R.string.toplist else R.string.quick_search) if (!mIsTopList) toolbar.inflateMenu(R.menu.drawer_gallery_list) toolbar.setOnMenuItemClickListener { item: MenuItem -> when (item.itemId) { R.id.action_add -> showAddQuickSearchDialog(qsDrawerAdapter) R.id.action_help -> showQuickSearchTipDialog() } true } return view } private fun checkDoubleClickExit(): Boolean { if (stackIndex != 0) { return false } val time = System.currentTimeMillis() return if (time - mPressBackTime > BACK_PRESSED_INTERVAL) { // It is the last scene mPressBackTime = time showTip(R.string.press_twice_exit, LENGTH_SHORT) true } else { false } } override fun onBackPressed() { if (null != mFabLayout && mFabLayout!!.isExpanded) { mFabLayout!!.setExpanded(expanded = false, animation = true) return } var handle = false when (mState) { STATE_NORMAL -> handle = checkDoubleClickExit() STATE_SIMPLE_SEARCH, STATE_SEARCH -> { setState(STATE_NORMAL) handle = true } STATE_SEARCH_SHOW_LIST -> { setState(STATE_SEARCH) handle = true } } if (!handle) { finish() } } fun onItemClick(view: View, position: Int) { if (null == mHelper || null == mRecyclerView) { return } val gi = mHelper!!.getDataAtEx(position) ?: return val args = Bundle() args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GALLERY_INFO) args.putParcelable(GalleryDetailScene.KEY_GALLERY_INFO, gi) val announcer = Announcer(GalleryDetailScene::class.java).setArgs(args) view.findViewById(R.id.thumb)?.let { announcer.setTranHelper(EnterGalleryDetailTransaction(it)) } startScene(announcer) } override fun onClick(v: View) { if (STATE_NORMAL != mState && null != mSearchBar) { mSearchBar!!.applySearch() hideSoftInput() } } override fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton) { if (STATE_NORMAL == mState) { view.toggle() } } private fun showGoToDialog() { val context = context if (null == context || null == mHelper) { return } if (mIsTopList) { val page = mHelper!!.pageForTop + 1 val pages = mHelper!!.pages val hint = getString(R.string.go_to_hint, page, pages) val builder = EditTextDialogBuilder(context, null, hint) builder.editText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL val dialog = builder.setTitle(R.string.go_to) .setPositiveButton(android.R.string.ok, null) .show() dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { val text = builder.text.trim { it <= ' ' } val goTo: Int = try { text.toInt() - 1 } catch (_: NumberFormatException) { builder.setError(getString(R.string.error_invalid_number)) return@setOnClickListener } if (goTo < 0 || goTo >= pages) { builder.setError(getString(R.string.error_out_of_range)) return@setOnClickListener } builder.setError(null) mHelper!!.goTo(goTo) dialog.dismiss() } } else { val initial = LocalDate(2007, 3, 21) val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY) val initialMillis = initial.toEpochMillis() val yesterdayMillis = yesterday.toEpochMillis() val listValidators = ArrayList() listValidators.add(DateValidatorPointForward.from(initialMillis)) listValidators.add(DateValidatorPointBackward.before(yesterdayMillis)) val constraintsBuilder = CalendarConstraints.Builder() .setStart(initialMillis) .setEnd(yesterdayMillis) .setValidator(CompositeDateValidator.allOf(listValidators)) val datePicker = MaterialDatePicker.Builder.datePicker() .setCalendarConstraints(constraintsBuilder.build()) .setTitleText(R.string.go_to) .setSelection(yesterdayMillis) .build() datePicker.show(requireActivity().supportFragmentManager, "date-picker") datePicker.addOnPositiveButtonClickListener { v: Long? -> mHelper!!.goTo( v!!, true, ) } } } private fun showGidDialog() { val context = context if (null == context || null == mHelper) { return } val builder = EditTextDialogBuilder(context, null, getString(R.string.go_to_gid)) val dialog = builder.setTitle(R.string.go_to) .setPositiveButton(android.R.string.ok, null) .show() dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { var text = builder.text.trim { it <= ' ' } if (TextUtils.isEmpty(text)) text = "0" val goTo: Int = try { text.toInt() + 1 } catch (_: NumberFormatException) { builder.setError(getString(R.string.error_invalid_number)) return@setOnClickListener } if (goTo < 1) { builder.setError(getString(R.string.error_out_of_range)) return@setOnClickListener } builder.setError(null) mHelper!!.goTo(goTo.toString(), goTo != 1) dialog.dismiss() } } override fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int) { if (null == mHelper) { return } when (position) { // Open right 0 -> openDrawer(GravityCompat.END) // Go to 1 -> { if (!mIsTopList || mHelper!!.canGoTo()) showGoToDialog() } // Last page 2 -> showGidDialog() // Refresh 3 -> mHelper!!.refresh() } view.isExpanded = false } override fun onExpand(expanded: Boolean) { if (null == mActionFabDrawable) { return } if (expanded) { setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END) mActionFabDrawable!!.setDelete(ANIMATE_TIME) } else { setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) mActionFabDrawable!!.setAdd(ANIMATE_TIME) } } fun onItemLongClick(position: Int): Boolean { val context = context val activity = mainActivity if (null == context || null == activity || null == mHelper) { return false } val gi = mHelper!!.getDataAtEx(position) ?: return true val downloaded = mDownloadManager.getDownloadState(gi.gid) != DownloadInfo.STATE_INVALID val favourited = gi.favoriteSlot != -2 val items = if (downloaded) { arrayOf( context.getString(R.string.read), context.getString(R.string.delete_downloads), context.getString(if (favourited) R.string.remove_from_favourites else R.string.add_to_favourites), context.getString(R.string.download_move_dialog_title), ) } else { arrayOf( context.getString(R.string.read), context.getString(R.string.download), context.getString(if (favourited) R.string.remove_from_favourites else R.string.add_to_favourites), ) } val icons = if (downloaded) { intArrayOf( R.drawable.v_book_open_x24, R.drawable.v_delete_x24, if (favourited) R.drawable.v_heart_broken_x24 else R.drawable.v_heart_x24, R.drawable.v_folder_move_x24, ) } else { intArrayOf( R.drawable.v_book_open_x24, R.drawable.v_download_x24, if (favourited) R.drawable.v_heart_broken_x24 else R.drawable.v_heart_x24, ) } AlertDialog.Builder(context) .setTitle(EhUtils.getSuitableTitle(gi)) .setAdapter( SelectItemWithIconAdapter( context, items, icons, ), ) { _: DialogInterface?, which: Int -> when (which) { 0 -> { val intent = Intent(activity, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, gi) startActivity(intent) } 1 -> if (downloaded) { AlertDialog.Builder(context) .setTitle(R.string.download_remove_dialog_title) .setMessage( getString( R.string.download_remove_dialog_message, gi.title, ), ) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> // DownloadManager Actions mDownloadManager.deleteDownload( gi.gid, ) } .show() } else { // CommonOperations Actions CommonOperations.startDownload(activity, gi, false) } 2 -> if (favourited) { // CommonOperations Actions CommonOperations.removeFromFavorites( activity, gi, RemoveFromFavoriteListener(context), ) } else { // CommonOperations Actions CommonOperations.addToFavorites( activity, gi, AddToFavoriteListener(context), false, ) } 3 -> { val labelRawList = mDownloadManager.labelList val labelList: MutableList = ArrayList(labelRawList.size + 1) labelList.add(getString(R.string.default_download_label_name)) var i = 0 val n = labelRawList.size while (i < n) { labelRawList[i].label?.let { labelList.add(it) } i++ } val labels = labelList.toTypedArray() val helper = MoveDialogHelper(labels, gi) AlertDialog.Builder(context) .setTitle(R.string.download_move_dialog_title) .setItems(labels, helper) .show() } } }.show() return true } private fun showActionFab() { if (null != mFabLayout && STATE_NORMAL == mState && !mShowActionFab) { mShowActionFab = true val fab: View? = mFabLayout!!.primaryFab fabAnimator?.cancel() fab!!.visibility = View.VISIBLE fab.rotation = -45.0f fabAnimator = fab.animate().scaleX(1.0f).scaleY(1.0f).rotation(0.0f).setListener(null) .setDuration(ANIMATE_TIME).setStartDelay(0L) .setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR) fabAnimator!!.start() } } private fun hideActionFab() { if (null != mFabLayout && STATE_NORMAL == mState && mShowActionFab) { mShowActionFab = false val fab: View? = mFabLayout!!.primaryFab fabAnimator?.cancel() fabAnimator = fab!!.animate().scaleX(0.0f).scaleY(0.0f).setListener(mActionFabAnimatorListener) .setDuration(ANIMATE_TIME).setStartDelay(0L) .setInterpolator(AnimationUtils.SLOW_FAST_INTERPOLATOR) fabAnimator!!.start() } } private fun selectSearchFab(animation: Boolean) { if (null == mFabLayout || null == mSearchFab) { return } mShowActionFab = false if (animation) { val fab: View? = mFabLayout!!.primaryFab val delay: Long if (View.INVISIBLE == fab!!.visibility) { delay = 0L } else { delay = ANIMATE_TIME mFabLayout!!.setExpanded(expanded = false, animation = true) fab.animate().scaleX(0.0f).scaleY(0.0f).setListener(mActionFabAnimatorListener) .setDuration(ANIMATE_TIME).setStartDelay(0L) .setInterpolator(AnimationUtils.SLOW_FAST_INTERPOLATOR).start() } mSearchFab!!.visibility = View.VISIBLE mSearchFab!!.rotation = -45.0f mSearchFab!!.animate().scaleX(1.0f).scaleY(1.0f).rotation(0.0f).setListener(null) .setDuration(ANIMATE_TIME).setStartDelay(delay) .setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR).start() } else { mFabLayout!!.setExpanded(expanded = false, animation = false) val fab: View? = mFabLayout!!.primaryFab fab!!.visibility = View.INVISIBLE fab.scaleX = 0.0f fab.scaleY = 0.0f mSearchFab!!.visibility = View.VISIBLE mSearchFab!!.scaleX = 1.0f mSearchFab!!.scaleY = 1.0f } } private fun selectActionFab(animation: Boolean) { if (null == mFabLayout || null == mSearchFab) { return } mShowActionFab = true if (animation) { val delay: Long if (View.INVISIBLE == mSearchFab!!.visibility) { delay = 0L } else { delay = ANIMATE_TIME mSearchFab!!.animate().scaleX(0.0f).scaleY(0.0f) .setListener(mSearchFabAnimatorListener) .setDuration(ANIMATE_TIME).setStartDelay(0L) .setInterpolator(AnimationUtils.SLOW_FAST_INTERPOLATOR).start() } val fab: View? = mFabLayout!!.primaryFab fab!!.visibility = View.VISIBLE fab.rotation = -45.0f fab.animate().scaleX(1.0f).scaleY(1.0f).rotation(0.0f).setListener(null) .setDuration(ANIMATE_TIME).setStartDelay(delay) .setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR).start() } else { mFabLayout!!.setExpanded(expanded = false, animation = false) val fab: View? = mFabLayout!!.primaryFab fab!!.visibility = View.VISIBLE fab.scaleX = 1.0f fab.scaleY = 1.0f mSearchFab!!.visibility = View.INVISIBLE mSearchFab!!.scaleX = 0.0f mSearchFab!!.scaleY = 0.0f } } private fun setState(@State state: Int) { setState(state, true) } @SuppressLint("SwitchIntDef") private fun setState(@State state: Int, animation: Boolean) { if (null == mSearchBar || null == mSearchBarMover || null == mViewTransition || null == mSearchLayout) { return } if (mState != state) { val oldState = mState mState = state when (oldState) { STATE_NORMAL -> when (state) { STATE_SIMPLE_SEARCH -> { mSearchBar!!.setState(SearchBar.STATE_SEARCH_LIST, animation) mSearchBarMover!!.returnSearchBarPosition() selectSearchFab(animation) } STATE_SEARCH -> { mViewTransition!!.showView(1, animation) mSearchLayout!!.scrollSearchContainerToTop() mSearchBar!!.setState(SearchBar.STATE_SEARCH, animation) mSearchBarMover!!.returnSearchBarPosition() selectSearchFab(animation) } STATE_SEARCH_SHOW_LIST -> { mViewTransition!!.showView(1, animation) mSearchLayout!!.scrollSearchContainerToTop() mSearchBar!!.setState(SearchBar.STATE_SEARCH_LIST, animation) mSearchBarMover!!.returnSearchBarPosition() selectSearchFab(animation) } } STATE_SIMPLE_SEARCH -> when (state) { STATE_NORMAL -> { mSearchBar!!.setState(SearchBar.STATE_NORMAL, animation) mSearchBarMover!!.returnSearchBarPosition() selectActionFab(animation) } STATE_SEARCH -> { mViewTransition!!.showView(1, animation) mSearchLayout!!.scrollSearchContainerToTop() mSearchBar!!.setState(SearchBar.STATE_SEARCH, animation) mSearchBarMover!!.returnSearchBarPosition() } STATE_SEARCH_SHOW_LIST -> { mViewTransition!!.showView(1, animation) mSearchLayout!!.scrollSearchContainerToTop() mSearchBar!!.setState(SearchBar.STATE_SEARCH_LIST, animation) mSearchBarMover!!.returnSearchBarPosition() } } STATE_SEARCH -> when (state) { STATE_NORMAL -> { mViewTransition!!.showView(0, animation) mSearchBar!!.setState(SearchBar.STATE_NORMAL, animation) mSearchBarMover!!.returnSearchBarPosition() selectActionFab(animation) } STATE_SIMPLE_SEARCH -> { mViewTransition!!.showView(0, animation) mSearchBar!!.setState(SearchBar.STATE_SEARCH_LIST, animation) mSearchBarMover!!.returnSearchBarPosition() } STATE_SEARCH_SHOW_LIST -> { mSearchBar!!.setState(SearchBar.STATE_SEARCH_LIST, animation) mSearchBarMover!!.returnSearchBarPosition() } } STATE_SEARCH_SHOW_LIST -> when (state) { STATE_NORMAL -> { mViewTransition!!.showView(0, animation) mSearchBar!!.setState(SearchBar.STATE_NORMAL, animation) mSearchBarMover!!.returnSearchBarPosition() selectActionFab(animation) } STATE_SIMPLE_SEARCH -> { mViewTransition!!.showView(0, animation) mSearchBar!!.setState(SearchBar.STATE_SEARCH_LIST, animation) mSearchBarMover!!.returnSearchBarPosition() } STATE_SEARCH -> { mSearchBar!!.setState(SearchBar.STATE_SEARCH, animation) mSearchBarMover!!.returnSearchBarPosition() } } } } } override fun onClickTitle() { if (mState == STATE_NORMAL) { setState(STATE_SIMPLE_SEARCH) } } override fun onClickLeftIcon() { if (null == mSearchBar) { return } if (mSearchBar!!.getState() == SearchBar.STATE_NORMAL) { toggleDrawer(GravityCompat.START) } else { setState(STATE_NORMAL) } } override fun onClickRightIcon() { if (null == mSearchBar) { return } if (mSearchBar!!.getState() == SearchBar.STATE_NORMAL) { setState(STATE_SEARCH) } else { if (mSearchBar!!.getEditText().length() == 0) { setState(STATE_NORMAL) } else { // Clear mSearchBar!!.setText("") } } } override fun onSearchEditTextClick() { if (mState == STATE_SEARCH) { setState(STATE_SEARCH_SHOW_LIST) } } override fun onApplySearch(query: String) { if (null == mHelper || null == mSearchLayout) { return } if (mState == STATE_SEARCH || mState == STATE_SEARCH_SHOW_LIST) { try { mSearchLayout!!.formatListUrlBuilder(mUrlBuilder, query) } catch (e: EhException) { showTip(e.message, LENGTH_LONG) return } } else { val oldMode = mUrlBuilder.mode // If it's MODE_SUBSCRIPTION, keep it val newMode = if (oldMode == MODE_SUBSCRIPTION) MODE_SUBSCRIPTION else ListUrlBuilder.MODE_NORMAL mUrlBuilder.reset() mUrlBuilder.mode = newMode mUrlBuilder.keyword = query } onUpdateUrlBuilder() mHelper!!.refresh() setState(STATE_NORMAL) } override fun onSearchEditTextBackPressed() { onBackPressed() } override fun onReceiveContent(uri: Uri?) { if (null == mSearchLayout || null == uri) { return } mSearchLayout!!.setSearchMode(SearchLayout.SEARCH_MODE_IMAGE) mSearchLayout!!.setImageUri(uri) setState(STATE_SEARCH) } override fun onStartDragHandler() { // Lock right drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END) } override fun onEndDragHandler() { // Restore right drawer setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) if (null != mSearchBarMover) { mSearchBarMover!!.returnSearchBarPosition() } } override fun onStateChange(searchBar: SearchBar, newState: Int, oldState: Int, animation: Boolean) { if (null == mLeftDrawable || null == mRightDrawable) { return } when (oldState) { SearchBar.STATE_NORMAL -> { mLeftDrawable!!.setArrow(if (animation) ANIMATE_TIME else 0) mRightDrawable!!.setDelete(if (animation) ANIMATE_TIME else 0) } SearchBar.STATE_SEARCH -> if (newState == SearchBar.STATE_NORMAL) { mLeftDrawable!!.setMenu(if (animation) ANIMATE_TIME else 0) mRightDrawable!!.setAdd(if (animation) ANIMATE_TIME else 0) } SearchBar.STATE_SEARCH_LIST -> if (newState == SearchBar.STATE_NORMAL) { mLeftDrawable!!.setMenu(if (animation) ANIMATE_TIME else 0) mRightDrawable!!.setAdd(if (animation) ANIMATE_TIME else 0) } } if (newState == STATE_NORMAL || newState == STATE_SIMPLE_SEARCH) { setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.END) } else { setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START) setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.END) } } override fun onChangeSearchMode() { if (null != mSearchBarMover) { mSearchBarMover!!.showSearchBar() } } override fun onSelectImage() { val builder = PickVisualMediaRequest.Builder() builder.setMediaType(ImageOnly) selectImageLauncher.launch(builder.build()) } // SearchBarMover.Helper override fun isValidView(recyclerView: RecyclerView): Boolean = (mState == STATE_NORMAL && recyclerView == mRecyclerView) || (mState == STATE_SEARCH && recyclerView == mSearchLayout) // SearchBarMover.Helper override fun getValidRecyclerView(): RecyclerView? = if (mState == STATE_NORMAL || mState == STATE_SIMPLE_SEARCH) { mRecyclerView } else { mSearchLayout } // SearchBarMover.Helper override fun forceShowSearchBar(): Boolean = mState == STATE_SIMPLE_SEARCH || mState == STATE_SEARCH_SHOW_LIST private fun onGetGalleryListSuccess(result: GalleryListParser.Result, taskId: Int) { if (mHelper != null && mHelper!!.isCurrentTask(taskId)) { val emptyString = getString(if (mUrlBuilder.mode == MODE_SUBSCRIPTION && result.noWatchedTags) R.string.gallery_list_empty_hit_subscription else R.string.gallery_list_empty_hit) mHelper!!.setEmptyString(emptyString) if (mIsTopList) { mHelper!!.onGetPageData( taskId, result.pages, result.nextPage, null, null, result.galleryInfoList, ) } else { mHelper!!.onGetPageData( taskId, 0, 0, result.prev, result.next, result.galleryInfoList, ) } } } private fun onGetGalleryListFailure(e: Exception, taskId: Int) { if (mHelper != null && mHelper!!.isCurrentTask(taskId)) { mHelper!!.onGetException(taskId, e) } } @IntDef(STATE_NORMAL, STATE_SIMPLE_SEARCH, STATE_SEARCH, STATE_SEARCH_SHOW_LIST) @Retention(AnnotationRetention.SOURCE) private annotation class State private inner class GetGalleryListListener( context: Context, private val mTaskId: Int, ) : EhCallback(context) { override fun onSuccess(result: GalleryListParser.Result) { val scene = this@GalleryListScene scene.onGetGalleryListSuccess(result, mTaskId) } override fun onFailure(e: Exception) { val scene = this@GalleryListScene scene.onGetGalleryListFailure(e, mTaskId) } override fun onCancel() {} } private class AddToFavoriteListener(context: Context) : EhCallback(context) { override fun onSuccess(result: Unit) { showTip(R.string.add_to_favorite_success, LENGTH_SHORT) } override fun onFailure(e: Exception) { showTip(R.string.add_to_favorite_failure, LENGTH_LONG) } override fun onCancel() {} } private class RemoveFromFavoriteListener(context: Context) : EhCallback(context) { override fun onSuccess(result: Unit) { showTip(R.string.remove_from_favorite_success, LENGTH_SHORT) } override fun onFailure(e: Exception) { showTip(R.string.remove_from_favorite_failure, LENGTH_LONG) } override fun onCancel() {} } @SuppressLint("ClickableViewAccessibility") private inner class QsDrawerHolder( itemView: View, ) : RecyclerView.ViewHolder(itemView), View.OnTouchListener { val key: TextView = ViewUtils.`$$`(itemView, R.id.tv_key) as TextView val option: ImageView = ViewUtils.`$$`(itemView, R.id.iv_option) as ImageView init { option.setOnTouchListener(this) } override fun onTouch(v: View, event: MotionEvent): Boolean { if (mItemTouchHelper != null && event.action == MotionEvent.ACTION_DOWN) { mItemTouchHelper!!.startDrag(this) } return false } } private inner class MoveDialogHelper( private val mLabels: Array, private val mGi: GalleryInfo, ) : DialogInterface.OnClickListener { override fun onClick(dialog: DialogInterface, which: Int) { // Cancel check mode context ?: return mRecyclerView?.outOfCustomChoiceMode() val downloadInfo = mDownloadManager.getDownloadInfo(mGi.gid) ?: return val label = if (which == 0) null else mLabels[which] // DownloadManager Actions mDownloadManager.changeLabel(listOf(downloadInfo), label) } } private inner class QsDrawerAdapter(private val mInflater: LayoutInflater) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QsDrawerHolder { val holder = QsDrawerHolder(mInflater.inflate(R.layout.item_drawer_list, parent, false)) if (!mIsTopList) { holder.itemView.setOnClickListener { if (null == mHelper) { return@setOnClickListener } val quickSearch = mQuickSearchList[holder.bindingAdapterPosition] mUrlBuilder.set(quickSearch) onUpdateUrlBuilder() val i = quickSearch.name!!.lastIndexOf("@") mHelper!!.goTo(if (i != -1) quickSearch.name!!.substring(i + 1) else null, true) setState(STATE_NORMAL) closeDrawer(GravityCompat.END) } holder.itemView.setOnLongClickListener { val index = holder.bindingAdapterPosition val quickSearch = mQuickSearchList[index] val popupMenu = PopupMenu(requireContext(), holder.option) popupMenu.inflate(R.menu.quicksearch_option) popupMenu.show() popupMenu.setOnMenuItemClickListener( object : PopupMenu.OnMenuItemClickListener { override fun onMenuItemClick(item: MenuItem): Boolean { if (item.itemId == R.id.menu_qs_remove) { AlertDialog.Builder(requireContext()) .setTitle(R.string.delete_quick_search_title) .setMessage(getString(R.string.delete_quick_search_message, quickSearch.name)) .setPositiveButton(R.string.delete) { _: DialogInterface?, _: Int -> mQuickSearchList.removeAt(index) notifyItemRemoved(index) updateDrawerView(true) lifecycleScope.launchIO { // DB Actions EhDB.deleteQuickSearch(quickSearch) } } .setNegativeButton(android.R.string.cancel, null) .show() return true } return false } }, ) return@setOnLongClickListener true } } else { val keywords = intArrayOf(15, 13, 12, 11) holder.itemView.setOnClickListener { if (null == mHelper) { return@setOnClickListener } val keyword = keywords[holder.bindingAdapterPosition].toString() Settings.putDefaultTopList(keyword) mUrlBuilder.keyword = keyword onUpdateUrlBuilder() mHelper!!.refresh() setState(STATE_NORMAL) closeDrawer(GravityCompat.END) } } return holder } override fun onBindViewHolder(holder: QsDrawerHolder, position: Int) { if (!mIsTopList) { holder.key.text = mQuickSearchList[position].name } else { val toplists = intArrayOf( R.string.toplist_yesterday, R.string.toplist_pastmonth, R.string.toplist_pastyear, R.string.toplist_alltime, ) holder.key.text = getString(toplists[position]) holder.option.visibility = View.GONE } } override fun getItemId(position: Int): Long = if (mIsTopList) position.toLong() else mQuickSearchList[position].id!! override fun getItemCount(): Int = if (mIsTopList) 4 else mQuickSearchList.size } private abstract inner class UrlSuggestion : Suggestion() { override fun getText(textView: TextView): CharSequence? = if (textView.id == android.R.id.text1) { val bookImage = AppCompatResources.getDrawable(textView.context, R.drawable.v_book_open_x24) val ssb = SpannableStringBuilder(" ") ssb.append(getString(R.string.gallery_list_search_bar_open_gallery)) val imageSize = (textView.textSize * 1.25).toInt() if (bookImage != null) { bookImage.setBounds(0, 0, imageSize, imageSize) ssb.setSpan(ImageSpan(bookImage), 1, 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } ssb } else { null } override fun onClick() { startScene(createAnnouncer()) if (mState == STATE_SIMPLE_SEARCH) { setState(STATE_NORMAL) } else if (mState == STATE_SEARCH_SHOW_LIST) { setState(STATE_SEARCH) } } abstract fun createAnnouncer(): Announcer } private inner class GalleryDetailUrlSuggestion( private val mGid: Long, private val mToken: String, ) : UrlSuggestion() { override fun createAnnouncer(): Announcer { val args = Bundle() args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GID_TOKEN) args.putLong(GalleryDetailScene.KEY_GID, mGid) args.putString(GalleryDetailScene.KEY_TOKEN, mToken) return Announcer(GalleryDetailScene::class.java).setArgs(args) } } private inner class GalleryPageUrlSuggestion( private val mGid: Long, private val mPToken: String, private val mPage: Int, ) : UrlSuggestion() { override fun createAnnouncer(): Announcer { val args = Bundle() args.putString(ProgressScene.KEY_ACTION, ProgressScene.ACTION_GALLERY_TOKEN) args.putLong(ProgressScene.KEY_GID, mGid) args.putString(ProgressScene.KEY_PTOKEN, mPToken) args.putInt(ProgressScene.KEY_PAGE, mPage) return Announcer(ProgressScene::class.java).setArgs(args) } } private inner class GalleryListAdapter( inflater: LayoutInflater, resources: Resources, recyclerView: RecyclerView, type: Int, ) : GalleryAdapter(inflater, resources, recyclerView, type, true) { override fun getItemCount(): Int = mHelper?.size() ?: 0 override fun onItemClick(view: View, position: Int) { this@GalleryListScene.onItemClick(view, position) } override fun onItemLongClick(view: View, position: Int): Boolean = this@GalleryListScene.onItemLongClick(position) override fun getDataAt(position: Int): GalleryInfo? = mHelper?.getDataAtEx(position) } private inner class GalleryListHelper : GalleryInfoContentHelper() { override fun getPageData( taskId: Int, type: Int, page: Int, index: String?, isNext: Boolean, ) { val activity = mainActivity if (null == activity || null == mHelper) { return } if (mIsTopList) { mUrlBuilder.setJumpTo(page.toString()) } else { mUrlBuilder.setIndex(index, isNext) mUrlBuilder.setJumpTo(jumpTo) } val url = mUrlBuilder.build() val request = EhRequest() request.setMethod(EhClient.METHOD_GET_GALLERY_LIST) request.setCallback( GetGalleryListListener(context, taskId), ) request.setArgs(url) request.enqueue(this@GalleryListScene) } override val context get() = requireContext() @SuppressLint("NotifyDataSetChanged") override fun notifyDataSetChanged() { mAdapter?.notifyDataSetChanged() } override fun notifyItemRangeInserted(positionStart: Int, itemCount: Int) { mAdapter?.notifyItemRangeInserted(positionStart, itemCount) } override fun onShowView(hiddenView: View, shownView: View) { mSearchBarMover?.showSearchBar() showActionFab() } override fun isDuplicate(d1: GalleryInfo?, d2: GalleryInfo?): Boolean = d1?.gid == d2?.gid && d1 != null && d2 != null override fun onScrollToPosition(position: Int) { if (0 == position) { mSearchBarMover?.showSearchBar() showActionFab() } } } private inner class GalleryListQSItemTouchHelperCallback(private val mAdapter: QsDrawerAdapter) : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int = makeMovementFlags( ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0, ) override fun isLongPressDragEnabled(): Boolean = false override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean { val fromPosition = viewHolder.bindingAdapterPosition val toPosition = target.bindingAdapterPosition if (fromPosition == toPosition) { return false } val item = mQuickSearchList.removeAt(fromPosition) mQuickSearchList.add(toPosition, item) mAdapter.notifyItemMoved(fromPosition, toPosition) lifecycleScope.launchIO { // DB Actions EhDB.moveQuickSearch(fromPosition, toPosition) } return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} } companion object { const val KEY_ACTION = "action" const val ACTION_HOMEPAGE = "action_homepage" const val ACTION_SUBSCRIPTION = "action_subscription" const val ACTION_WHATS_HOT = "action_whats_hot" const val ACTION_TOP_LIST = "action_top_list" const val ACTION_LIST_URL_BUILDER = "action_list_url_builder" const val KEY_LIST_URL_BUILDER = "list_url_builder" const val KEY_HAS_FIRST_REFRESH = "has_first_refresh" const val KEY_STATE = "state" private const val BACK_PRESSED_INTERVAL = 2000 private const val STATE_NORMAL = 0 private const val STATE_SIMPLE_SEARCH = 1 private const val STATE_SEARCH = 2 private const val STATE_SEARCH_SHOW_LIST = 3 private const val ANIMATE_TIME = 300L private fun getSuitableTitleForUrlBuilder( resources: Resources, urlBuilder: ListUrlBuilder, appName: Boolean, ): String? { val keyword = urlBuilder.keyword val category = urlBuilder.category return if (ListUrlBuilder.MODE_NORMAL == urlBuilder.mode && EhUtils.NONE == category && TextUtils.isEmpty(keyword) && urlBuilder.advanceSearch == -1 && urlBuilder.minRating == -1 && urlBuilder.pageFrom == -1 && urlBuilder.pageTo == -1 ) { resources.getString(if (appName) R.string.app_name else R.string.homepage) } else if (MODE_SUBSCRIPTION == urlBuilder.mode && EhUtils.NONE == category && TextUtils.isEmpty(keyword) && urlBuilder.advanceSearch == -1 && urlBuilder.minRating == -1 && urlBuilder.pageFrom == -1 && urlBuilder.pageTo == -1 ) { resources.getString(R.string.subscription) } else if (MODE_WHATS_HOT == urlBuilder.mode) { resources.getString(R.string.whats_hot) } else if (MODE_TOPLIST == urlBuilder.mode) { when (urlBuilder.keyword) { "11" -> resources.getString(R.string.toplist_alltime) "12" -> resources.getString(R.string.toplist_pastyear) "13" -> resources.getString(R.string.toplist_pastmonth) "15" -> resources.getString(R.string.toplist_yesterday) else -> null } } else if (!TextUtils.isEmpty(keyword)) { keyword } else if (MathUtils.hammingWeight(category) == 1) { EhUtils.getCategory(category) } else { null } } fun startScene(scene: SceneFragment, lub: ListUrlBuilder?) { scene.startScene(getStartAnnouncer(lub)) } fun getStartAnnouncer(lub: ListUrlBuilder?): Announcer { val args = Bundle() args.putString(KEY_ACTION, ACTION_LIST_URL_BUILDER) args.putParcelable(KEY_LIST_URL_BUILDER, lub) return Announcer(GalleryListScene::class.java).setArgs(args) } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/GalleryPreviewsScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.annotation.SuppressLint import android.app.Dialog import android.content.Context import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.RecyclerView import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.MarginItemDecoration import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhRequest import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.data.GalleryDetail import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.data.GalleryPreview import com.hippo.ehviewer.client.data.PreviewSet import com.hippo.ehviewer.client.exception.EhException import com.hippo.ehviewer.ui.GalleryActivity import com.hippo.util.getParcelableCompat import com.hippo.widget.ContentLayout import com.hippo.widget.ContentLayout.ContentHelper import com.hippo.widget.LoadImageView import com.hippo.widget.Slider import com.hippo.widget.recyclerview.AutoGridLayoutManager import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.ViewUtils import java.util.Locale class GalleryPreviewsScene : ToolbarScene() { private var mGalleryInfo: GalleryInfo? = null private var mRecyclerView: EasyRecyclerView? = null private var mAdapter: GalleryPreviewAdapter? = null private var mHelper: GalleryPreviewHelper? = null private var mHasFirstRefresh = false private var mScrollTo = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } private fun onInit() { val args = arguments ?: return mGalleryInfo = args.getParcelableCompat(KEY_GALLERY_INFO) mScrollTo = args.getInt(KEY_SCROLL_TO) } private fun onRestore(savedInstanceState: Bundle) { mGalleryInfo = savedInstanceState.getParcelableCompat(KEY_GALLERY_INFO) mHasFirstRefresh = savedInstanceState.getBoolean(KEY_HAS_FIRST_REFRESH) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val hasFirstRefresh: Boolean = if (mHelper != null && 1 == mHelper!!.shownViewIndex) { false } else { mHasFirstRefresh } outState.putBoolean(KEY_HAS_FIRST_REFRESH, hasFirstRefresh) outState.putParcelable(KEY_GALLERY_INFO, mGalleryInfo) } override fun onCreateViewWithToolbar( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val mContentLayout = inflater.inflate( R.layout.scene_gallery_previews, container, false, ) as ContentLayout mContentLayout.hideFastScroll() mRecyclerView = mContentLayout.recyclerView mAdapter = GalleryPreviewAdapter() mRecyclerView!!.adapter = mAdapter val columnWidth = Settings.previewSize val layoutManager = AutoGridLayoutManager(context, columnWidth, LayoutUtils.dp2pix(context, 16f)) layoutManager.setStrategy(AutoGridLayoutManager.STRATEGY_SUITABLE_SIZE) mRecyclerView!!.layoutManager = layoutManager mRecyclerView!!.clipToPadding = false val padding = LayoutUtils.dp2pix(context, 4f) val decoration = MarginItemDecoration(padding, padding, padding, padding, padding) mRecyclerView!!.addItemDecoration(decoration) mHelper = GalleryPreviewHelper() mContentLayout.setHelper(mHelper!!) // Only refresh for the first time if (!mHasFirstRefresh) { mHasFirstRefresh = true if (mScrollTo == -1) { mHelper!!.goTo(1) mScrollTo = 0 } else { mHelper!!.firstRefresh() } } return mContentLayout } override fun onDestroyView() { super.onDestroyView() if (null != mHelper) { if (1 == mHelper!!.shownViewIndex) { mHasFirstRefresh = false } } if (null != mRecyclerView) { mRecyclerView!!.stopScroll() mRecyclerView = null } mAdapter = null } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setTitle(R.string.gallery_previews) setNavigationIcon(R.drawable.v_arrow_left_dark_x24) } override fun getMenuResId(): Int = if ((mGalleryInfo as GalleryDetail).previewPages > 1) R.menu.scene_gallery_previews else 0 override fun onMenuItemClick(item: MenuItem): Boolean { val context = context ?: return false val id = item.itemId if (id == R.id.action_go_to) { if (mHelper == null) { return true } val pages = mHelper!!.pages if (pages > 1 && mHelper!!.canGoTo()) { val helper = GoToDialogHelper(pages, mHelper!!.pageForTop) val dialog = AlertDialog.Builder(context).setTitle(R.string.go_to) .setView(R.layout.dialog_go_to) .setPositiveButton(android.R.string.ok, null) .create() dialog.show() helper.setDialog(dialog) } return true } return false } override fun onNavigationClick() { onBackPressed() } fun onItemClick(position: Int): Boolean { val context = context if (null != context && null != mHelper && null != mGalleryInfo) { val p = mHelper!!.getDataAtEx(position) if (p != null) { val intent = Intent(context, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, mGalleryInfo) intent.putExtra(GalleryActivity.KEY_PAGE, p.position) startActivity(intent) } } return true } private fun onGetPreviewSetSuccess(result: Pair, taskId: Int) { if (null != mHelper && mHelper!!.isCurrentTask(taskId) && null != mGalleryInfo) { val previewSet = result.first val size = previewSet.size() val list = ArrayList(size) for (i in 0 until size) { list.add(previewSet.getGalleryPreview(mGalleryInfo!!.gid, i)) } mHelper!!.onGetPageData( taskId, result.second, 0, null, null, list as List, ) if (mScrollTo != 0 && mScrollTo < size) { mHelper!!.scrollTo(mScrollTo) mScrollTo = 0 } } } private fun onGetPreviewSetFailure(e: Exception, taskId: Int) { if (mHelper != null && mHelper!!.isCurrentTask(taskId)) { mHelper!!.onGetException(taskId, e) } } private class GalleryPreviewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var image: LoadImageView = itemView.findViewById(R.id.image) var text: TextView = itemView.findViewById(R.id.text) } private inner class GetPreviewSetListener( context: Context, private val mTaskId: Int, ) : EhCallback>(context) { override fun onSuccess(result: Pair) { val scene = this@GalleryPreviewsScene scene.onGetPreviewSetSuccess(result, mTaskId) } override fun onFailure(e: Exception) { val scene = this@GalleryPreviewsScene scene.onGetPreviewSetFailure(e, mTaskId) } override fun onCancel() {} } private inner class GalleryPreviewAdapter : RecyclerView.Adapter() { private val mInflater: LayoutInflater = layoutInflater override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GalleryPreviewHolder = GalleryPreviewHolder( mInflater.inflate( R.layout.item_gallery_preview, parent, false, ), ) @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: GalleryPreviewHolder, position: Int) { mHelper?.getDataAtEx(position)?.let { it.load(holder.image) holder.text.text = (it.position + 1).toString() } holder.itemView.setOnClickListener { onItemClick(holder.bindingAdapterPosition) } } override fun getItemCount(): Int = if (mHelper != null) mHelper!!.size() else 0 } private inner class GalleryPreviewHelper : ContentHelper() { override fun getPageData( taskId: Int, type: Int, page: Int, index: String?, isNext: Boolean, ) { val activity = mainActivity if (null == activity || null == mGalleryInfo) { onGetException(taskId, EhException(getString(R.string.error_cannot_find_gallery))) return } val url = EhUrl.getGalleryDetailUrl(mGalleryInfo!!.gid, mGalleryInfo!!.token, page, false) val request = EhRequest() request.setMethod(EhClient.METHOD_GET_PREVIEW_SET) request.setCallback( GetPreviewSetListener(context, taskId), ) request.setArgs(url) request.enqueue(this@GalleryPreviewsScene) } override val context get() = this@GalleryPreviewsScene.requireContext() @SuppressLint("NotifyDataSetChanged") override fun notifyDataSetChanged() { if (mAdapter != null) { mAdapter!!.notifyDataSetChanged() } } override fun notifyItemRangeInserted(positionStart: Int, itemCount: Int) { if (mAdapter != null) { mAdapter!!.notifyItemRangeInserted(positionStart, itemCount) } } override fun isDuplicate(d1: GalleryPreview, d2: GalleryPreview): Boolean = false } private inner class GoToDialogHelper(private val mPages: Int, private val mCurrentPage: Int) : View.OnClickListener, DialogInterface.OnDismissListener { private var mSlider: Slider? = null private var mDialog: Dialog? = null fun setDialog(dialog: AlertDialog) { mDialog = dialog (ViewUtils.`$$`(dialog, R.id.start) as TextView).text = String.format(Locale.US, "%d", 1) (ViewUtils.`$$`(dialog, R.id.end) as TextView).text = String.format(Locale.US, "%d", mPages) mSlider = ViewUtils.`$$`(dialog, R.id.slider) as Slider mSlider!!.setRange(1, mPages) mSlider!!.progress = mCurrentPage + 1 dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(this) dialog.setOnDismissListener(this) } override fun onClick(v: View) { if (null == mSlider) { return } val page = mSlider!!.progress - 1 if (page in 0 until mPages && mHelper != null) { mHelper!!.goTo(page) if (mDialog != null) { mDialog!!.dismiss() mDialog = null } } else { showTip(R.string.error_out_of_range, LENGTH_LONG) } } override fun onDismiss(dialog: DialogInterface) { mDialog = null mSlider = null } } companion object { const val KEY_GALLERY_INFO = "gallery_info" const val KEY_SCROLL_TO = "scroll_to" private const val KEY_HAS_FIRST_REFRESH = "has_first_refresh" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/HistoryScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.text.TextUtils import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingDataAdapter import androidx.paging.cachedIn import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.FastScroller import com.hippo.easyrecyclerview.HandlerDrawable import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.FavouriteStatusRouter import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.getThumbKey import com.hippo.ehviewer.client.thumbUrl import com.hippo.ehviewer.dao.DownloadInfo import com.hippo.ehviewer.dao.HistoryInfo import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.download.DownloadManager.DownloadInfoListener import com.hippo.ehviewer.ui.CommonOperations import com.hippo.ehviewer.ui.GalleryActivity import com.hippo.ehviewer.ui.dialog.SelectItemWithIconAdapter import com.hippo.ehviewer.widget.SimpleRatingView import com.hippo.scene.Announcer import com.hippo.util.launchIO import com.hippo.util.withUIContext import com.hippo.view.ViewTransition import com.hippo.widget.LoadImageView import com.hippo.widget.recyclerview.AutoStaggeredGridLayoutManager import com.hippo.yorozuya.ViewUtils import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import rikka.core.res.resolveColor @SuppressLint("NotifyDataSetChanged") class HistoryScene : ToolbarScene() { private var mRecyclerView: EasyRecyclerView? = null private val mAdapter: HistoryAdapter by lazy { HistoryAdapter(object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: HistoryInfo, newItem: HistoryInfo): Boolean = oldItem.gid == newItem.gid override fun areContentsTheSame(oldItem: HistoryInfo, newItem: HistoryInfo): Boolean = oldItem.gid == newItem.gid }) } private val mDownloadManager = DownloadManager private val mDownloadInfoListener: DownloadInfoListener by lazy { object : DownloadInfoListener { override fun onAdd(info: DownloadInfo, list: List, position: Int) { mAdapter.notifyDataSetChanged() } override fun onUpdate(info: DownloadInfo, list: List) {} override fun onUpdateAll() {} override fun onReload() { mAdapter.notifyDataSetChanged() } override fun onChange() { mAdapter.notifyDataSetChanged() } override fun onRenameLabel(from: String, to: String) {} override fun onRemove(info: DownloadInfo, list: List, position: Int) { mAdapter.notifyDataSetChanged() } override fun onUpdateLabels() {} } } private val mFavouriteStatusRouter = EhApplication.favouriteStatusRouter private val mFavouriteStatusRouterListener: FavouriteStatusRouter.Listener by lazy { FavouriteStatusRouter.Listener { _: Long, _: Int -> mAdapter.notifyDataSetChanged() } } override fun onDestroy() { super.onDestroy() mDownloadManager.removeDownloadInfoListener(mDownloadInfoListener) mFavouriteStatusRouter.removeListener(mFavouriteStatusRouterListener) } override fun getNavCheckedItem(): Int = R.id.nav_history @SuppressLint("NotifyDataSetChanged") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mDownloadManager.addDownloadInfoListener(mDownloadInfoListener) mFavouriteStatusRouter.addListener(mFavouriteStatusRouterListener) } override fun onCreateViewWithToolbar( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { val view = inflater.inflate(R.layout.scene_history, container, false) val content = ViewUtils.`$$`(view, R.id.content) val recyclerView = ViewUtils.`$$`(content, R.id.recycler_view) as EasyRecyclerView val mFastScroller = ViewUtils.`$$`(content, R.id.fast_scroller) as FastScroller val mTip = ViewUtils.`$$`(view, R.id.tip) as TextView val mViewTransition = ViewTransition(content, mTip) val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.big_history) drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) mTip.setCompoundDrawables(null, drawable, null, null) val historyData = Pager( PagingConfig(20), ) { // DB Actions EhDB.historyLazyList }.flow.cachedIn(viewLifecycleOwner.lifecycleScope) recyclerView.adapter = mAdapter val layoutManager = AutoStaggeredGridLayoutManager( 0, StaggeredGridLayoutManager.VERTICAL, ) layoutManager.setColumnSize(Settings.detailSize) layoutManager.setStrategy(AutoStaggeredGridLayoutManager.STRATEGY_MIN_SIZE) recyclerView.layoutManager = layoutManager recyclerView.clipToPadding = false recyclerView.clipChildren = false val itemTouchHelper = ItemTouchHelper(HistoryItemTouchHelperCallback()) itemTouchHelper.attachToRecyclerView(recyclerView) mFastScroller.attachToRecyclerView(recyclerView) mRecyclerView = recyclerView val handlerDrawable = HandlerDrawable() handlerDrawable.setColor(theme.resolveColor(R.attr.widgetColorThemeAccent)) mFastScroller.setHandlerDrawable(handlerDrawable) viewLifecycleOwner.lifecycleScope.launch { historyData.collectLatest { value -> mAdapter.submitData( value, ) } } viewLifecycleOwner.lifecycleScope.launch { mAdapter.onPagesUpdatedFlow.collectLatest { if (mAdapter.itemCount == 0) { mViewTransition.showView(1, true) } else { mViewTransition.showView(0, true) } } } return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setTitle(R.string.history) setNavigationIcon(R.drawable.ic_baseline_menu_24) } override fun onDestroyView() { super.onDestroyView() mRecyclerView?.stopScroll() mRecyclerView = null } @SuppressLint("RtlHardcoded") override fun onNavigationClick() { toggleDrawer(GravityCompat.START) } override fun getMenuResId(): Int = R.menu.scene_history private fun showClearAllDialog() { AlertDialog.Builder(requireContext()) .setMessage(R.string.clear_all_history) .setPositiveButton(R.string.clear_all) { _: DialogInterface?, which: Int -> if (DialogInterface.BUTTON_POSITIVE != which) { return@setPositiveButton } lifecycleScope.launchIO { // DB Actions EhDB.clearHistoryInfo() withUIContext { mAdapter.refresh() } } } .setNegativeButton(android.R.string.cancel, null) .show() } override fun onMenuItemClick(item: MenuItem): Boolean { val id = item.itemId if (id == R.id.action_clear_all) { showClearAllDialog() return true } return false } fun onItemClick(view: View, gi: GalleryInfo): Boolean { val args = Bundle() args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GALLERY_INFO) args.putParcelable(GalleryDetailScene.KEY_GALLERY_INFO, gi) val announcer = Announcer(GalleryDetailScene::class.java).setArgs(args) val thumb: View? = view.findViewById(R.id.thumb) thumb?.let { announcer.setTranHelper(EnterGalleryDetailTransaction(thumb)) } startScene(announcer) return true } fun onItemLongClick(gi: GalleryInfo): Boolean { val context = requireContext() val activity = mainActivity ?: return false val downloaded = mDownloadManager.getDownloadState(gi.gid) != DownloadInfo.STATE_INVALID val favourited = gi.favoriteSlot != -2 val items = if (downloaded) { arrayOf( context.getString(R.string.read), context.getString(R.string.delete_downloads), context.getString(if (favourited) R.string.remove_from_favourites else R.string.add_to_favourites), context.getString(R.string.delete), context.getString(R.string.download_move_dialog_title), ) } else { arrayOf( context.getString(R.string.read), context.getString(R.string.download), context.getString(if (favourited) R.string.remove_from_favourites else R.string.add_to_favourites), context.getString(R.string.delete), ) } val icons = if (downloaded) { intArrayOf( R.drawable.v_book_open_x24, R.drawable.v_delete_x24, if (favourited) R.drawable.v_heart_broken_x24 else R.drawable.v_heart_x24, R.drawable.v_delete_x24, R.drawable.v_folder_move_x24, ) } else { intArrayOf( R.drawable.v_book_open_x24, R.drawable.v_download_x24, if (favourited) R.drawable.v_heart_broken_x24 else R.drawable.v_heart_x24, R.drawable.v_delete_x24, ) } AlertDialog.Builder(context) .setTitle(EhUtils.getSuitableTitle(gi)) .setAdapter( SelectItemWithIconAdapter( context, items, icons, ), ) { _: DialogInterface?, which: Int -> when (which) { 0 -> { val intent = Intent(activity, GalleryActivity::class.java) intent.action = GalleryActivity.ACTION_EH intent.putExtra(GalleryActivity.KEY_GALLERY_INFO, gi) startActivity(intent) } 1 -> if (downloaded) { AlertDialog.Builder(context) .setTitle(R.string.download_remove_dialog_title) .setMessage( getString( R.string.download_remove_dialog_message, gi.title, ), ) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> // DownloadManager Actions mDownloadManager.deleteDownload( gi.gid, ) } .show() } else { // CommonOperations Actions CommonOperations.startDownload(activity, gi, false) } 2 -> if (favourited) { // CommonOperations Actions CommonOperations.removeFromFavorites( activity, gi, RemoveFromFavoriteListener(context), ) } else { // CommonOperations Actions CommonOperations.addToFavorites( activity, gi, AddToFavoriteListener(context), false, ) } 3 -> { lifecycleScope.launchIO { val hi: HistoryInfo? = gi as? HistoryInfo // DB Actions hi?.let { EhDB.deleteHistoryInfo(hi) } withUIContext { mAdapter.refresh() } } } 4 -> { val labelRawList = mDownloadManager.labelList val labelList: MutableList = ArrayList(labelRawList.size + 1) labelList.add(getString(R.string.default_download_label_name)) var i = 0 val n = labelRawList.size while (i < n) { labelRawList[i].label?.let { labelList.add(it) } i++ } val labels = labelList.toTypedArray() val helper = MoveDialogHelper(labels, gi) AlertDialog.Builder(context) .setTitle(R.string.download_move_dialog_title) .setItems(labels, helper) .show() } } }.show() return true } private class AddToFavoriteListener(context: Context) : EhCallback(context) { override fun onSuccess(result: Unit) { showTip(R.string.add_to_favorite_success, LENGTH_SHORT) } override fun onFailure(e: Exception) { showTip(R.string.add_to_favorite_failure, LENGTH_LONG) } override fun onCancel() {} } private class RemoveFromFavoriteListener(context: Context) : EhCallback(context) { override fun onSuccess(result: Unit) { showTip(R.string.remove_from_favorite_success, LENGTH_SHORT) } override fun onFailure(e: Exception) { showTip(R.string.remove_from_favorite_failure, LENGTH_LONG) } override fun onCancel() {} } private class HistoryHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val card: View = itemView.findViewById(R.id.card) val thumb: LoadImageView = itemView.findViewById(R.id.thumb) val title: TextView = itemView.findViewById(R.id.title) val uploader: TextView = itemView.findViewById(R.id.uploader) val rating: SimpleRatingView = itemView.findViewById(R.id.rating) val category: TextView = itemView.findViewById(R.id.category) val posted: TextView = itemView.findViewById(R.id.posted) val simpleLanguage: TextView = itemView.findViewById(R.id.simple_language) val pages: TextView = itemView.findViewById(R.id.pages) val downloaded: ImageView = itemView.findViewById(R.id.downloaded) val favourited: ImageView = itemView.findViewById(R.id.favourited) } private inner class MoveDialogHelper( private val mLabels: Array, private val mGi: GalleryInfo, ) : DialogInterface.OnClickListener { override fun onClick(dialog: DialogInterface, which: Int) { val downloadInfo = mDownloadManager.getDownloadInfo(mGi.gid) ?: return val label = if (which == 0) null else mLabels[which] // DownloadManager Actions mDownloadManager.changeLabel(listOf(downloadInfo), label) } } private inner class HistoryAdapter(diffCallback: DiffUtil.ItemCallback) : PagingDataAdapter(diffCallback) { private val mInflater: LayoutInflater = layoutInflater private val mListThumbWidth: Int private val mListThumbHeight: Int init { @SuppressLint("InflateParams") val calculator = mInflater.inflate(R.layout.item_gallery_list_thumb_height, null) ViewUtils.measureView(calculator, 1024, ViewGroup.LayoutParams.WRAP_CONTENT) mListThumbHeight = calculator.measuredHeight mListThumbWidth = mListThumbHeight * 2 / 3 } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryHolder { val holder = HistoryHolder(mInflater.inflate(R.layout.item_history, parent, false)) val lp = holder.thumb.layoutParams lp.width = mListThumbWidth lp.height = mListThumbHeight holder.thumb.layoutParams = lp return holder } override fun onBindViewHolder(holder: HistoryHolder, position: Int) { val gi: GalleryInfo? = getItem(position) gi ?: return gi.thumb?.let { holder.thumb.load(getThumbKey(gi.gid), gi.thumbUrl!!, hardware = false) } holder.title.text = EhUtils.getSuitableTitle(gi) holder.uploader.text = gi.uploader holder.rating.rating = gi.rating val category = holder.category val newCategoryText = EhUtils.getCategory(gi.category) if (!newCategoryText.contentEquals(category.text)) { category.text = newCategoryText category.setBackgroundColor(EhUtils.getCategoryColor(gi.category)) } holder.posted.text = gi.posted holder.pages.text = null holder.pages.visibility = View.GONE if (TextUtils.isEmpty(gi.simpleLanguage)) { holder.simpleLanguage.text = null holder.simpleLanguage.visibility = View.GONE } else { holder.simpleLanguage.text = gi.simpleLanguage holder.simpleLanguage.visibility = View.VISIBLE } holder.downloaded.visibility = if (mDownloadManager.containDownloadInfo(gi.gid)) View.VISIBLE else View.GONE holder.favourited.visibility = if (gi.favoriteSlot != -2) View.VISIBLE else View.GONE // Update transition name ViewCompat.setTransitionName( holder.thumb, TransitionNameFactory.getThumbTransitionName(gi.gid), ) holder.card.setOnClickListener { onItemClick(holder.itemView, gi) } holder.card.setOnLongClickListener { onItemLongClick(gi) } } } private inner class HistoryItemTouchHelperCallback : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, ): Int = makeMovementFlags(0, ItemTouchHelper.LEFT) override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float = 0.3f override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder, ): Boolean = false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val mPosition = viewHolder.bindingAdapterPosition lifecycleScope.launchIO { val info: HistoryInfo? = mAdapter.peek(mPosition) // DB Actions info?.let { EhDB.deleteHistoryInfo(info) } withUIContext { mAdapter.refresh() } } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/ProgressScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.core.content.ContextCompat import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhClient import com.hippo.ehviewer.client.EhRequest import com.hippo.scene.Announcer import com.hippo.util.ExceptionUtils import com.hippo.view.ViewTransition import com.hippo.yorozuya.ViewUtils import kotlinx.coroutines.DelicateCoroutinesApi /** * Only show a progress with jobs in background */ class ProgressScene : BaseScene(), View.OnClickListener { private var mValid = false private var mError: String? = null private var mAction: String? = null private var mGid: Long = 0 private var mPToken: String? = null private var mPage = 0 private var mTip: TextView? = null private var mViewTransition: ViewTransition? = null override fun needShowLeftDrawer(): Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) { onInit() } else { onRestore(savedInstanceState) } } @OptIn(DelicateCoroutinesApi::class) private fun doJobs(): Boolean { val context = context val activity = mainActivity if (null == context || null == activity) { return false } if (ACTION_GALLERY_TOKEN == mAction) { if (mGid == -1L || mPToken == null || mPage == -1) { return false } val request = EhRequest() .setMethod(EhClient.METHOD_GET_GALLERY_TOKEN) .setArgs(mGid, mPToken!!, mPage) .setCallback( GetGalleryTokenListener(context), ) request.enqueue() return true } return false } private fun handleArgs(args: Bundle?): Boolean { if (args == null) { return false } mAction = args.getString(KEY_ACTION) if (ACTION_GALLERY_TOKEN == mAction) { mGid = args.getLong(KEY_GID, -1) mPToken = args.getString(KEY_PTOKEN, null) mPage = args.getInt(KEY_PAGE, -1) return mGid != -1L && mPToken != null && mPage != -1 } return false } private fun onInit() { mValid = handleArgs(arguments) if (mValid) { mValid = doJobs() } if (!mValid) { mError = getString(R.string.error_something_wrong_happened) } } private fun onRestore(savedInstanceState: Bundle) { mValid = savedInstanceState.getBoolean(KEY_VALID) mError = savedInstanceState.getString(KEY_ERROR) mAction = savedInstanceState.getString(KEY_ACTION) mGid = savedInstanceState.getLong(KEY_GID, -1) mPToken = savedInstanceState.getString(KEY_PTOKEN, null) mPage = savedInstanceState.getInt(KEY_PAGE, -1) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(KEY_VALID, mValid) outState.putString(KEY_ERROR, mError) outState.putString(KEY_ACTION, mAction) outState.putLong(KEY_GID, mGid) outState.putString(KEY_PTOKEN, mPToken) outState.putInt(KEY_PAGE, mPage) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { val view = inflater.inflate(R.layout.scene_progress, container, false) val progress = ViewUtils.`$$`(view, R.id.progress) mTip = ViewUtils.`$$`(view, R.id.tip) as TextView val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.big_sad_pandroid) drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) mTip!!.setCompoundDrawables(null, drawable, null, null) mTip!!.setOnClickListener(this) mTip!!.text = mError mViewTransition = ViewTransition(progress, mTip) if (mValid) { mViewTransition!!.showView(0, false) } else { mViewTransition!!.showView(1, false) } return view } override fun onDestroyView() { super.onDestroyView() mTip = null mViewTransition = null } override fun onClick(v: View) { if (mTip === v) { if (doJobs()) { mValid = true // Show progress if (null != mViewTransition) { mViewTransition!!.showView(0, true) } } } } private fun onGetGalleryTokenSuccess(result: String) { val arg = Bundle() arg.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GID_TOKEN) arg.putLong(GalleryDetailScene.KEY_GID, mGid) arg.putString(GalleryDetailScene.KEY_TOKEN, result) arg.putInt(GalleryDetailScene.KEY_PAGE, mPage) startScene(Announcer(GalleryDetailScene::class.java).setArgs(arg)) finish() } private fun onGetGalleryTokenFailure(e: Exception) { mValid = false val context = context if (null != context && null != mViewTransition && null != mTip) { // Show tip mError = ExceptionUtils.getReadableString(e) mViewTransition!!.showView(1) mTip!!.text = mError } } private inner class GetGalleryTokenListener( context: Context, ) : EhCallback(context) { override fun onSuccess(result: String) { val scene = this@ProgressScene scene.onGetGalleryTokenSuccess(result) } override fun onFailure(e: Exception) { val scene = this@ProgressScene scene.onGetGalleryTokenFailure(e) } override fun onCancel() {} } companion object { const val KEY_ACTION = "action" const val ACTION_GALLERY_TOKEN = "gallery_token" const val KEY_GID = "gid" const val KEY_PTOKEN = "ptoken" const val KEY_PAGE = "page" private const val KEY_VALID = "valid" private const val KEY_ERROR = "error" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/SecurityScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.content.Context import android.hardware.Sensor import android.hardware.SensorManager import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.widget.lockpattern.LockPatternUtils import com.hippo.widget.lockpattern.LockPatternView import com.hippo.widget.lockpattern.LockPatternView.OnPatternListener import com.hippo.yorozuya.ObjectUtils import com.hippo.yorozuya.ViewUtils import java.util.concurrent.Executors class SecurityScene : SolidScene(), OnPatternListener { private var mPatternView: LockPatternView? = null private var mSensorManager: SensorManager? = null private var mAccelerometer: Sensor? = null private var promptInfo: BiometricPrompt.PromptInfo? = null private var biometricPrompt: BiometricPrompt? = null private var canAuthenticate = false private var mRetryTimes = 0 override fun needShowLeftDrawer(): Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val context = requireContext() mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager mAccelerometer = mSensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) mRetryTimes = savedInstanceState?.getInt(KEY_RETRY_TIMES) ?: MAX_RETRY_TIMES canAuthenticate = Settings.enableFingerprint && BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS biometricPrompt = BiometricPrompt( this, Executors.newSingleThreadExecutor(), object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { startSceneForCheckStep(CHECK_STEP_SECURITY, arguments) finish() } }, ) promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(getString(R.string.app_name)) .setNegativeButtonText(getString(android.R.string.cancel)) .setConfirmationRequired(false) .build() } override fun onDestroy() { super.onDestroy() mSensorManager = null mAccelerometer = null } override fun onResume() { super.onResume() if (canAuthenticate) { startBiometricPrompt() } } private fun startBiometricPrompt() { biometricPrompt!!.authenticate(promptInfo!!) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt(KEY_RETRY_TIMES, mRetryTimes) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.scene_security, container, false) if (canAuthenticate) { view.setOnClickListener { startBiometricPrompt() } } mPatternView = ViewUtils.`$$`(view, R.id.pattern_view) as LockPatternView mPatternView!!.setOnPatternListener(this) return view } override fun onDestroyView() { super.onDestroyView() mPatternView = null } override fun onPatternStart() {} override fun onPatternCleared() {} override fun onPatternCellAdded(pattern: List) {} override fun onPatternDetected(pattern: List) { val activity = mainActivity if (null == activity || null == mPatternView) { return } val enteredPatter = LockPatternUtils.patternToString(pattern) val targetPatter = Settings.security if (ObjectUtils.equal(enteredPatter, targetPatter)) { startSceneForCheckStep(CHECK_STEP_SECURITY, arguments) finish() } else { mPatternView!!.setDisplayMode(LockPatternView.DisplayMode.Wrong) mRetryTimes-- if (mRetryTimes <= 0) { finish() } } } companion object { private const val KEY_RETRY_TIMES = "retry_times" private const val MAX_RETRY_TIMES = 5 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/SelectSiteScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButtonToggleGroup import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUrl import com.hippo.yorozuya.ViewUtils class SelectSiteScene : SolidScene(), View.OnClickListener { private var mButtonGroup: MaterialButtonToggleGroup? = null private var mOk: View? = null override fun needShowLeftDrawer(): Boolean = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_select_site, container, false) mButtonGroup = ViewUtils.`$$`(view, R.id.button_group) as MaterialButtonToggleGroup (ViewUtils.`$$`(view, R.id.site_ex) as MaterialButton).isChecked = true mOk = ViewUtils.`$$`(view, R.id.ok) mOk!!.setOnClickListener(this) return view } override fun onDestroyView() { super.onDestroyView() mButtonGroup = null mOk = null } override fun onClick(v: View) { val id = mButtonGroup?.checkedButtonId ?: return if (v == mOk) { Settings.putSelectSite(false) Settings.putGallerySite(if (id == R.id.site_ex) EhUrl.SITE_EX else EhUrl.SITE_E) startSceneForCheckStep(CHECK_STEP_SELECT_SITE, arguments) finish() } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/SignInScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.graphics.Paint import android.os.Bundle import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.TextView import android.widget.TextView.OnEditorActionListener import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.UrlOpener import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhEngine import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.scene.Announcer import com.hippo.util.ExceptionUtils import com.hippo.util.launchIO import com.hippo.util.withUIContext import com.hippo.yorozuya.ViewUtils import kotlinx.coroutines.Job import kotlinx.coroutines.launch class SignInScene : SolidScene(), OnEditorActionListener, View.OnClickListener { private var mProgress: View? = null private var mUsernameLayout: TextInputLayout? = null private var mPasswordLayout: TextInputLayout? = null private var mUsername: EditText? = null private var mPassword: EditText? = null private var mRegister: View? = null private var mSignIn: View? = null private var mSignInViaWebView: TextView? = null private var mSignInViaCookies: TextView? = null private var mSkipSigningIn: TextView? = null private var mSignInJob: Job? = null override fun needShowLeftDrawer(): Boolean = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val view = inflater.inflate(R.layout.scene_login, container, false) val loginForm = ViewUtils.`$$`(view, R.id.login_form) mProgress = ViewUtils.`$$`(view, R.id.progress) mUsernameLayout = ViewUtils.`$$`(loginForm, R.id.username_layout) as TextInputLayout mUsername = mUsernameLayout!!.editText!! mPasswordLayout = ViewUtils.`$$`(loginForm, R.id.password_layout) as TextInputLayout mPassword = mPasswordLayout!!.editText!! mRegister = ViewUtils.`$$`(loginForm, R.id.register) mSignIn = ViewUtils.`$$`(loginForm, R.id.sign_in) mSignInViaWebView = ViewUtils.`$$`(loginForm, R.id.sign_in_via_webview) as TextView mSignInViaCookies = ViewUtils.`$$`(loginForm, R.id.sign_in_via_cookies) as TextView mSkipSigningIn = ViewUtils.`$$`(loginForm, R.id.tourist_mode) as TextView mSignInViaWebView!!.run { paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG } mSignInViaCookies!!.run { paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG } mSkipSigningIn!!.run { paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG or Paint.ANTI_ALIAS_FLAG } mPassword!!.setOnEditorActionListener(this) mRegister!!.setOnClickListener(this) mSignIn!!.setOnClickListener(this) mSignInViaWebView!!.setOnClickListener(this) mSignInViaCookies!!.setOnClickListener(this) mSkipSigningIn!!.setOnClickListener(this) return view } override fun onDestroyView() { super.onDestroyView() mProgress = null mUsernameLayout = null mPasswordLayout = null mUsername = null mPassword = null mRegister = null mSignIn = null mSignInViaWebView = null mSignInViaCookies = null mSkipSigningIn = null mSignInJob = null } private fun showProgress() { if (mProgress?.visibility == View.VISIBLE) return mProgress?.apply { alpha = 0f visibility = View.VISIBLE animate().alpha(1f).setDuration(500).start() } } private fun hideProgress() { mProgress?.visibility = View.GONE } override fun onSceneResult(requestCode: Int, resultCode: Int, data: Bundle?) { when (requestCode) { REQUEST_CODE_WEBVIEW -> if (resultCode == RESULT_OK) { getProfile() } REQUEST_CODE_COOKIE -> if (resultCode == RESULT_OK) { finishSignIn() } else -> super.onSceneResult(requestCode, resultCode, data) } } override fun onClick(v: View) { val activity = mainActivity ?: return when (v) { mRegister -> UrlOpener.openUrl(activity, EhUrl.URL_REGISTER, false) mSignIn -> signIn() mSignInViaCookies -> startScene(Announcer(CookieSignInScene::class.java).setRequestCode(this, REQUEST_CODE_COOKIE)) mSignInViaWebView -> startScene(Announcer(WebViewSignInScene::class.java).setRequestCode(this, REQUEST_CODE_WEBVIEW)) mSkipSigningIn -> { lifecycleScope.launchIO { EhUtils.signOut() // Set gallery size SITE_E if skip sign in Settings.putGallerySite(EhUrl.SITE_E) Settings.putSelectSite(false) finishSignIn(false) } } } } override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean { if (v == mPassword) { if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_NULL) { signIn() return true } } return false } private fun signIn() { if (mSignInJob?.isActive == true || mUsername == null || mPassword == null || mUsernameLayout == null || mPasswordLayout == null ) { return } val username = mUsername!!.text.toString() val password = mPassword!!.text.toString() if (username.isEmpty()) { mUsernameLayout!!.error = getString(R.string.error_username_cannot_empty) return } else { mUsernameLayout!!.error = null } if (password.isEmpty()) { mPasswordLayout!!.error = getString(R.string.error_password_cannot_empty) return } else { mPasswordLayout!!.error = null } hideSoftInput() showProgress() mSignInJob = viewLifecycleOwner.lifecycleScope.launchIO { EhUtils.signOut() runCatching { EhEngine.signIn(username, password) }.onFailure { withUIContext { hideProgress() showResultErrorDialog(it) } }.onSuccess { getProfile() } } } private fun getProfile() { lifecycleScope.launchIO { withUIContext { showProgress() } runCatching { EhEngine.getProfile().run { Settings.putDisplayName(displayName) Settings.putAvatar(avatar) } }.onFailure { withUIContext { hideProgress() showResultErrorDialog(it) } }.onSuccess { finishSignIn() } } } private fun finishSignIn(signedIn: Boolean = true) { lifecycleScope.launchIO { withUIContext { showProgress() } if (signedIn) { runCatching { // For the `star` cookie, https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873 EhEngine.getNews(false) EhCookieStore.copyCookie(EhUrl.DOMAIN_E, EhUrl.DOMAIN_EX, EhCookieStore.KEY_STAR) // Get cookies for image limits launch { runCatching { EhEngine.getUConfig(EhUrl.URL_UCONFIG_E) } } // Sad panda check EhEngine.getUConfig(EhUrl.URL_UCONFIG_EX) }.onFailure { Settings.putGallerySite(EhUrl.SITE_E) Settings.putSelectSite(false) } } withUIContext { Settings.putNeedSignIn(false) updateAvatar() if (null != mainActivity) { startSceneForCheckStep(CHECK_STEP_SIGN_IN, arguments) } finish() } } } private fun showResultErrorDialog(e: Throwable) { AlertDialog.Builder(requireContext()) .setTitle(R.string.sign_in_failed) .setMessage("${ExceptionUtils.getReadableString(e)}\n\n${getString(R.string.sign_in_failed_tip)}") .setPositiveButton(R.string.get_it, null) .show() } companion object { private const val REQUEST_CODE_COOKIE = 0 private const val REQUEST_CODE_WEBVIEW = 1 } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/SolidScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.os.Bundle import android.util.Log import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhUtils import com.hippo.scene.Announcer /** * Scene for safety, can't be covered */ open class SolidScene : BaseScene() { fun startSceneForCheckStep(checkStep: Int, args: Bundle?) { when (checkStep) { CHECK_STEP_SECURITY -> { if (EhUtils.needSignedIn()) { startScene(Announcer(SignInScene::class.java).setArgs(args), true) } else { startSceneForCheckStep(CHECK_STEP_SIGN_IN, args) } } CHECK_STEP_SIGN_IN -> { if (Settings.selectSite) { startScene(Announcer(SelectSiteScene::class.java).setArgs(args), true) } else { startSceneForCheckStep(CHECK_STEP_SELECT_SITE, args) } } CHECK_STEP_SELECT_SITE -> { var targetScene: String? = null var targetArgs: Bundle? = null if (null != args) { targetScene = args.getString(KEY_TARGET_SCENE) targetArgs = args.getBundle(KEY_TARGET_ARGS) } var clazz: Class<*>? = null if (targetScene != null) { try { clazz = Class.forName(targetScene) } catch (_: ClassNotFoundException) { Log.e(TAG, "Can't find class with name: $targetScene") } } if (clazz != null) { startScene(Announcer(clazz).setArgs(targetArgs)) } else { val newArgs = Bundle() newArgs.putString(GalleryListScene.KEY_ACTION, Settings.launchPageGalleryListSceneAction) startScene(Announcer(GalleryListScene::class.java).setArgs(newArgs)) } } } } companion object { const val CHECK_STEP_SECURITY = 0 const val CHECK_STEP_SIGN_IN = 1 const val CHECK_STEP_SELECT_SITE = 2 const val KEY_TARGET_SCENE = "target_scene" const val KEY_TARGET_ARGS = "target_args" private val TAG = SolidScene::class.java.simpleName } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/ToolbarScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.widget.Toolbar import com.hippo.ehviewer.R abstract class ToolbarScene : BaseScene() { private var mToolbar: Toolbar? = null private var mTempTitle: CharSequence? = null open fun onCreateViewWithToolbar( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? = null override var needWhiteStatusBar = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { val view = inflater.inflate(R.layout.scene_toolbar, container, false) val toolbar = view.findViewById(R.id.toolbar) val contentPanel = view.findViewById(R.id.content_panel) val contentView = onCreateViewWithToolbar(inflater, contentPanel, savedInstanceState) return if (contentView == null) { null } else { mToolbar = toolbar contentPanel.addView(contentView, 0) view } } override fun onDestroyView() { super.onDestroyView() mToolbar = null } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) mToolbar?.apply { mTempTitle?.let { title = it } val menuResId = getMenuResId() if (menuResId != 0) { inflateMenu(menuResId) setOnMenuItemClickListener { item: MenuItem -> onMenuItemClick(item) } } setNavigationOnClickListener { onNavigationClick() } } } open fun getMenuResId(): Int = 0 open fun onMenuItemClick(item: MenuItem): Boolean = false open fun onNavigationClick() {} fun setNavigationIcon(@DrawableRes resId: Int) { mToolbar?.setNavigationIcon(resId) } fun setTitle(@StringRes resId: Int) { setTitle(getString(resId)) } fun setTitle(title: CharSequence) { mToolbar?.title = title mTempTitle = title } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/TransitionNameFactory.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene object TransitionNameFactory { fun getThumbTransitionName(gid: Long): String = "thumb:$gid" fun getTitleTransitionName(gid: Long): String = "title:$gid" fun getUploaderTransitionName(gid: Long): String = "uploader:$gid" fun getCategoryTransitionName(gid: Long): String = "category:$gid" } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/ui/scene/WebViewSignInScene.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.ui.scene import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebViewClient import androidx.lifecycle.lifecycleScope import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.util.setDefaultSettings import com.hippo.ehviewer.widget.DialogWebChromeClient import com.hippo.util.launchIO import com.hippo.util.withUIContext import okhttp3.Cookie import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import rikka.core.res.resolveColor class WebViewSignInScene : SolidScene() { private var mWebView: WebView? = null override fun needShowLeftDrawer(): Boolean = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { // http://stackoverflow.com/questions/32284642/how-to-handle-an-uncatched-exception CookieManager.getInstance().apply { flush() removeAllCookies(null) removeSessionCookies(null) } return WebView(requireContext()).apply { setBackgroundColor(theme.resolveColor(android.R.attr.colorBackground)) setDefaultSettings() webViewClient = LoginWebViewClient() webChromeClient = DialogWebChromeClient(context) loadUrl(EhUrl.URL_SIGN_IN) mWebView = this } } override fun onDestroyView() { super.onDestroyView() mWebView?.destroy() mWebView = null } private inner class LoginWebViewClient : WebViewClient() { fun parseCookies(url: HttpUrl?, cookieStrings: String?): List { if (cookieStrings == null) { return emptyList() } var cookies: MutableList? = null val pieces = cookieStrings.split(";".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() for (piece in pieces) { val cookie = Cookie.parse(url!!, piece) ?: continue if (cookies == null) { cookies = ArrayList() } cookies.add(cookie) } return cookies ?: emptyList() } private suspend fun addCookie(domain: String, cookie: Cookie) { EhCookieStore.addCookie( EhCookieStore.newCookie( cookie, domain, forcePersistent = true, forceLongLive = true, forceNotHostOnly = true, ), ) } override fun onPageFinished(view: WebView, url: String) { val httpUrl = url.toHttpUrlOrNull() ?: return val cookieString = CookieManager.getInstance().getCookie(EhUrl.HOST_E) val cookies = parseCookies(httpUrl, cookieString) var getId = false var getHash = false for (cookie in cookies) { if (EhCookieStore.KEY_IPB_MEMBER_ID == cookie.name) { getId = true } else if (EhCookieStore.KEY_IPB_PASS_HASH == cookie.name) { getHash = true } } if (getId && getHash) { viewLifecycleOwner.lifecycleScope.launchIO { EhUtils.signOut() cookies.forEach { addCookie(EhUrl.DOMAIN_EX, it) addCookie(EhUrl.DOMAIN_E, it) } withUIContext { setResult(RESULT_OK, null) finish() } } } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/util/WebViewExtensions.kt ================================================ package com.hippo.ehviewer.util import android.annotation.SuppressLint import android.webkit.WebSettings import android.webkit.WebView import androidx.webkit.WebViewCompat import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.Settings private const val MINIMUM_WEBVIEW_VERSION = 118 val WebViewVersion = run { val context = EhApplication.application val uaVersion = runCatching { Regex("Chrome/(\\d+)") .find(WebSettings.getDefaultUserAgent(context)) ?.groupValues ?.get(1) ?.toIntOrNull() }.getOrNull() val pkgVersion = WebViewCompat .getCurrentWebViewPackage(context) ?.versionName ?.substringBefore('.') ?.toIntOrNull() uaVersion ?: pkgVersion ?: MINIMUM_WEBVIEW_VERSION } val isWebViewOutdated = WebViewVersion < MINIMUM_WEBVIEW_VERSION @SuppressLint("SetJavaScriptEnabled") fun WebView.setDefaultSettings() = settings.run { builtInZoomControls = true displayZoomControls = false javaScriptEnabled = true domStorageEnabled = true userAgentString = Settings.userAgent!! } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/AdvanceSearchTable.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.annotation.SuppressLint import android.content.Context import android.os.Bundle import android.os.Parcelable import android.util.AttributeSet import android.view.KeyEvent import android.view.LayoutInflater import android.view.ViewGroup import android.widget.CheckBox import android.widget.EditText import android.widget.LinearLayout import android.widget.Spinner import android.widget.TextView import com.hippo.ehviewer.R import com.hippo.util.getParcelableCompat import com.hippo.yorozuya.NumberUtils class AdvanceSearchTable @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : LinearLayout(context, attrs) { private var mSh: CheckBox private var mSto: CheckBox private var mSr: CheckBox private var mMinRating: Spinner private var mSp: CheckBox private var mSpf: EditText private var mSpt: EditText private var mSfl: CheckBox private var mSfu: CheckBox private var mSft: CheckBox init { orientation = VERTICAL val inflater = LayoutInflater.from(context) inflater.inflate(R.layout.widget_advance_search_table, this) val row0 = getChildAt(0) as ViewGroup mSh = row0.getChildAt(0) as CheckBox mSto = row0.getChildAt(1) as CheckBox val row1 = getChildAt(1) as ViewGroup mSr = row1.getChildAt(0) as CheckBox mMinRating = row1.getChildAt(1) as Spinner val row2 = getChildAt(2) as ViewGroup mSp = row2.getChildAt(0) as CheckBox mSpf = row2.getChildAt(1) as EditText mSpt = row2.getChildAt(3) as EditText val row4 = getChildAt(4) as ViewGroup mSfl = row4.getChildAt(0) as CheckBox mSfu = row4.getChildAt(1) as CheckBox mSft = row4.getChildAt(2) as CheckBox mSpt.setOnEditorActionListener { v: TextView, _: Int, _: KeyEvent? -> val nextView = v.focusSearch(FOCUS_DOWN) nextView?.requestFocus(FOCUS_DOWN) true } } var advanceSearch: Int get() { var advanceSearch = 0 if (mSh.isChecked) advanceSearch = advanceSearch or SH if (mSto.isChecked) advanceSearch = advanceSearch or STO if (mSfl.isChecked) advanceSearch = advanceSearch or SFL if (mSfu.isChecked) advanceSearch = advanceSearch or SFU if (mSft.isChecked) advanceSearch = advanceSearch or SFT return advanceSearch } set(advanceSearch) { mSh.isChecked = NumberUtils.int2boolean(advanceSearch and SH) mSto.isChecked = NumberUtils.int2boolean(advanceSearch and STO) mSfl.isChecked = NumberUtils.int2boolean(advanceSearch and SFL) mSfu.isChecked = NumberUtils.int2boolean(advanceSearch and SFU) mSft.isChecked = NumberUtils.int2boolean(advanceSearch and SFT) } var minRating: Int get() { val position = mMinRating.selectedItemPosition return if (mSr.isChecked && position >= 0) { position + 2 } else { -1 } } set(minRating) { if (minRating in 2..5) { mSr.isChecked = true mMinRating.setSelection(minRating - 2) } else { mSr.isChecked = false } } var pageFrom: Int get() = if (mSp.isChecked) { NumberUtils.parseIntSafely(mSpf.text.toString(), -1) } else { -1 } @SuppressLint("SetTextI18n") set(pageFrom) { if (pageFrom > 0) { mSpf.setText(pageFrom.toString()) mSp.isChecked = true } else { mSp.isChecked = false mSpf.text = null } } var pageTo: Int get() = if (mSp.isChecked) { NumberUtils.parseIntSafely(mSpt.text.toString(), -1) } else { -1 } @SuppressLint("SetTextI18n") set(pageTo) { if (pageTo > 0) { mSpt.setText(pageTo.toString()) mSp.isChecked = true } else { mSp.isChecked = false } } override fun onSaveInstanceState(): Parcelable { val state = Bundle() state.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState()) state.putInt(STATE_KEY_ADVANCE_SEARCH, advanceSearch) state.putInt(STATE_KEY_MIN_RATING, minRating) state.putInt(STATE_KEY_PAGE_FROM, pageFrom) state.putInt(STATE_KEY_PAGE_TO, pageTo) return state } override fun onRestoreInstanceState(state: Parcelable) { if (state is Bundle) { super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER)) advanceSearch = state.getInt(STATE_KEY_ADVANCE_SEARCH) minRating = state.getInt(STATE_KEY_MIN_RATING) pageFrom = state.getInt(STATE_KEY_PAGE_FROM) pageTo = state.getInt(STATE_KEY_PAGE_TO) } else { super.onRestoreInstanceState(state) } } companion object { const val SH = 0x1 const val STO = 0x2 const val SFL = 0x100 const val SFU = 0x200 const val SFT = 0x400 private const val STATE_KEY_SUPER = "super" private const val STATE_KEY_ADVANCE_SEARCH = "advance_search" private const val STATE_KEY_MIN_RATING = "min_rating" private const val STATE_KEY_PAGE_FROM = "page_from" private const val STATE_KEY_PAGE_TO = "page_to" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/CategoryTable.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.content.Context import android.os.Bundle import android.os.Parcelable import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TableLayout import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhUtils import com.hippo.util.getParcelableCompat import com.hippo.widget.CheckTextView import com.hippo.yorozuya.NumberUtils class CategoryTable @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : TableLayout(context, attrs), View.OnLongClickListener { private var mDoujinshi: CheckTextView private var mManga: CheckTextView private var mArtistCG: CheckTextView private var mGameCG: CheckTextView private var mWestern: CheckTextView private var mNonH: CheckTextView private var mImageSets: CheckTextView private var mCosplay: CheckTextView private var mAsianPorn: CheckTextView private var mMisc: CheckTextView private var mOptions: Array init { LayoutInflater.from(context).inflate(R.layout.widget_category_table, this) val row0 = getChildAt(0) as ViewGroup mDoujinshi = row0.getChildAt(0) as CheckTextView mManga = row0.getChildAt(1) as CheckTextView val row1 = getChildAt(1) as ViewGroup mArtistCG = row1.getChildAt(0) as CheckTextView mGameCG = row1.getChildAt(1) as CheckTextView val row2 = getChildAt(2) as ViewGroup mWestern = row2.getChildAt(0) as CheckTextView mNonH = row2.getChildAt(1) as CheckTextView val row3 = getChildAt(3) as ViewGroup mImageSets = row3.getChildAt(0) as CheckTextView mCosplay = row3.getChildAt(1) as CheckTextView val row4 = getChildAt(4) as ViewGroup mAsianPorn = row4.getChildAt(0) as CheckTextView mMisc = row4.getChildAt(1) as CheckTextView mOptions = arrayOf( mDoujinshi, mManga, mArtistCG, mGameCG, mWestern, mNonH, mImageSets, mCosplay, mAsianPorn, mMisc, ) for (option in mOptions) { option.setOnLongClickListener(this) } } override fun onLongClick(v: View): Boolean { if (v is CheckTextView) { val checked = v.isChecked for (option in mOptions) { if (option !== v) { option.isChecked = !checked } } } return true } var category: Int get() { var category = 0 if (!mDoujinshi.isChecked) category = category or EhUtils.DOUJINSHI if (!mManga.isChecked) category = category or EhUtils.MANGA if (!mArtistCG.isChecked) category = category or EhUtils.ARTIST_CG if (!mGameCG.isChecked) category = category or EhUtils.GAME_CG if (!mWestern.isChecked) category = category or EhUtils.WESTERN if (!mNonH.isChecked) category = category or EhUtils.NON_H if (!mImageSets.isChecked) category = category or EhUtils.IMAGE_SET if (!mCosplay.isChecked) category = category or EhUtils.COSPLAY if (!mAsianPorn.isChecked) category = category or EhUtils.ASIAN_PORN if (!mMisc.isChecked) category = category or EhUtils.MISC return category } set(category) { mDoujinshi.isChecked = !NumberUtils.int2boolean(category and EhUtils.DOUJINSHI) mManga.isChecked = !NumberUtils.int2boolean(category and EhUtils.MANGA) mArtistCG.isChecked = !NumberUtils.int2boolean(category and EhUtils.ARTIST_CG) mGameCG.isChecked = !NumberUtils.int2boolean(category and EhUtils.GAME_CG) mWestern.isChecked = !NumberUtils.int2boolean(category and EhUtils.WESTERN) mNonH.isChecked = !NumberUtils.int2boolean(category and EhUtils.NON_H) mImageSets.isChecked = !NumberUtils.int2boolean(category and EhUtils.IMAGE_SET) mCosplay.isChecked = !NumberUtils.int2boolean(category and EhUtils.COSPLAY) mAsianPorn.isChecked = !NumberUtils.int2boolean(category and EhUtils.ASIAN_PORN) mMisc.isChecked = !NumberUtils.int2boolean(category and EhUtils.MISC) } override fun onSaveInstanceState(): Parcelable { val bundle = Bundle() bundle.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState()) bundle.putInt(STATE_KEY_CATEGORY, category) return bundle } override fun onRestoreInstanceState(state: Parcelable) { if (state is Bundle) { super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER)) category = state.getInt(STATE_KEY_CATEGORY) } } companion object { private const val STATE_KEY_SUPER = "super" private const val STATE_KEY_CATEGORY = "category" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/DialogWebChromeClient.java ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.webkit.JsPromptResult; import android.webkit.JsResult; import android.webkit.WebChromeClient; import android.webkit.WebView; import android.widget.EditText; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; import com.hippo.ehviewer.R; public class DialogWebChromeClient extends WebChromeClient { private final Context context; public DialogWebChromeClient(Context context) { this.context = context; } @Override public boolean onJsAlert(WebView view, String url, String message, JsResult result) { new AlertDialog.Builder(view.getContext()) .setMessage(message) .setPositiveButton(android.R.string.ok, (dialog, which) -> result.confirm()) .setOnCancelListener(dialog -> result.cancel()) .show(); return true; } @Override public boolean onJsConfirm(WebView view, String url, String message, JsResult result) { new AlertDialog.Builder(view.getContext()) .setMessage(message) .setPositiveButton(android.R.string.ok, (dialog, which) -> result.confirm()) .setNegativeButton(android.R.string.cancel, (dialog, which) -> result.cancel()) .setOnCancelListener(dialog -> result.cancel()) .show(); return true; } @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { LayoutInflater inflater = LayoutInflater.from(context); View promptView = inflater.inflate(R.layout.dialog_js_prompt, null, false); TextView messageView = promptView.findViewById(R.id.message); messageView.setText(message); final EditText valueView = promptView.findViewById(R.id.value); valueView.setText(defaultValue); new AlertDialog.Builder(context) .setView(promptView) .setPositiveButton(android.R.string.ok, (dialog, which) -> result.confirm(valueView.getText().toString())) .setOnCancelListener(dialog -> result.cancel()) .show(); return true; } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/EhStageLayout.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.widget import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout.AttachedBehavior import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.google.android.material.snackbar.Snackbar.SnackbarLayout import com.hippo.scene.StageLayout import com.hippo.yorozuya.LayoutUtils import kotlin.math.min class EhStageLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : StageLayout( context, attrs, defStyle, ), AttachedBehavior { private var mAboveSnackViewList: MutableList? = null fun addAboveSnackView(view: View) { if (null == mAboveSnackViewList) { mAboveSnackViewList = ArrayList() } mAboveSnackViewList!!.add(view) } fun removeAboveSnackView(view: View) { if (null == mAboveSnackViewList) { return } mAboveSnackViewList!!.remove(view) } val aboveSnackViewCount: Int get() = if (null == mAboveSnackViewList) 0 else mAboveSnackViewList!!.size fun getAboveSnackViewAt(index: Int): View? = if (null == mAboveSnackViewList || index < 0 || index >= mAboveSnackViewList!!.size) { null } else { mAboveSnackViewList!![index] } override fun getBehavior(): Behavior = Behavior() class Behavior : CoordinatorLayout.Behavior() { @SuppressLint("RestrictedApi") override fun layoutDependsOn( parent: CoordinatorLayout, child: EhStageLayout, dependency: View, ): Boolean = dependency is SnackbarLayout override fun onDependentViewChanged( parent: CoordinatorLayout, child: EhStageLayout, dependency: View, ): Boolean { for (i in 0 until child.aboveSnackViewCount) { val view = child.getAboveSnackViewAt(i) if (view != null) { val translationY = min( 0.0, ( dependency.translationY - dependency.height - LayoutUtils.dp2pix( view.context, 8f, ) ).toDouble(), ).toFloat() view.animate().setInterpolator(FastOutSlowInInterpolator()) .translationY(translationY).setDuration(150).start() } } return false } override fun onDependentViewRemoved( parent: CoordinatorLayout, child: EhStageLayout, dependency: View, ) { for (i in 0 until child.aboveSnackViewCount) { child.getAboveSnackViewAt(i)?.animate()?.setInterpolator(FastOutSlowInInterpolator())?.translationY(0f) ?.setDuration(75)?.start() } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/FixedThumb.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet import androidx.core.content.withStyledAttributes import com.hippo.ehviewer.R import com.hippo.widget.LoadImageView open class FixedThumb @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : LoadImageView(context, attrs, defStyleAttr) { private var minAspect = 0f private var maxAspect = 0f private var alwaysCutAndScale = false init { context.withStyledAttributes(attrs, R.styleable.FixedThumb, defStyleAttr, defStyleAttr) { minAspect = getFloat(R.styleable.FixedThumb_minAspect, 0f) maxAspect = getFloat(R.styleable.FixedThumb_maxAspect, 0f) alwaysCutAndScale = getBoolean(R.styleable.FixedThumb_alwaysCutAndScale, false) } } override fun onPreSetImageDrawable(drawable: Drawable?, isTarget: Boolean) { if (alwaysCutAndScale) { setScaleType(ScaleType.CENTER_CROP) return } if (isTarget && drawable != null) { val width = drawable.intrinsicWidth val height = drawable.intrinsicHeight if (width > 0 && height > 0) { val aspect = width.toFloat() / height.toFloat() if (aspect < maxAspect && aspect > minAspect) { setScaleType(ScaleType.CENTER_CROP) return } } } setScaleType(ScaleType.FIT_CENTER) } override fun onPreSetImageResource(resId: Int, isTarget: Boolean) { setScaleType(ScaleType.FIT_CENTER) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/GalleryGuideView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import com.hippo.ehviewer.R; import com.hippo.ehviewer.Settings; import com.hippo.yorozuya.ViewUtils; import rikka.core.res.ResourcesKt; public class GalleryGuideView extends ViewGroup implements View.OnClickListener { private final float[] mPoints = new float[3 * 4]; private int mBgColor; private Paint mPaint; private int mStep; private TextView mLeftText; private TextView mRightText; private TextView mMenuText; private TextView mProgressText; private TextView mLongClickText; public GalleryGuideView(Context context) { super(context); init(context); } public GalleryGuideView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public GalleryGuideView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context); } private void init(Context context) { mBgColor = ResourcesKt.resolveColor(context.getTheme(), R.attr.guideBackgroundColor); mPaint = new Paint(); mPaint.setColor(ResourcesKt.resolveColor(context.getTheme(), R.attr.guideTitleColor)); mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(context.getResources().getDimension(R.dimen.gallery_guide_divider_width)); setOnClickListener(this); setWillNotDraw(false); bind(); } private void bind() { // Clear up removeAllViews(); mLeftText = null; mRightText = null; mMenuText = null; mProgressText = null; mLongClickText = null; switch (mStep) { case 0: bind1(); break; case 1: default: bind2(); break; } } private void bind1() { LayoutInflater inflater = LayoutInflater.from(getContext()); inflater.inflate(R.layout.widget_gallery_guide_1, this); mLeftText = (TextView) getChildAt(0); mRightText = (TextView) getChildAt(1); mMenuText = (TextView) getChildAt(2); mProgressText = (TextView) getChildAt(3); } private void bind2() { LayoutInflater inflater = LayoutInflater.from(getContext()); inflater.inflate(R.layout.widget_gallery_guide_2, this); mLongClickText = (TextView) getChildAt(0); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthSize = MeasureSpec.getSize(widthMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); if (MeasureSpec.EXACTLY != widthMode || MeasureSpec.EXACTLY != heightMode) { throw new IllegalStateException(); } switch (mStep) { case 0: mLeftText.measure(MeasureSpec.makeMeasureSpec(widthSize / 3, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)); mRightText.measure(MeasureSpec.makeMeasureSpec(widthSize / 3, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)); mMenuText.measure(MeasureSpec.makeMeasureSpec(widthSize / 3, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightSize / 2, MeasureSpec.EXACTLY)); mProgressText.measure(MeasureSpec.makeMeasureSpec(widthSize / 3, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightSize / 2, MeasureSpec.EXACTLY)); break; case 1: default: mLongClickText.measure(MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)); break; } setMeasuredDimension(widthSize, heightSize); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int width = r - l; int height = b - t; switch (mStep) { case 0: mLeftText.layout(0, 0, width / 3, height); mRightText.layout(width * 2 / 3, 0, width, height); mMenuText.layout(width / 3, 0, width * 2 / 3, height / 2); mProgressText.layout(width / 3, height / 2, width * 2 / 3, height); break; case 1: default: mLongClickText.layout(0, 0, width, height); break; } } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (0 == mStep) { mPoints[0] = (float) w / 3; mPoints[1] = 0; mPoints[2] = (float) w / 3; mPoints[3] = h; mPoints[4] = (float) (w * 2) / 3; mPoints[5] = 0; mPoints[6] = (float) (w * 2) / 3; mPoints[7] = h; mPoints[8] = (float) w / 3; mPoints[9] = (float) h / 2; mPoints[10] = (float) (w * 2) / 3; mPoints[11] = (float) h / 2; } } @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); canvas.drawColor(mBgColor); if (0 == mStep) { canvas.drawLines(mPoints, mPaint); } } @Override public void onClick(View v) { switch (mStep) { case 0: mStep++; bind(); break; case 1: default: Settings.INSTANCE.putGuideGallery(false); ViewUtils.removeFromParent(this); break; } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/GalleryHeader.java ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.content.Context; import android.graphics.Rect; import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.core.view.DisplayCutoutCompat; import com.hippo.ehviewer.R; import com.hippo.yorozuya.ObjectUtils; public class GalleryHeader extends ViewGroup { private final Rect batteryRect = new Rect(); private final Rect progressRect = new Rect(); private final Rect clockRect = new Rect(); private final int[] location = new int[2]; private DisplayCutoutCompat displayCutout; private int topInsets = 0; private View battery; private View progress; private View clock; private int lastX = 0; private int lastY = 0; public GalleryHeader(Context context, AttributeSet attrs) { super(context, attrs); } public void setDisplayCutout(@Nullable DisplayCutoutCompat displayCutout) { if (!ObjectUtils.equal(this.displayCutout, displayCutout)) { this.displayCutout = displayCutout; requestLayout(); } } public void setTopInsets(int topInsets) { if (this.topInsets != topInsets) { this.topInsets = topInsets; requestLayout(); } } @Override protected void onFinishInflate() { super.onFinishInflate(); battery = findViewById(R.id.battery); progress = findViewById(R.id.progress); clock = findViewById(R.id.clock); } private void measureChild(Rect rect, View view, int width, int paddingLeft, int paddingRight) { int left; MarginLayoutParams lp = (MarginLayoutParams) view.getLayoutParams(); if (view == battery) { left = paddingLeft + lp.leftMargin; } else if (view == progress) { left = paddingLeft + (width - paddingLeft - paddingRight) / 2 - view.getMeasuredWidth() / 2; } else { left = width - paddingRight - lp.rightMargin - view.getMeasuredWidth(); } rect.set(left, lp.topMargin + topInsets, left + view.getMeasuredWidth(), lp.topMargin + topInsets + view.getMeasuredHeight()); } @RequiresApi(api = Build.VERSION_CODES.P) private boolean offsetVertically(Rect rect, View view, int width) { int offset = 0; measureChild(rect, view, width, 0, 0); rect.offset(lastX, lastY); for (Rect notch : displayCutout.getBoundingRects()) { if (Rect.intersects(notch, rect)) { offset = Math.max(offset, notch.bottom - lastY); } } if (offset != 0) { rect.offset(-lastX, -lastY); rect.offset(0, offset); return true; } else { return false; } } @RequiresApi(api = Build.VERSION_CODES.P) private int getOffsetLeft(Rect rect, View view, int width) { int offset = 0; measureChild(rect, view, width, 0, 0); rect.offset(lastX, lastY); for (Rect notch : displayCutout.getBoundingRects()) { if (Rect.intersects(notch, rect)) { offset = Math.max(offset, notch.right - lastX); } } return offset; } @RequiresApi(api = Build.VERSION_CODES.P) private int getOffsetRight(Rect rect, View view, int width) { int offset = 0; measureChild(rect, view, width, 0, 0); rect.offset(lastX, lastY); for (Rect notch : displayCutout.getBoundingRects()) { if (Rect.intersects(notch, rect)) { offset = Math.max(offset, lastX + width - notch.left); } } return offset; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) { throw new IllegalStateException(); } int width = MeasureSpec.getSize(widthMeasureSpec); int height = 0; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); measureChild(child, widthMeasureSpec, heightMeasureSpec); MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); height = Math.max(height, child.getMeasuredHeight() + lp.topMargin + topInsets); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && displayCutout != null) { // Check progress covered if (offsetVertically(progressRect, progress, width)) { offsetVertically(batteryRect, battery, width); offsetVertically(clockRect, clock, width); height = Math.max(progressRect.bottom, Math.max(batteryRect.bottom, clockRect.bottom)); } else { // Clamp left and right int paddingLeft = getOffsetLeft(batteryRect, battery, width); int paddingRight = getOffsetRight(clockRect, clock, width); measureChild(batteryRect, battery, width, paddingLeft, paddingRight); measureChild(progressRect, progress, width, paddingLeft, paddingRight); measureChild(clockRect, clock, width, paddingLeft, paddingRight); } } else { measureChild(batteryRect, battery, width, 0, 0); measureChild(progressRect, progress, width, 0, 0); measureChild(clockRect, clock, width, 0, 0); } setMeasuredDimension(width, height); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { battery.layout(batteryRect.left, batteryRect.top, batteryRect.right, batteryRect.bottom); progress.layout(progressRect.left, progressRect.top, progressRect.right, progressRect.bottom); clock.layout(clockRect.left, clockRect.top, clockRect.right, clockRect.bottom); getLocationOnScreen(location); if (lastX != location[0] || lastY != location[1]) { lastX = location[0]; lastY = location[1]; requestLayout(); } } @Override public MarginLayoutParams generateLayoutParams(AttributeSet attrs) { return new MarginLayoutParams(getContext(), attrs); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { return p instanceof MarginLayoutParams; } @Override protected MarginLayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { if (lp instanceof MarginLayoutParams) { return new MarginLayoutParams((MarginLayoutParams) lp); } return new MarginLayoutParams(lp); } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/GalleryInfoContentHelper.kt ================================================ /* * Copyright 2019 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.annotation.SuppressLint import android.os.Bundle import android.os.Parcelable import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.FavouriteStatusRouter import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.util.toLocalDateTime import com.hippo.widget.ContentLayout.ContentHelper import com.hippo.yorozuya.IntIdGenerator abstract class GalleryInfoContentHelper : ContentHelper() { var jumpTo: String? = null private val listener: FavouriteStatusRouter.Listener @SuppressLint("UseSparseArrays") private var map: MutableMap = HashMap() init { listener = FavouriteStatusRouter.Listener { gid: Long, slot: Int -> val info = map[gid] if (info != null) { info.favoriteSlot = slot } } EhApplication.favouriteStatusRouter.addListener(listener) } fun destroy() { EhApplication.favouriteStatusRouter.removeListener(listener) } override fun onAddData(data: List) { for (info in data) { info?.let { map[info.gid] = info } } } override fun onRemoveData(data: List) { for (info in data) { info?.let { map.remove(info.gid) } } } override fun onClearData() { map.clear() } override fun saveInstanceState(superState: Parcelable?): Parcelable { val bundle = super.saveInstanceState(superState) as Bundle // TODO It's a bad design val router = EhApplication.favouriteStatusRouter val id = router.saveDataMap(map) bundle.putInt(KEY_DATA_MAP, id) return bundle } override fun restoreInstanceState(state: Parcelable): Parcelable? { val bundle = state as Bundle val id = bundle.getInt(KEY_DATA_MAP, IntIdGenerator.INVALID_ID) if (id != IntIdGenerator.INVALID_ID) { val router = EhApplication.favouriteStatusRouter val map = router.restoreDataMap(id) if (map != null) { this.map = map } } return super.restoreInstanceState(state) } fun goTo(time: Long, isNext: Boolean) { jumpTo = time.toLocalDateTime().date.toString() if (isNext) { goTo(mNext ?: "2", true) } else { goTo(mPrev, false) } jumpTo = null } companion object { private const val KEY_DATA_MAP = "data_map" } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/GalleryRatingBar.java ================================================ /* * Copyright 2014-2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.content.Context; import android.graphics.Canvas; import android.util.AttributeSet; import android.widget.RatingBar; import androidx.appcompat.widget.AppCompatRatingBar; public class GalleryRatingBar extends AppCompatRatingBar implements RatingBar.OnRatingBarChangeListener { private OnUserRateListener mListener; public GalleryRatingBar(Context context) { super(context); init(); } public GalleryRatingBar(Context context, AttributeSet attrs) { super(context, attrs); init(); } public GalleryRatingBar(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } private void init() { setOnRatingBarChangeListener(this); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mListener != null) { mListener.onUserRate(getRating()); } } public void setOnUserRateListener(OnUserRateListener l) { mListener = l; } @Override public void onRatingChanged(RatingBar ratingBar, float rating, boolean fromUser) { if (rating <= 0.0f) { setRating(0.5f); } } public interface OnUserRateListener { void onUserRate(float rating); } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/ImageSearchLayout.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.content.Context import android.graphics.BitmapFactory import android.net.Uri import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import android.view.AbsSavedState import android.view.LayoutInflater import android.view.View import android.widget.ImageView import android.widget.LinearLayout import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.hippo.ehviewer.R import com.hippo.ehviewer.client.data.ListUrlBuilder import com.hippo.ehviewer.client.exception.EhException import com.hippo.unifile.UniFile import com.hippo.unifile.openInputStream import com.hippo.unifile.sha1 import com.hippo.yorozuya.ViewUtils import java.io.FileInputStream class ImageSearchLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : LinearLayout( context, attrs, defStyleAttr, ), View.OnClickListener { private var mPreview: ImageView? = null private var mSelectImage: View? = null private var mHelper: Helper? = null private var mImageUri: Uri? = null init { orientation = VERTICAL setDividerDrawable(ContextCompat.getDrawable(context, R.drawable.spacer_keyline)) setShowDividers(SHOW_DIVIDER_MIDDLE) setClipChildren(false) clipToPadding = false LayoutInflater.from(context).inflate(R.layout.widget_image_search, this) mPreview = ViewUtils.`$$`(this, R.id.preview) as ImageView mSelectImage = ViewUtils.`$$`(this, R.id.select_image) mSelectImage!!.setOnClickListener(this) } fun setHelper(helper: Helper?) { mHelper = helper } override fun onClick(v: View) { if (v === mSelectImage) { if (null != mHelper) { mHelper!!.onSelectImage() } } } fun setImageUri(imageUri: Uri?) { if (null == imageUri) { return } val context = context UniFile.fromUri(context, imageUri)?.openInputStream().use { val bitmap = BitmapFactory.decodeStream(it) ?: return mImageUri = imageUri mPreview!!.setImageBitmap(bitmap) mPreview!!.setVisibility(VISIBLE) } } private fun setImagePath(imagePath: String?) { if (null == imagePath) { return } FileInputStream(imagePath).use { val bitmap = BitmapFactory.decodeStream(it) ?: return mImageUri = imagePath.toUri() mPreview!!.setImageBitmap(bitmap) mPreview!!.setVisibility(VISIBLE) } } fun formatListUrlBuilder(builder: ListUrlBuilder) { if (null == mImageUri) { throw EhException(context.getString(R.string.select_image_first)) } UniFile.fromUri(context, mImageUri!!)?.sha1()?.let { builder.hash = it } } override fun onSaveInstanceState(): Parcelable { val superState = super.onSaveInstanceState() val ss = SavedState(superState) ss.imagePath = mImageUri.toString() return ss } override fun onRestoreInstanceState(state: Parcelable) { val ss = state as SavedState super.onRestoreInstanceState(ss.superState) setImagePath(ss.imagePath) } interface Helper { fun onSelectImage() } private class SavedState : AbsSavedState { var imagePath: String? = null /** * Constructor called from [ImageSearchLayout.onSaveInstanceState] */ constructor(superState: Parcelable?) : super(superState) /** * Constructor called from [.CREATOR] */ private constructor(source: Parcel) : super(source) { imagePath = source.readString() } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) out.writeString(imagePath) } override fun describeContents(): Int = 0 companion object CREATOR : Parcelable.Creator { override fun createFromParcel(`in`: Parcel): SavedState = SavedState(`in`) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/ResizeableFixedThumb.kt ================================================ /* * Copyright 2022 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.ehviewer.widget import android.content.Context import android.util.AttributeSet import com.hippo.ehviewer.Settings class ResizeableFixedThumb @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : FixedThumb(context, attrs) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension(Settings.listThumbSize, Settings.listThumbSize / 2 * 3) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/ReversibleSeekBar.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.util.AttributeSet; import android.view.MotionEvent; import androidx.annotation.NonNull; import androidx.appcompat.widget.AppCompatSeekBar; public class ReversibleSeekBar extends AppCompatSeekBar { private boolean mReverse; public ReversibleSeekBar(Context context) { super(context); } public ReversibleSeekBar(Context context, AttributeSet attrs) { super(context, attrs); } public ReversibleSeekBar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public void setReverse(boolean reverse) { mReverse = reverse; invalidate(); } @Override public void draw(@NonNull Canvas canvas) { boolean reverse = mReverse; int saveCount = 0; if (reverse) { saveCount = canvas.save(); float px = this.getWidth() / 2.0f; float py = this.getHeight() / 2.0f; canvas.scale(-1, 1, px, py); } super.draw(canvas); if (reverse) { canvas.restoreToCount(saveCount); } } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { boolean reverse = mReverse; float x = 0.0f, y = 0.0f; if (reverse) { x = event.getX(); y = event.getY(); event.setLocation(getWidth() - x, y); } boolean result = super.onTouchEvent(event); if (reverse) { event.setLocation(x, y); } return result; } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/SearchBar.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.animation.Animator import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.TextView import androidx.annotation.Keep import androidx.core.graphics.withSave import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.card.MaterialCardView import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.LinearDividerItemDecoration import com.hippo.ehviewer.EhApplication.Companion.searchDatabase import com.hippo.ehviewer.R import com.hippo.ehviewer.Settings import com.hippo.ehviewer.client.EhTagDatabase import com.hippo.util.getParcelableCompat import com.hippo.util.launchIO import com.hippo.util.withUIContext import com.hippo.view.ViewTransition import com.hippo.yorozuya.AnimationUtils import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.SimpleAnimatorListener import com.hippo.yorozuya.ViewUtils import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import rikka.core.res.resolveColor class SearchBar @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : MaterialCardView(context, attrs), View.OnClickListener, TextView.OnEditorActionListener, TextWatcher, SearchEditText.SearchEditTextListener { private val mRect = Rect() private var mState = STATE_NORMAL private var mWidth = 0 private var mHeight = 0 private var mProgress = 0f private var mMenuButton: ImageView private var mTitleTextView: TextView private var mActionButton: ImageView private var mEditText: SearchEditText private var mListContainer: View private var mListView: EasyRecyclerView private var mListHeader: View private var mViewTransition: ViewTransition private var mSuggestionAdapter: SuggestionAdapter private var mSuggestionList = listOf() private val suggestionLock = Mutex() private var mAllowEmptySearch = true private var mInAnimation = false private var mHelper: Helper? = null private var mSuggestionProvider: SuggestionProvider? = null private var mOnStateChangeListener: OnStateChangeListener? = null init { val inflater = LayoutInflater.from(context) inflater.inflate(R.layout.widget_search_bar, this) mMenuButton = ViewUtils.`$$`(this, R.id.search_menu) as ImageView mTitleTextView = ViewUtils.`$$`(this, R.id.search_title) as TextView mActionButton = ViewUtils.`$$`(this, R.id.search_action) as ImageView mEditText = ViewUtils.`$$`(this, R.id.search_edit_text) as SearchEditText mListContainer = ViewUtils.`$$`(this, R.id.list_container) mListView = ViewUtils.`$$`(mListContainer, R.id.search_bar_list) as EasyRecyclerView mListHeader = ViewUtils.`$$`(mListContainer, R.id.list_header) mViewTransition = ViewTransition(mTitleTextView, mEditText) mMenuButton.setOnClickListener(this) mTitleTextView.setOnClickListener(this) mActionButton.setOnClickListener(this) mEditText.setSearchEditTextListener(this) mEditText.setOnEditorActionListener(this) mEditText.addTextChangedListener(this) // Get base height ViewUtils.measureView( this, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, ) mSuggestionAdapter = SuggestionAdapter(inflater) mListView.adapter = mSuggestionAdapter val decoration = LinearDividerItemDecoration( LinearDividerItemDecoration.VERTICAL, context.theme.resolveColor(R.attr.dividerColor), LayoutUtils.dp2pix(context, 1f), ) decoration.setShowLastDivider(false) mListView.addItemDecoration(decoration) mListView.layoutManager = LinearLayoutManager(context) } private fun addListHeader() { mListHeader.visibility = VISIBLE } private fun removeListHeader() { mListHeader.visibility = GONE } @SuppressLint("NotifyDataSetChanged") @OptIn(DelicateCoroutinesApi::class) private fun updateSuggestions(scrollToTop: Boolean = true) { launchIO { suggestionLock.withLock { mSuggestionList = mergedSuggestionFlow().toList() withUIContext { if (mSuggestionList.isEmpty()) { removeListHeader() } else { addListHeader() } mSuggestionAdapter.notifyDataSetChanged() } } } if (scrollToTop) { mListView.scrollToPosition(0) } } private fun mergedSuggestionFlow(): Flow = flow { val text = mEditText.text.toString() mSuggestionProvider?.run { providerSuggestions(text)?.forEach { emit(it) } } searchDatabase.getSuggestions(text, 128).forEach { emit(KeywordSuggestion(it)) } EhTagDatabase.takeIf { it.isInitialized() }?.run { if (text.isNotEmpty() && !text.endsWith(" ")) { val keyword = text.substringAfterLast(" ") val translate = Settings.showTagTranslations && isTranslatable(context) arrayOf(TYPE_EQUAL, TYPE_START, TYPE_CONTAIN).forEach { type -> suggestFlow(keyword, translate, type).collect { emit(TagSuggestion(it.first, it.second)) } } } } } fun setAllowEmptySearch(allowEmptySearch: Boolean) { mAllowEmptySearch = allowEmptySearch } fun setEditTextHint(hint: CharSequence) { mEditText.hint = hint } fun setHelper(helper: Helper) { mHelper = helper } fun setOnStateChangeListener(listener: OnStateChangeListener) { mOnStateChangeListener = listener } fun setSuggestionProvider(suggestionProvider: SuggestionProvider) { mSuggestionProvider = suggestionProvider } fun setText(text: String) { mEditText.setText(text) } fun cursorToEnd() { mEditText.setSelection(mEditText.length()) } fun setTitle(title: String) { mTitleTextView.text = title } fun setLeftDrawable(drawable: Drawable) { mMenuButton.setImageDrawable(drawable) } fun setRightDrawable(drawable: Drawable) { mActionButton.setImageDrawable(drawable) } fun applySearch() { val query = mEditText.text.toString().replace(Regex("\\p{Cntrl}"), "").trim { it <= ' ' } if (!mAllowEmptySearch && query.isEmpty()) { return } // Put it into db searchDatabase.addQuery(query) // Callback mHelper?.onApplySearch(query) } override fun onClick(v: View) { if (v === mTitleTextView) { mHelper?.onClickTitle() } else if (v === mMenuButton) { mHelper?.onClickLeftIcon() } else if (v === mActionButton) { mHelper?.onClickRightIcon() } } override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean { if (v === mEditText) { if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_NULL) { applySearch() return true } } return false } fun getState(): Int = mState fun setState(state: Int, animation: Boolean = true) { if (mState != state) { val oldState = mState mState = state when (oldState) { STATE_NORMAL -> { mViewTransition.showView(1, animation) mEditText.requestFocus() if (state == STATE_SEARCH_LIST) { showImeAndSuggestionsList(animation) } mOnStateChangeListener?.onStateChange(this, state, oldState, animation) } STATE_SEARCH -> { if (state == STATE_NORMAL) { mViewTransition.showView(0, animation) } else if (state == STATE_SEARCH_LIST) { showImeAndSuggestionsList(animation) } mOnStateChangeListener?.onStateChange(this, state, oldState, animation) } STATE_SEARCH_LIST -> { hideImeAndSuggestionsList(animation) if (state == STATE_NORMAL) { mViewTransition.showView(0, animation) } mOnStateChangeListener?.onStateChange(this, state, oldState, animation) } } } } @Keep private fun showImeAndSuggestionsList(animation: Boolean) { // Show ime val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(mEditText, 0) // update suggestion for show suggestions list updateSuggestions() // Show suggestions list if (animation) { val oa = ObjectAnimator.ofFloat(this, "progress", 1f) oa.duration = ANIMATE_TIME oa.interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR oa.addListener(object : SimpleAnimatorListener() { override fun onAnimationStart(animation: Animator) { mListContainer.visibility = VISIBLE mInAnimation = true } override fun onAnimationEnd(animation: Animator) { mInAnimation = false } }) oa.setAutoCancel(true) oa.start() } else { mListContainer.visibility = VISIBLE progress = 1f } } private fun hideImeAndSuggestionsList(animation: Boolean) { // Hide ime val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(windowToken, 0) // Hide suggestions list if (animation) { val oa = ObjectAnimator.ofFloat(this, "progress", 0f) oa.duration = ANIMATE_TIME oa.interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR oa.addListener(object : SimpleAnimatorListener() { override fun onAnimationStart(animation: Animator) { mInAnimation = true } override fun onAnimationEnd(animation: Animator) { mListContainer.visibility = GONE mInAnimation = false } }) oa.setAutoCancel(true) oa.start() } else { progress = 0f mListContainer.visibility = GONE } } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) if (mListContainer.isVisible && (!mInAnimation || mHeight == 0)) { mWidth = right - left mHeight = bottom - top } } override fun getProgress(): Float = mProgress @Keep override fun setProgress(progress: Float) { mProgress = progress invalidate() } fun getEditText(): SearchEditText = mEditText override fun draw(canvas: Canvas) { if (mInAnimation && mHeight != 0) { canvas.withSave { val bottom = MathUtils.lerp(measuredHeight, mHeight, mProgress) mRect.set(0, 0, mWidth, bottom) setClipBounds(mRect) canvas.clipRect(mRect) super.draw(canvas) } } else { clipBounds = null super.draw(canvas) } } override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { // Empty } override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { // Empty } override fun afterTextChanged(s: Editable) { updateSuggestions() } override fun onClick() { mHelper?.onSearchEditTextClick() } override fun onBackPressed() { mHelper?.onSearchEditTextBackPressed() } override fun onReceiveContent(uri: Uri?) { mHelper?.onReceiveContent(uri) } override fun onSaveInstanceState(): Parcelable { val state = Bundle() state.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState()) state.putInt(STATE_KEY_STATE, mState) return state } override fun onRestoreInstanceState(state: Parcelable) { if (state is Bundle) { super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER)) setState(state.getInt(STATE_KEY_STATE), false) } } private fun wrapTagKeyword(keyword: String): String = if (keyword.endsWith(':')) { keyword } else if (keyword.contains(" ")) { val tag = keyword.substringAfter(':') val prefix = keyword.dropLast(tag.length) "$prefix\"$tag$\" " } else { "$keyword$ " } interface Helper { fun onClickTitle() fun onClickLeftIcon() fun onClickRightIcon() fun onSearchEditTextClick() fun onApplySearch(query: String) fun onSearchEditTextBackPressed() fun onReceiveContent(uri: Uri?) } interface OnStateChangeListener { fun onStateChange(searchBar: SearchBar, newState: Int, oldState: Int, animation: Boolean) } interface SuggestionProvider { fun providerSuggestions(text: String): List? } abstract class Suggestion { abstract fun getText(textView: TextView): CharSequence? abstract fun onClick() open fun onLongClick(): Boolean = false } private class SuggestionHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val text1: TextView = itemView.findViewById(android.R.id.text1) val text2: TextView = itemView.findViewById(android.R.id.text2) } private inner class SuggestionAdapter( private val mInflater: LayoutInflater, ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionHolder = SuggestionHolder(mInflater.inflate(R.layout.item_simple_list_2, parent, false)) override fun onBindViewHolder(holder: SuggestionHolder, position: Int) { val suggestion = mSuggestionList[position] val text1 = suggestion.getText(holder.text1) val text2 = suggestion.getText(holder.text2) holder.text1.text = text1 if (text2 == null) { holder.text2.visibility = GONE holder.text2.text = "" } else { holder.text2.visibility = VISIBLE holder.text2.text = text2 } holder.itemView.setOnClickListener { mSuggestionList.run { if (position < size) { this[position].onClick() } } } holder.itemView.setOnLongClickListener { mSuggestionList.run { if (position < size) { return@setOnLongClickListener this[position].onLongClick() } } return@setOnLongClickListener false } } override fun getItemId(position: Int): Long = position.toLong() override fun getItemCount(): Int = mSuggestionList.size } inner class TagSuggestion( private var mHint: String?, private var mKeyword: String, ) : Suggestion() { override fun getText(textView: TextView): CharSequence? = if (textView.id == android.R.id.text1) { mKeyword } else { mHint } override fun onClick() { val editable = mEditText.text as Editable val keywords = editable.toString().substringBeforeLast(" ", "") val keyword = wrapTagKeyword(mKeyword) val newKeywords = if (keywords.isNotEmpty()) "$keywords $keyword" else keyword mEditText.setText(newKeywords) mEditText.setSelection(newKeywords.length) } } inner class KeywordSuggestion( private val mKeyword: String, ) : Suggestion() { override fun getText(textView: TextView): CharSequence? = if (textView.id == android.R.id.text1) { mKeyword } else { null } override fun onClick() { mEditText.setText(mKeyword) mEditText.setSelection(mEditText.length()) } override fun onLongClick(): Boolean { searchDatabase.deleteQuery(mKeyword) updateSuggestions(false) return true } } companion object { const val STATE_NORMAL = 0 const val STATE_SEARCH = 1 const val STATE_SEARCH_LIST = 2 private const val STATE_KEY_SUPER = "super" private const val STATE_KEY_STATE = "state" private const val ANIMATE_TIME = 300L } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/SearchDatabase.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.text.TextUtils; import android.util.Log; import com.hippo.util.SqlUtils; import java.util.LinkedList; import java.util.List; public final class SearchDatabase { public static final String COLUMN_QUERY = "query"; public static final String COLUMN_DATE = "date"; private static final String TAG = SearchDatabase.class.getSimpleName(); private static final String DATABASE_NAME = "search_database.db"; private static final String TABLE_SUGGESTIONS = "suggestions"; private static final int MAX_HISTORY = 100; private static SearchDatabase sInstance; private final SQLiteDatabase mDatabase; @SuppressWarnings("resource") private SearchDatabase(Context context) { DatabaseHelper databaseHelper = new DatabaseHelper(context); mDatabase = databaseHelper.getWritableDatabase(); } public static SearchDatabase getInstance(Context context) { if (sInstance == null) { sInstance = new SearchDatabase(context.getApplicationContext()); } return sInstance; } public String[] getSuggestions(String prefix, int limit) { List queryList = new LinkedList<>(); limit = Math.max(0, limit); StringBuilder sb = new StringBuilder(); sb.append("SELECT * FROM ").append(TABLE_SUGGESTIONS); if (!TextUtils.isEmpty(prefix)) { sb.append(" WHERE ").append(COLUMN_QUERY).append(" LIKE '") .append(SqlUtils.sqlEscapeString(prefix)).append("%'"); } sb.append(" ORDER BY ").append(COLUMN_DATE).append(" DESC") .append(" LIMIT ").append(limit); try { Cursor cursor = mDatabase.rawQuery(sb.toString(), null); int queryIndex = cursor.getColumnIndex(COLUMN_QUERY); if (cursor.moveToFirst()) { while (!cursor.isAfterLast()) { String suggestion = cursor.getString(queryIndex); if (!prefix.equals(suggestion)) { queryList.add(suggestion); } cursor.moveToNext(); } } cursor.close(); return queryList.toArray(new String[0]); } catch (SQLException e) { return new String[0]; } } public void addQuery(final String query) { if (!TextUtils.isEmpty(query)) { // Delete old first deleteQuery(query); // Add it to database ContentValues values = new ContentValues(); values.put(COLUMN_QUERY, query); values.put(COLUMN_DATE, System.currentTimeMillis()); mDatabase.insert(TABLE_SUGGESTIONS, null, values); // Remove history if more than max truncateHistory(MAX_HISTORY); } } public void deleteQuery(final String query) { mDatabase.delete(TABLE_SUGGESTIONS, COLUMN_QUERY + "=?", new String[]{query}); } public void clearQuery() { truncateHistory(0); } /** * Reduces the length of the history table, to prevent it from growing too large. * * @param maxEntries Max entries to leave in the table. 0 means remove all entries. */ private void truncateHistory(int maxEntries) { if (maxEntries < 0) { throw new IllegalArgumentException(); } try { // null means "delete all". otherwise "delete but leave n newest" String selection = null; if (maxEntries > 0) { selection = "_id IN " + "(SELECT _id FROM " + TABLE_SUGGESTIONS + " ORDER BY " + COLUMN_DATE + " DESC" + " LIMIT -1 OFFSET " + maxEntries + ")"; } mDatabase.delete(TABLE_SUGGESTIONS, selection, null); } catch (RuntimeException e) { Log.e(TAG, "truncateHistory", e); } } /** * Builds the database. This version has extra support for using the version field * as a mode flags field, and configures the database columns depending on the mode bits * (features) requested by the extending class. */ private static class DatabaseHelper extends SQLiteOpenHelper { public DatabaseHelper(Context context) { super(context, DATABASE_NAME, null, 1); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE " + TABLE_SUGGESTIONS + " (" + "_id INTEGER PRIMARY KEY" + ", `" + COLUMN_QUERY + "` TEXT" + "," + COLUMN_DATE + " LONG" + ");"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TABLE_SUGGESTIONS); onCreate(db); } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/SearchEditText.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.annotation.SuppressLint; import android.content.ClipData; import android.content.Context; import android.net.Uri; import android.os.Build; import android.util.AttributeSet; import android.view.ContentInfo; import android.view.KeyEvent; import android.view.MotionEvent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.widget.AppCompatEditText; import com.hippo.util.ExceptionUtils; public class SearchEditText extends AppCompatEditText { private SearchEditTextListener mListener; public SearchEditText(Context context) { super(context); } public SearchEditText(Context context, AttributeSet attrs) { super(context, attrs); } public SearchEditText(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public void setSearchEditTextListener(SearchEditTextListener listener) { mListener = listener; } @Override public boolean onKeyPreIme(int keyCode, @NonNull KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { // special case for the back key, we do not even try to send it // to the drop down list but instead, consume it immediately if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { KeyEvent.DispatcherState state = getKeyDispatcherState(); if (state != null) { state.startTracking(event, this); } return true; } else if (event.getAction() == KeyEvent.ACTION_UP) { KeyEvent.DispatcherState state = getKeyDispatcherState(); if (state != null) { state.handleUpEvent(event); } if (event.isTracking() && !event.isCanceled()) { // TODO stopSelectionActionMode if (mListener != null) { mListener.onBackPressed(); return true; } } } } return super.onKeyPreIme(keyCode, event); } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(@NonNull MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_UP && mListener != null) { mListener.onClick(); } try { return super.onTouchEvent(event); } catch (Throwable t) { // Some devices crash here. // I don't why. ExceptionUtils.INSTANCE.throwIfFatal(t); return false; } } @RequiresApi(api = Build.VERSION_CODES.S) @Nullable @Override public ContentInfo onReceiveContent(@NonNull ContentInfo payload) { ClipData clipData = payload.getClip(); if (clipData.getItemCount() == 1) { if (mListener != null) { mListener.onReceiveContent(clipData.getItemAt(0).getUri()); } } return super.onReceiveContent(payload); } public interface SearchEditTextListener { void onClick(); void onBackPressed(); void onReceiveContent(Uri uri); } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/SearchLayout.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.util.AttributeSet import android.util.SparseArray import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.CompoundButton import android.widget.FrameLayout import android.widget.ImageView import android.widget.Switch import android.widget.TextView import androidx.annotation.IntDef import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.MarginItemDecoration import com.hippo.easyrecyclerview.SimpleHolder import com.hippo.ehviewer.GetText.getString import com.hippo.ehviewer.R import com.hippo.ehviewer.client.data.ListUrlBuilder import com.hippo.ehviewer.client.exception.EhException import com.hippo.util.getParcelableCompat import com.hippo.widget.RadioGridGroup import com.hippo.yorozuya.ViewUtils @SuppressLint("InflateParams") class SearchLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : EasyRecyclerView( context, attrs, defStyle, ), CompoundButton.OnCheckedChangeListener, View.OnClickListener, ImageSearchLayout.Helper, OnTabSelectedListener { private var mInflater: LayoutInflater? = null @SearchMode private var mSearchMode = SEARCH_MODE_NORMAL private var mEnableAdvance = false private var mNormalView: View? = null private var mCategoryTable: CategoryTable? = null private var mNormalSearchMode: RadioGridGroup? = null private var mNormalSearchModeHelp: ImageView? = null @SuppressLint("UseSwitchCompatOrMaterialCode") private var mEnableAdvanceSwitch: Switch? = null private var mAdvanceView: View? = null private var mTableAdvanceSearch: AdvanceSearchTable? = null private var mImageView: ImageSearchLayout? = null private var mActionView: View? = null private var mAction: TabLayout? = null private var mLayoutManager: LinearLayoutManager? = null private var mAdapter: SearchAdapter? = null private var mHelper: Helper? = null init { val resources = context.resources mInflater = LayoutInflater.from(context) mLayoutManager = SearchLayoutManager(context) mAdapter = SearchAdapter() mAdapter!!.setHasStableIds(true) setLayoutManager(mLayoutManager) setAdapter(mAdapter) setHasFixedSize(true) setClipToPadding(false) (itemAnimator as DefaultItemAnimator?)!!.supportsChangeAnimations = false val interval = resources.getDimensionPixelOffset(R.dimen.search_layout_interval) val paddingH = resources.getDimensionPixelOffset(R.dimen.search_layout_margin_h) val paddingV = resources.getDimensionPixelOffset(R.dimen.search_layout_margin_v) val decoration = MarginItemDecoration( interval, paddingH, paddingV, paddingH, paddingV, ) addItemDecoration(decoration) decoration.applyPaddings(this) // Create normal view val normalView = mInflater!!.inflate(R.layout.search_normal, null) mNormalView = normalView mCategoryTable = normalView.findViewById(R.id.search_category_table) mNormalSearchMode = normalView.findViewById(R.id.normal_search_mode) mNormalSearchModeHelp = normalView.findViewById(R.id.normal_search_mode_help) mEnableAdvanceSwitch = normalView.findViewById(R.id.search_enable_advance) mNormalSearchModeHelp!!.setOnClickListener(this) mEnableAdvanceSwitch!!.setOnCheckedChangeListener(this) mEnableAdvanceSwitch!!.setSwitchPadding(resources.getDimensionPixelSize(R.dimen.switch_padding)) // Create advance view mAdvanceView = mInflater!!.inflate(R.layout.search_advance, null) mTableAdvanceSearch = mAdvanceView!!.findViewById(R.id.search_advance_search_table) // Create image view mImageView = mInflater!!.inflate(R.layout.search_image, null) as ImageSearchLayout mImageView!!.setHelper(this) // Create action view mActionView = mInflater!!.inflate(R.layout.search_action, null) mActionView!!.setLayoutParams( LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, ), ) mAction = mActionView!!.findViewById(R.id.action) mAction!!.addOnTabSelectedListener(this) } fun setHelper(helper: Helper?) { mHelper = helper } fun scrollSearchContainerToTop() { mLayoutManager!!.scrollToPositionWithOffset(0, 0) } fun setImageUri(imageUri: Uri?) { mImageView!!.setImageUri(imageUri) } fun setNormalSearchMode(id: Int) { mNormalSearchMode!!.check(id) } override fun onSelectImage() { if (mHelper != null) { mHelper!!.onSelectImage() } } override fun dispatchSaveInstanceState(container: SparseArray) { super.dispatchSaveInstanceState(container) mNormalView!!.saveHierarchyState(container) mAdvanceView!!.saveHierarchyState(container) mImageView!!.saveHierarchyState(container) mActionView!!.saveHierarchyState(container) } override fun dispatchRestoreInstanceState(container: SparseArray) { super.dispatchRestoreInstanceState(container) mNormalView!!.restoreHierarchyState(container) mAdvanceView!!.restoreHierarchyState(container) mImageView!!.restoreHierarchyState(container) mActionView!!.restoreHierarchyState(container) } override fun onSaveInstanceState(): Parcelable { val state = Bundle() state.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState()) state.putInt(STATE_KEY_SEARCH_MODE, mSearchMode) state.putBoolean(STATE_KEY_ENABLE_ADVANCE, mEnableAdvance) return state } override fun onRestoreInstanceState(state: Parcelable) { if (state is Bundle) { super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER)!!) mSearchMode = state.getInt(STATE_KEY_SEARCH_MODE) mEnableAdvance = state.getBoolean(STATE_KEY_ENABLE_ADVANCE) } } override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { if (buttonView === mEnableAdvanceSwitch) { post { mEnableAdvance = isChecked if (mSearchMode == SEARCH_MODE_NORMAL) { if (mEnableAdvance) { mAdapter!!.notifyItemInserted(1) } else { mAdapter!!.notifyItemRemoved(1) } if (mHelper != null) { mHelper!!.onChangeSearchMode() } } } } } fun formatListUrlBuilder(urlBuilder: ListUrlBuilder, query: String?) { urlBuilder.reset() when (mSearchMode) { SEARCH_MODE_NORMAL -> { val nsMode = mNormalSearchMode!!.checkedRadioButtonId when (nsMode) { R.id.search_subscription_search -> { urlBuilder.mode = ListUrlBuilder.MODE_SUBSCRIPTION } R.id.search_specify_uploader -> { urlBuilder.mode = ListUrlBuilder.MODE_UPLOADER } R.id.search_specify_tag -> { urlBuilder.mode = ListUrlBuilder.MODE_TAG } else -> { urlBuilder.mode = ListUrlBuilder.MODE_NORMAL } } urlBuilder.keyword = query urlBuilder.category = mCategoryTable!!.category if (mEnableAdvance) { urlBuilder.advanceSearch = mTableAdvanceSearch!!.advanceSearch urlBuilder.minRating = mTableAdvanceSearch!!.minRating val pageFrom = mTableAdvanceSearch!!.pageFrom val pageTo = mTableAdvanceSearch!!.pageTo if (pageFrom != -1 && pageFrom > 1000) { throw EhException(getString(R.string.search_sp_err0)) } else if (pageTo != -1 && pageTo < 10) { throw EhException(getString(R.string.search_sp_err1)) } else if (pageFrom != -1 && pageTo != -1 && pageTo - pageFrom < 20) { throw EhException(getString(R.string.search_sp_err2)) } else if (pageFrom != -1 && pageTo != -1 && pageFrom.toFloat() / pageTo > 0.5) { throw EhException(getString(R.string.search_sp_err3)) } urlBuilder.pageFrom = pageFrom urlBuilder.pageTo = pageTo } } SEARCH_MODE_IMAGE -> { urlBuilder.mode = ListUrlBuilder.MODE_IMAGE_SEARCH mImageView!!.formatListUrlBuilder(urlBuilder) } } } override fun onClick(v: View) { if (mNormalSearchModeHelp === v) { AlertDialog.Builder(context) .setMessage(R.string.search_tip) .show() } } override fun onTabSelected(tab: TabLayout.Tab) { post { setSearchMode(tab.position) } } fun setSearchMode(@SearchMode mode: Int) { val oldItemCount = mAdapter!!.getItemCount() mSearchMode = mode val newItemCount = mAdapter!!.getItemCount() mAdapter!!.notifyItemRangeRemoved(0, oldItemCount - 1) mAdapter!!.notifyItemRangeInserted(0, newItemCount - 1) if (mHelper != null) { mHelper!!.onChangeSearchMode() } } override fun onTabUnselected(tab: TabLayout.Tab) {} override fun onTabReselected(tab: TabLayout.Tab) {} @IntDef(SEARCH_MODE_NORMAL, SEARCH_MODE_IMAGE) @Retention(AnnotationRetention.SOURCE) private annotation class SearchMode interface Helper { fun onChangeSearchMode() fun onSelectImage() } internal class SearchLayoutManager(context: Context?) : LinearLayoutManager(context) { override fun onLayoutChildren(recycler: Recycler, state: State) { try { super.onLayoutChildren(recycler, state) } catch (e: IndexOutOfBoundsException) { e.printStackTrace() } } } private inner class SearchAdapter : Adapter() { override fun getItemCount(): Int { var count = SEARCH_ITEM_COUNT_ARRAY[mSearchMode] if (mSearchMode == SEARCH_MODE_NORMAL && !mEnableAdvance) { count-- } return count } override fun getItemViewType(position: Int): Int { var type = SEARCH_ITEM_TYPE[mSearchMode][position] if (mSearchMode == SEARCH_MODE_NORMAL && position == 1 && !mEnableAdvance) { type = ITEM_TYPE_ACTION } return type } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SimpleHolder { val view: View? if (viewType == ITEM_TYPE_ACTION) { ViewUtils.removeFromParent(mActionView) view = mActionView } else { view = mInflater!!.inflate(R.layout.search_category, parent, false) val title = view.findViewById(R.id.category_title) val content = view.findViewById(R.id.category_content) when (viewType) { ITEM_TYPE_NORMAL -> { title.setText(R.string.search_normal) ViewUtils.removeFromParent(mNormalView) content.addView(mNormalView) } ITEM_TYPE_NORMAL_ADVANCE -> { title.setText(R.string.search_advance) ViewUtils.removeFromParent(mAdvanceView) content.addView(mAdvanceView) } ITEM_TYPE_IMAGE -> { title.setText(R.string.search_image) ViewUtils.removeFromParent(mImageView) content.addView(mImageView) } } } return SimpleHolder(view!!) } override fun onBindViewHolder(holder: SimpleHolder, position: Int) { if (holder.itemViewType == ITEM_TYPE_ACTION) { mAction!!.selectTab(mAction!!.getTabAt(mSearchMode)) } } override fun getItemId(position: Int): Long { var type = SEARCH_ITEM_TYPE[mSearchMode][position] if (mSearchMode == SEARCH_MODE_NORMAL && position == 1 && !mEnableAdvance) { type = ITEM_TYPE_ACTION } return type.toLong() } } companion object { const val SEARCH_MODE_NORMAL = 0 const val SEARCH_MODE_IMAGE = 1 private const val STATE_KEY_SUPER = "super" private const val STATE_KEY_SEARCH_MODE = "search_mode" private const val STATE_KEY_ENABLE_ADVANCE = "enable_advance" private const val ITEM_TYPE_NORMAL = 0 private const val ITEM_TYPE_NORMAL_ADVANCE = 1 private const val ITEM_TYPE_IMAGE = 2 private const val ITEM_TYPE_ACTION = 3 private val SEARCH_ITEM_COUNT_ARRAY = intArrayOf( 3, 2, ) private val SEARCH_ITEM_TYPE = arrayOf( intArrayOf(ITEM_TYPE_NORMAL, ITEM_TYPE_NORMAL_ADVANCE, ITEM_TYPE_ACTION), intArrayOf( ITEM_TYPE_IMAGE, ITEM_TYPE_ACTION, ), ) } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/SeekBarPanel.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import android.widget.LinearLayout import android.widget.SeekBar import com.hippo.ehviewer.R import com.hippo.yorozuya.ViewUtils class SeekBarPanel @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : LinearLayout( context, attrs, defStyle, ) { private val mLocation = IntArray(2) private var mSeekBar: SeekBar? = null init { post { val rootWindowInsets = getRootWindowInsets() if (rootWindowInsets != null) { @Suppress("DEPRECATION") setPadding(0, 0, 0, rootWindowInsets.systemWindowInsetBottom) } } } override fun onFinishInflate() { super.onFinishInflate() mSeekBar = ViewUtils.`$$`(this, R.id.seek_bar) as SeekBar } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (mSeekBar == null) { return super.onTouchEvent(event) } else { ViewUtils.getLocationInAncestor(mSeekBar, mLocation, this) val offsetX = -mLocation[0].toFloat() val offsetY = -mLocation[1].toFloat() event.offsetLocation(offsetX, offsetY) mSeekBar!!.onTouchEvent(event) event.offsetLocation(-offsetX, -offsetY) return true } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/SimpleRatingView.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.View; import androidx.core.content.ContextCompat; import com.hippo.ehviewer.R; import com.hippo.yorozuya.MathUtils; /** * 5 stars, from 0 to 10 */ public class SimpleRatingView extends View { private Drawable mStarDrawable; private Drawable mStarHalfDrawable; private Drawable mStarOutlineDrawable; private int mRatingSize; private int mRatingInterval; private float mRating; private int mRatingInt; public SimpleRatingView(Context context) { super(context); init(context); } public SimpleRatingView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public SimpleRatingView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context); } private void init(Context context) { Resources resources = context.getResources(); mStarDrawable = ContextCompat.getDrawable(context, R.drawable.v_star_x16); mStarHalfDrawable = ContextCompat.getDrawable(context, R.drawable.v_star_half_x16); mStarOutlineDrawable = ContextCompat.getDrawable(context, R.drawable.v_star_outline_x16); mRatingSize = resources.getDimensionPixelOffset(R.dimen.rating_size); mRatingInterval = resources.getDimensionPixelOffset(R.dimen.rating_interval); mStarDrawable.setBounds(0, 0, mRatingSize, mRatingSize); mStarHalfDrawable.setBounds(0, 0, mRatingSize, mRatingSize); mStarOutlineDrawable.setBounds(0, 0, mRatingSize, mRatingSize); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(mRatingSize * 5 + mRatingInterval * 4, mRatingSize); } @Override protected void onDraw(Canvas canvas) { int ratingInt = mRatingInt; int step = mRatingSize + mRatingInterval; int numStar = ratingInt / 2; int numStarHalf = ratingInt % 2; int saved = canvas.save(); while (numStar-- > 0) { mStarDrawable.draw(canvas); canvas.translate(step, 0); } if (numStarHalf == 1) { mStarHalfDrawable.draw(canvas); canvas.translate(step, 0); } int numOutline = 5 - numStar - numStarHalf; while (numOutline-- > 0) { mStarOutlineDrawable.draw(canvas); canvas.translate(step, 0); } canvas.restoreToCount(saved); } public float getRating() { return mRating; } public void setRating(float rating) { if (mRating != rating) { mRating = rating; int ratingInt = MathUtils.clamp((int) Math.ceil(rating * 2), 0, 10); if (mRatingInt != ratingInt) { mRatingInt = ratingInt; invalidate(); } } } } ================================================ FILE: app/src/main/java/com/hippo/ehviewer/widget/TileThumb.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.ehviewer.widget; import android.content.Context; import android.util.AttributeSet; import com.hippo.widget.LoadImageView; import com.hippo.yorozuya.MathUtils; public class TileThumb extends LoadImageView { private static final float MIN_ASPECT = 0.33f; private static final float MAX_ASPECT = 1.5f; private static final float DEFAULT_ASPECT = 0.67f; public TileThumb(Context context) { super(context); } public TileThumb(Context context, AttributeSet attrs) { super(context, attrs); } public TileThumb(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public void setThumbSize(int thumbWidth, int thumbHeight) { float aspect; if (thumbWidth > 0 && thumbHeight > 0) { aspect = MathUtils.clamp(thumbWidth / (float) thumbHeight, MIN_ASPECT, MAX_ASPECT); } else { aspect = DEFAULT_ASPECT; } setAspect(aspect); } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/DownUpDetector.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import android.view.MotionEvent; class DownUpDetector { private final DownUpListener mListener; private boolean mStillDown; public DownUpDetector(DownUpListener listener) { mListener = listener; } private void setState(boolean down, MotionEvent e) { if (down == mStillDown) return; mStillDown = down; if (down) { mListener.onDown(e); } else { mListener.onUp(e); } } public void onTouchEvent(MotionEvent ev) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN -> setState(true, ev); case MotionEvent.ACTION_POINTER_DOWN -> mListener.onPointerDown(ev); case MotionEvent.ACTION_UP -> { mListener.onPointerUp(ev); setState(false, ev); } case MotionEvent.ACTION_CANCEL -> setState(false, ev); } } public boolean isDown() { return mStillDown; } public interface DownUpListener { void onDown(MotionEvent e); void onUp(MotionEvent e); void onPointerDown(MotionEvent e); void onPointerUp(MotionEvent e); } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/Fling.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import android.content.Context; import android.hardware.SensorManager; import android.view.ViewConfiguration; import android.view.animation.Interpolator; import com.hippo.glview.anim.Animation; abstract class Fling extends Animation { private static final float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9)); private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1) private static final float START_TENSION = 0.5f; private static final float END_TENSION = 1.0f; private static final float P1 = START_TENSION * INFLEXION; private static final float P2 = 1.0f - END_TENSION * (1.0f - INFLEXION); private static final float FLING_FRICTION = ViewConfiguration.getScrollFriction(); private static final int NB_SAMPLES = 100; private static final float[] SPLINE_POSITION = new float[NB_SAMPLES + 1]; private static final Interpolator FLING_INTERPOLATOR = input -> { final int index = (int) (NB_SAMPLES * input); float distanceCoef = 1.f; float velocityCoef; if (index < NB_SAMPLES) { final float t_inf = (float) index / NB_SAMPLES; final float t_sup = (float) (index + 1) / NB_SAMPLES; final float d_inf = SPLINE_POSITION[index]; final float d_sup = SPLINE_POSITION[index + 1]; velocityCoef = (d_sup - d_inf) / (t_sup - t_inf); distanceCoef = d_inf + (input - t_inf) * velocityCoef; } return distanceCoef; }; private static final float[] SPLINE_TIME = new float[NB_SAMPLES + 1]; static { float x_min = 0.0f; float y_min = 0.0f; for (int i = 0; i < NB_SAMPLES; i++) { final float alpha = (float) i / NB_SAMPLES; float x_max = 1.0f; float x, tx, coef; while (true) { x = x_min + (x_max - x_min) / 2.0f; coef = 3.0f * x * (1.0f - x); tx = coef * ((1.0f - x) * P1 + x * P2) + x * x * x; if (Math.abs(tx - alpha) < 1E-5) break; if (tx > alpha) x_max = x; else x_min = x; } SPLINE_POSITION[i] = coef * ((1.0f - x) * START_TENSION + x) + x * x * x; float y_max = 1.0f; float y, dy; while (true) { y = y_min + (y_max - y_min) / 2.0f; coef = 3.0f * y * (1.0f - y); dy = coef * ((1.0f - y) * START_TENSION + y) + y * y * y; if (Math.abs(dy - alpha) < 1E-5) break; if (dy > alpha) y_max = y; else y_min = y; } SPLINE_TIME[i] = coef * ((1.0f - y) * P1 + y * P2) + y * y * y; } SPLINE_POSITION[NB_SAMPLES] = SPLINE_TIME[NB_SAMPLES] = 1.0f; } private final float mPhysicalCoeff; public Fling(Context context) { final float ppi = context.getResources().getDisplayMetrics().density * 160.0f; mPhysicalCoeff = SensorManager.GRAVITY_EARTH // g (m/s^2) * 39.37f // inch/meter * ppi * 0.84f; // look and feel tuning setInterpolator(FLING_INTERPOLATOR); } private double getSplineDeceleration(int velocity) { return Math.log(INFLEXION * Math.abs(velocity) / (FLING_FRICTION * mPhysicalCoeff)); } /* Returns the duration, expressed in milliseconds */ protected int getSplineFlingDuration(int velocity) { final double l = getSplineDeceleration(velocity); final double decelMinusOne = DECELERATION_RATE - 1.0; return (int) (1000.0 * Math.exp(l / decelMinusOne)); } protected double getSplineFlingDistance(int velocity) { final double l = getSplineDeceleration(velocity); final double decelMinusOne = DECELERATION_RATE - 1.0; return FLING_FRICTION * mPhysicalCoeff * Math.exp(DECELERATION_RATE / decelMinusOne * l); } /** * Modifies mDuration to the duration it takes to get from start to newFinal using the * spline interpolation. The previous duration was needed to get to oldFinal. **/ protected int adjustDuration(int oldFinal, int newFinal, int duration) { final float x = Math.abs((float) newFinal / oldFinal); final int index = (int) (NB_SAMPLES * x); if (index < NB_SAMPLES) { final float x_inf = (float) index / NB_SAMPLES; final float x_sup = (float) (index + 1) / NB_SAMPLES; final float t_inf = SPLINE_TIME[index]; final float t_sup = SPLINE_TIME[index + 1]; final float timeCoef = t_inf + (x - x_inf) / (x_sup - x_inf) * (t_sup - t_inf); duration *= (int) timeCoef; } return duration; } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/GalleryPageView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import com.hippo.glview.glrenderer.BasicTexture; import com.hippo.glview.glrenderer.Texture; import com.hippo.glview.image.GLImageMovableTextView; import com.hippo.glview.image.ImageMovableTextTexture; import com.hippo.glview.image.ImageTexture; import com.hippo.glview.view.Gravity; import com.hippo.glview.widget.GLFrameLayout; import com.hippo.glview.widget.GLLinearLayout; import com.hippo.glview.widget.GLProgressView; import com.hippo.glview.widget.GLTextureView; public class GalleryPageView extends GLFrameLayout { public static final int INVALID_INDEX = -1; public static final float PROGRESS_GONE = -1.0f; public static final float PROGRESS_INDETERMINATE = -2.0f; private final ImageView mImage; private final GLLinearLayout mInfo; private final GLImageMovableTextView mPage; private final GLTextureView mError; private final GLProgressView mProgress; private final int mMinHeight; private int mIndex = INVALID_INDEX; public GalleryPageView(ImageMovableTextTexture pageTextTexture, int progressColor, int progressBgColor, int progressSize, int minHeight, int infoInterval) { // Add image mImage = new ImageView(); GravityLayoutParams glp = new GravityLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); addComponent(mImage, glp); // Add other panel mInfo = new GLLinearLayout(); mInfo.setOrientation(GLLinearLayout.VERTICAL); mInfo.setInterval(infoInterval); glp = new GravityLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); glp.gravity = Gravity.CENTER; addComponent(mInfo, glp); // Add page mPage = new GLImageMovableTextView(); mPage.setTextTexture(pageTextTexture); GLLinearLayout.LayoutParams lp = new GLLinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); lp.gravity = Gravity.CENTER_HORIZONTAL; mInfo.addComponent(mPage, lp); // Add error mError = new GLTextureView(); lp = new GLLinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); lp.gravity = Gravity.CENTER_HORIZONTAL; mInfo.addComponent(mError, lp); // Add progress mProgress = new GLProgressView(); mProgress.setBgColor(progressBgColor); mProgress.setColor(progressColor); mProgress.setMinimumWidth(progressSize); mProgress.setMinimumHeight(progressSize); lp = new GLLinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); lp.gravity = Gravity.CENTER_HORIZONTAL; mInfo.addComponent(mProgress, lp); mMinHeight = minHeight; } @Override protected int getSuggestedMinimumHeight() { // The height of the actual image may be smaller than mPageMinHeight. // Set min height as 0 when the image is visible. // For PageLayoutManager, min height is useless. if (mImage.getVisibility() == VISIBLE) { return 0; } else { return mMinHeight; } } int getIndex() { return mIndex; } void setIndex(int index) { mIndex = index; } public void showImage() { mImage.setVisibility(VISIBLE); mInfo.setVisibility(GONE); } public void showInfo() { // For image valid rect mImage.setVisibility(INVISIBLE); mInfo.setVisibility(VISIBLE); } private void unbindImage() { ImageTexture texture = mImage.getImageTexture(); if (texture != null) { mImage.setImageTexture(null); texture.recycle(); } } public void setImage(ImageTexture imageTexture) { unbindImage(); if (imageTexture != null) { mImage.setImageTexture(imageTexture); } } public void setPage(int page) { mPage.setVisibility(VISIBLE); mPage.setText(Integer.toString(page)); } public void setProgress(float progress) { if (progress == PROGRESS_GONE) { mProgress.setVisibility(GONE); } else if (progress == PROGRESS_INDETERMINATE) { mProgress.setVisibility(VISIBLE); mProgress.setIndeterminate(true); } else { mProgress.setVisibility(VISIBLE); mProgress.setIndeterminate(false); mProgress.setProgress(progress); } } private void unbindError() { Texture texture = mError.getTexture(); if (texture != null) { mError.setTexture(null); if (texture instanceof BasicTexture) { ((BasicTexture) texture).recycle(); } } } public void setError(String error, GalleryView galleryView) { unbindError(); if (error == null) { mError.setVisibility(GONE); } else { mError.setVisibility(VISIBLE); galleryView.bindErrorView(mError, error); } } ImageView getImageView() { return mImage; } boolean isLoaded() { return mImage.getVisibility() == VISIBLE; } boolean isError() { return mError.getVisibility() == VISIBLE; } boolean isUnderInfo(float x, float y) { return mInfo.bounds().contains((int) x, (int) y); } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/GalleryProvider.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery import androidx.annotation.CallSuper import androidx.annotation.IntDef import androidx.collection.lruCache import com.hippo.ehviewer.Settings import com.hippo.glview.glrenderer.GLCanvas import com.hippo.glview.image.ImageWrapper import com.hippo.glview.view.GLRoot import com.hippo.glview.view.GLRoot.OnGLIdleListener import com.hippo.image.Image import com.hippo.util.isAtLeastO import com.hippo.yorozuya.ConcurrentPool import com.hippo.yorozuya.MathUtils import com.hippo.yorozuya.OSUtils abstract class GalleryProvider { private val mNotifyTaskPool = ConcurrentPool(5) private val mImageCache = lruCache( maxSize = if (isAtLeastO) { (OSUtils.getTotalMemory() / 12).toInt().coerceIn(MIN_CACHE_SIZE, MAX_CACHE_SIZE) } else { (OSUtils.getAppMaxMemory() / 3 * 2).toInt() }, sizeOf = { _, v -> v.width * v.height * if (v.animated) 20 else 4 }, onEntryRemoved = { _, _, o, _ -> o.release() }, ) private val mPreloads = MathUtils.clamp(Settings.preloadImage, 0, 100) @Volatile private var mListener: Listener? = null @Volatile private var mGLRoot: GLRoot? = null abstract suspend fun awaitReady(): Boolean abstract val isReady: Boolean abstract fun start() @CallSuper open fun stop() { mImageCache.evictAll() } fun setGLRoot(glRoot: GLRoot) { mGLRoot = glRoot } abstract val size: Int private var lastRequestIndex = -1 fun request(index: Int) { mImageCache[index]?.let { notifyPageSucceed(index, it) } ?: onRequest(index) val preloadRange = if (index >= lastRequestIndex) { index + 1..(index + mPreloads).coerceAtMost(size - 1) } else { index - 1 downTo (index - mPreloads).coerceAtLeast(0) } val start = if (preloadRange.step > 0) preloadRange.first else preloadRange.last val end = if (preloadRange.step > 0) preloadRange.last else preloadRange.first preloadPages( preloadRange.filter { mImageCache[it] == null }, start - 8 to end + 8, ) lastRequestIndex = index } fun forceRequest(index: Int) { onForceRequest(index) } fun removeCache(index: Int) { mImageCache.remove(index) } protected abstract fun preloadPages(pages: List, pair: Pair) protected abstract fun onRequest(index: Int) protected abstract fun onForceRequest(index: Int) fun cancelRequest(index: Int) { onCancelRequest(index) } protected abstract fun onCancelRequest(index: Int) fun setListener(listener: Listener?) { mListener = listener } fun notifyDataChanged(index: Int) { notify(NotifyTask.TYPE_DATA_CHANGED, index, 0.0f, null, null) } fun notifyPageWait(index: Int) { notify(NotifyTask.TYPE_WAIT, index, 0.0f, null, null) } fun notifyPagePercent(index: Int, percent: Float) { notify(NotifyTask.TYPE_PERCENT, index, percent, null, null) } fun notifyPageSucceed(index: Int, image: Image) { val imageWrapper = ImageWrapper(image) if (imageWrapper.obtain()) mImageCache.put(index, imageWrapper) notifyPageSucceed(index, imageWrapper) } private fun notifyPageSucceed(index: Int, image: ImageWrapper) { notify(NotifyTask.TYPE_SUCCEED, index, 0.0f, image, null) } fun notifyPageFailed(index: Int, error: String?) { notify(NotifyTask.TYPE_FAILED, index, 0.0f, null, error) } private fun notify( @NotifyTask.Type type: Int, index: Int, percent: Float, image: ImageWrapper?, error: String?, ) { val listener = mListener ?: return val glRoot = mGLRoot ?: return val task = mNotifyTaskPool.pop() ?: NotifyTask(listener, mNotifyTaskPool) task.setData(type, index, percent, image, error) glRoot.addOnGLIdleListener(task) } interface Listener { fun onDataChanged() fun onPageWait(index: Int) fun onPagePercent(index: Int, percent: Float) fun onPageSucceed(index: Int, image: ImageWrapper?) fun onPageFailed(index: Int, error: String?) fun onDataChanged(index: Int) } private class NotifyTask( private val mListener: Listener, private val mPool: ConcurrentPool, ) : OnGLIdleListener { @Type private var mType = 0 private var mIndex = 0 private var mPercent = 0f private var mImage: ImageWrapper? = null private var mError: String? = null fun setData( @Type type: Int, index: Int, percent: Float, image: ImageWrapper?, error: String?, ) { mType = type mIndex = index mPercent = percent mImage = image mError = error } override fun onGLIdle(canvas: GLCanvas, renderRequested: Boolean): Boolean { when (mType) { TYPE_DATA_CHANGED -> if (mIndex < 0) { mListener.onDataChanged() } else { mListener.onDataChanged(mIndex) } TYPE_WAIT -> mListener.onPageWait(mIndex) TYPE_PERCENT -> mListener.onPagePercent(mIndex, mPercent) TYPE_SUCCEED -> mListener.onPageSucceed(mIndex, mImage) TYPE_FAILED -> mListener.onPageFailed(mIndex, mError) } // Clean data mImage = null mError = null // Push back mPool.push(this) return false } @IntDef(TYPE_DATA_CHANGED, TYPE_WAIT, TYPE_PERCENT, TYPE_SUCCEED, TYPE_FAILED) @Retention( AnnotationRetention.SOURCE, ) annotation class Type companion object { const val TYPE_DATA_CHANGED = 0 const val TYPE_WAIT = 1 const val TYPE_PERCENT = 2 const val TYPE_SUCCEED = 3 const val TYPE_FAILED = 4 } } companion object { private const val MAX_CACHE_SIZE = 512 * 1024 * 1024 private const val MIN_CACHE_SIZE = 256 * 1024 * 1024 } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/GalleryView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import android.content.Context; import android.graphics.Color; import android.graphics.Rect; import android.graphics.Typeface; import android.view.MotionEvent; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.hippo.glview.glrenderer.BasicTexture; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.glrenderer.StringTexture; import com.hippo.glview.glrenderer.Texture; import com.hippo.glview.image.ImageMovableTextTexture; import com.hippo.glview.util.GalleryUtils; import com.hippo.glview.view.AnimationTime; import com.hippo.glview.view.GLRoot; import com.hippo.glview.view.GLView; import com.hippo.glview.widget.GLTextureView; import com.hippo.yorozuya.MathUtils; import com.hippo.yorozuya.Pool; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; public final class GalleryView extends GLView implements GestureRecognizer.Listener { public static final int LAYOUT_LEFT_TO_RIGHT = 0; public static final int LAYOUT_RIGHT_TO_LEFT = 1; public static final int LAYOUT_TOP_TO_BOTTOM = 2; public static final int SCALE_ORIGIN = ImageView.SCALE_ORIGIN; public static final int SCALE_FIT_WIDTH = ImageView.SCALE_FIT_WIDTH; public static final int SCALE_FIT_HEIGHT = ImageView.SCALE_FIT_HEIGHT; public static final int SCALE_FIT = ImageView.SCALE_FIT; public static final int SCALE_FIXED = ImageView.SCALE_FIXED; public static final int START_POSITION_TOP_LEFT = ImageView.START_POSITION_TOP_LEFT; public static final int START_POSITION_TOP_RIGHT = ImageView.START_POSITION_TOP_RIGHT; public static final int START_POSITION_BOTTOM_LEFT = ImageView.START_POSITION_BOTTOM_LEFT; public static final int START_POSITION_BOTTOM_RIGHT = ImageView.START_POSITION_BOTTOM_RIGHT; public static final int START_POSITION_CENTER = ImageView.START_POSITION_CENTER; private static final float[] LEFT_AREA = {0.0f, 0.0f, 1.0f / 3.0f, 1f}; private static final float[] RIGHT_AREA = {2.0f / 3.0f, 0.0f, 1.0f, 1f}; private static final float[] MENU_AREA = {1.0f / 3.0f, 0.0f, 2.0f / 3.0f, 1.0f / 2.0f}; private static final float[] SLIDER_AREA = {1.0f / 3.0f, 1.0f / 2.0f, 2.0f / 3.0f, 1.0f}; private static final int METHOD_ON_SINGLE_TAP_UP = 0; private static final int METHOD_ON_SINGLE_TAP_CONFIRMED = 1; private static final int METHOD_ON_DOUBLE_TAP = 2; private static final int METHOD_ON_DOUBLE_TAP_CONFIRMED = 3; private static final int METHOD_ON_LONG_PRESS = 4; private static final int METHOD_ON_SCROLL = 5; private static final int METHOD_ON_FLING = 6; private static final int METHOD_ON_SCALE_BEGIN = 7; private static final int METHOD_ON_SCALE = 8; private static final int METHOD_ON_SCALE_END = 9; private static final int METHOD_ON_DOWN = 10; private static final int METHOD_ON_UP = 11; private static final int METHOD_ON_POINTER_DOWN = 12; private static final int METHOD_ON_POINTER_UP = 13; private static final int METHOD_SET_LAYOUT_MODE = 14; private static final int METHOD_SET_CURRENT_PAGE = 15; private static final int METHOD_PAGE_LEFT = 16; private static final int METHOD_PAGE_RIGHT = 17; private static final int METHOD_SET_SCALE_MODE = 18; private static final int METHOD_SET_START_POSITION = 19; private static final int METHOD_ON_ATTACH_TO_ROOT = 20; private static final int METHOD_SET_PAGER_INTERVAL = 21; private static final int METHOD_SET_SCROLL_INTERVAL = 22; private final Context mContext; private final GestureRecognizer mGestureRecognizer; @Nullable private final Listener mListener; private final Pool mGalleryPageViewPool = new Pool<>(5); private final int mBackgroundColor; private final int mPageMinHeight; private final int mPageInfoInterval; private final int mProgressColor; private final int mProgressSize; private final int mPageTextColor; private final int mPageTextSize; private final Typeface mPageTextTypeface; private final int mErrorTextSize; private final int mErrorTextColor; private final String mEmptyString; private final Rect mLeftArea = new Rect(); private final Rect mRightArea = new Rect(); private final Rect mMenuArea = new Rect(); private final Rect mSliderArea = new Rect(); private final List mMethodList = new ArrayList<>(5); private final List mArgsList = new ArrayList<>(5); private final List mMethodListTemp = new ArrayList<>(5); private final List mArgsListTemp = new ArrayList<>(5); private final AtomicInteger mCurrentIndex = new AtomicInteger(GalleryPageView.INVALID_INDEX); private Adapter mAdapter; private ImageMovableTextTexture mPageTextTexture; private PagerLayoutManager mPagerLayoutManager; private ScrollLayoutManager mScrollLayoutManager; @Nullable private LayoutManager mLayoutManager; private GLTextureView mErrorViewCache; private int mPagerInterval; private int mScrollInterval; private boolean mEnableRequestFill = true; private boolean mRequestFill = false; private boolean mWillFill = false; private boolean mScale = false; private boolean mScroll = false; private boolean mFirstScroll = false; private int mLayoutMode; private int mScaleMode; private int mStartPosition; private int mIndex; private GalleryView(Builder build) { mContext = build.mContext; mAdapter = build.mAdapter; mAdapter.setGalleryView(this); mListener = build.mListener; mGestureRecognizer = new GestureRecognizer(mContext, this); mLayoutMode = build.mLayoutMode; mScaleMode = build.mScaleMode; mStartPosition = build.mStartPosition; mIndex = MathUtils.clamp(build.mStartPage, 0, Integer.MAX_VALUE); mBackgroundColor = build.mBackgroundColor; mPageMinHeight = build.mPageMinHeight; mPagerInterval = build.mPagerInterval; mScrollInterval = build.mScrollInterval; mPageInfoInterval = build.mPageInfoInterval; mProgressColor = build.mProgressColor; mProgressSize = build.mProgressSize; mPageTextColor = build.mPageTextColor; mPageTextSize = build.mPageTextSize; mPageTextTypeface = build.mPageTextTypeface; mErrorTextColor = build.mErrorTextColor; mErrorTextSize = build.mErrorTextSize; mEmptyString = build.mEmptyString; setBackgroundColor(mBackgroundColor); } @LayoutMode public static int sanitizeLayoutMode(int layoutMode) { if (layoutMode != GalleryView.LAYOUT_LEFT_TO_RIGHT && layoutMode != GalleryView.LAYOUT_RIGHT_TO_LEFT && layoutMode != GalleryView.LAYOUT_TOP_TO_BOTTOM) { return GalleryView.LAYOUT_LEFT_TO_RIGHT; } else { return layoutMode; } } @ScaleMode public static int sanitizeScaleMode(int scaleMode) { if (scaleMode != GalleryView.SCALE_ORIGIN && scaleMode != GalleryView.SCALE_FIT_WIDTH && scaleMode != GalleryView.SCALE_FIT_HEIGHT && scaleMode != GalleryView.SCALE_FIT && scaleMode != GalleryView.SCALE_FIXED) { return GalleryView.SCALE_FIT; } else { return scaleMode; } } @StartPosition public static int sanitizeStartPosition(int startPosition) { if (startPosition != GalleryView.START_POSITION_TOP_LEFT && startPosition != GalleryView.START_POSITION_TOP_RIGHT && startPosition != GalleryView.START_POSITION_BOTTOM_LEFT && startPosition != GalleryView.START_POSITION_BOTTOM_RIGHT && startPosition != GalleryView.START_POSITION_CENTER) { return GalleryView.START_POSITION_TOP_LEFT; } else { return startPosition; } } private void ensurePagerLayoutManager() { if (mPagerLayoutManager == null) { mPagerLayoutManager = new PagerLayoutManager(mContext, this, mScaleMode, mStartPosition, 1.0f, mPagerInterval); } } private void ensureScrollLayoutManager() { if (mScrollLayoutManager == null) { mScrollLayoutManager = new ScrollLayoutManager(mContext, this, mScrollInterval); } } private void attachLayoutManager() { if (null != mLayoutManager) { return; } switch (mLayoutMode) { case LAYOUT_LEFT_TO_RIGHT -> { ensurePagerLayoutManager(); mPagerLayoutManager.setMode(PagerLayoutManager.MODE_LEFT_TO_RIGHT); mPagerLayoutManager.onAttach(mAdapter); mPagerLayoutManager.setCurrentIndex(mIndex); mAdapter = null; mLayoutManager = mPagerLayoutManager; } case LAYOUT_RIGHT_TO_LEFT -> { ensurePagerLayoutManager(); mPagerLayoutManager.setMode(PagerLayoutManager.MODE_RIGHT_TO_LEFT); mPagerLayoutManager.onAttach(mAdapter); mPagerLayoutManager.setCurrentIndex(mIndex); mAdapter = null; mLayoutManager = mPagerLayoutManager; } case LAYOUT_TOP_TO_BOTTOM -> { ensureScrollLayoutManager(); mScrollLayoutManager.onAttach(mAdapter); mScrollLayoutManager.setCurrentIndex(mIndex); mAdapter = null; mLayoutManager = mScrollLayoutManager; } } requestFill(); } private void detachLayoutManager() { if (null == mLayoutManager) { return; } mIndex = mLayoutManager.getInternalCurrentIndex(); mAdapter = mLayoutManager.onDetach(); mLayoutManager = null; } private void onAttachToRootInternal() { if (null == mPageTextTexture) { mPageTextTexture = ImageMovableTextTexture.create(mPageTextTypeface, mPageTextSize, mPageTextColor, new char[]{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}); } attachLayoutManager(); } private void setPagerIntervalInternal(int interval) { mPagerInterval = interval; if (mPagerLayoutManager != null) { mPagerLayoutManager.setInterval(interval); } } private void setScrollIntervalInternal(int interval) { mScrollInterval = interval; if (mScrollLayoutManager != null) { mScrollLayoutManager.setInterval(interval); } } @Override public void onAttachToRoot(GLRoot root) { super.onAttachToRoot(root); postMethod(METHOD_ON_ATTACH_TO_ROOT); } @Override public void onDetachFromRoot() { // When detached, render() will not be called. So do it here detachLayoutManager(); if (null != mPageTextTexture) { mPageTextTexture.recycle(); mPageTextTexture = null; } super.onDetachFromRoot(); } public int getLayoutMode() { return mLayoutMode; } public void setLayoutMode(@LayoutMode int layoutMode) { postMethod(METHOD_SET_LAYOUT_MODE, layoutMode); } @Override public void requestLayout() { // Do not need requestLayout, because the size will not change requestFill(); } public void requestFill() { if (mEnableRequestFill) { mRequestFill = true; if (!mWillFill) { invalidate(); } } } @Override protected boolean dispatchTouchEvent(MotionEvent event) { // Do not pass event to component, so handle event here mGestureRecognizer.onTouchEvent(event); return true; } String getEmptyStr() { return mEmptyString; } boolean isFirstScroll() { boolean firstScroll = mFirstScroll; mFirstScroll = false; return firstScroll; } // Make sure method run in render thread to ensure thread safe private void postMethod(int method, Object... args) { synchronized (this) { mMethodList.add(method); mArgsList.add(args); } invalidate(); } public void setCurrentPage(int page) { postMethod(METHOD_SET_CURRENT_PAGE, page); } public void pageLeft() { postMethod(METHOD_PAGE_LEFT); } public void pageRight() { postMethod(METHOD_PAGE_RIGHT); } public void setScaleMode(int scaleMode) { postMethod(METHOD_SET_SCALE_MODE, scaleMode); } public void setStartPosition(int startPosition) { postMethod(METHOD_SET_START_POSITION, startPosition); } public void setPagerInterval(int interval) { postMethod(METHOD_SET_PAGER_INTERVAL, interval); } public void setScrollInterval(int interval) { postMethod(METHOD_SET_SCROLL_INTERVAL, interval); } @Override public boolean onSingleTapUp(float x, float y) { postMethod(METHOD_ON_SINGLE_TAP_UP, x, y); return true; } @Override public boolean onSingleTapConfirmed(float x, float y) { postMethod(METHOD_ON_SINGLE_TAP_CONFIRMED, x, y); return true; } @Override public boolean onDoubleTap(float x, float y) { postMethod(METHOD_ON_DOUBLE_TAP, x, y); return true; } @Override public boolean onDoubleTapConfirmed(float x, float y) { postMethod(METHOD_ON_DOUBLE_TAP_CONFIRMED, x, y); return true; } @Override public void onLongPress(float x, float y) { if (mLayoutManager != null && mLayoutManager.isTapOrPressDisable()) { return; } postMethod(METHOD_ON_LONG_PRESS, x, y); } @Override public boolean onScroll(float dx, float dy, float totalX, float totalY, float x, float y) { postMethod(METHOD_ON_SCROLL, dx, dy, totalX, totalY, x, y); return true; } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { postMethod(METHOD_ON_FLING, velocityX, velocityY); return true; } @Override public boolean onScaleBegin(float focusX, float focusY) { postMethod(METHOD_ON_SCALE_BEGIN, focusX, focusY); return true; } @Override public boolean onScale(float focusX, float focusY, float scale) { postMethod(METHOD_ON_SCALE, focusX, focusY, scale); return true; } @Override public void onScaleEnd() { postMethod(METHOD_ON_SCALE_END); } @Override public void onDown(float x, float y) { postMethod(METHOD_ON_DOWN, x, y); } @Override public void onUp() { postMethod(METHOD_ON_UP); } @Override public void onPointerDown(float x, float y) { postMethod(METHOD_ON_POINTER_DOWN, x, y); } @Override public void onPointerUp() { postMethod(METHOD_ON_POINTER_UP); } @Override protected void onLayout(boolean changeSize, int left, int top, int right, int bottom) { fill(); if (changeSize) { int width = right - left; int height = bottom - top; mLeftArea.set((int) (LEFT_AREA[0] * width), (int) (LEFT_AREA[1] * height), (int) (LEFT_AREA[2] * width), (int) (LEFT_AREA[3] * height)); mRightArea.set((int) (RIGHT_AREA[0] * width), (int) (RIGHT_AREA[1] * height), (int) (RIGHT_AREA[2] * width), (int) (RIGHT_AREA[3] * height)); mMenuArea.set((int) (MENU_AREA[0] * width), (int) (MENU_AREA[1] * height), (int) (MENU_AREA[2] * width), (int) (MENU_AREA[3] * height)); mSliderArea.set((int) (SLIDER_AREA[0] * width), (int) (SLIDER_AREA[1] * height), (int) (SLIDER_AREA[2] * width), (int) (SLIDER_AREA[3] * height)); } } public void onDataChanged() { GalleryUtils.assertInRenderThread(); if (mLayoutManager != null) { mLayoutManager.onDataChanged(); } } private void onSingleTapUpInternal() { } private GalleryPageView findPageUnder(float x, float y) { for (int i = 0, n = getComponentCount(); i < n; i++) { GLView view = getComponent(i); if (view instanceof GalleryPageView && view.bounds().contains((int) x, (int) y)) { return (GalleryPageView) view; } } return null; } private void onSingleTapConfirmedInternal(float x, float y) { if (mLayoutManager == null || mLayoutManager.isTapOrPressDisable()) { return; } GalleryPageView page = findPageUnder(x, y); if (page != null && page.getIndex() != GalleryPageView.INVALID_INDEX && page.isError() && page.isUnderInfo(x - page.bounds().left, y - page.bounds().top)) { if (mListener != null) { mListener.onTapErrorText(page.getIndex()); } } else if (mSliderArea.contains((int) x, (int) y)) { if (mListener != null) { mListener.onTapSliderArea(); } } else if (mMenuArea.contains((int) x, (int) y)) { if (mListener != null) { mListener.onTapMenuArea(); } } else if (mLeftArea.contains((int) x, (int) y)) { mLayoutManager.onPageLeft(); } else if (mRightArea.contains((int) x, (int) y)) { mLayoutManager.onPageRight(); } } private void onDoubleTapInternal() { } private void onDoubleTapConfirmedInternal(float x, float y) { if (mScale) { return; } if (mLayoutManager != null) { mLayoutManager.onDoubleTapConfirmed(x, y); } } private void onLongPressInternal(float x, float y) { if (mScale) { return; } if (mLayoutManager == null) { return; } int index = mLayoutManager.getIndexUnder(x, y); if (index == GalleryPageView.INVALID_INDEX) { return; } if (mListener != null) { mListener.onLongPressPage(index); } } private void onScrollInternal(float dx, float dy, float totalX, float totalY, float x, float y) { if (mScale) { return; } mScroll = true; if (mLayoutManager != null) { mLayoutManager.onScroll(dx, dy, totalX, totalY, x, y); } } private void onFlingInternal(float velocityX, float velocityY) { if (mLayoutManager != null) { mLayoutManager.onFling(velocityX, velocityY); } } private void onScaleBeginInternal(float focusX, float focusY) { onScaleInternal(focusX, focusY, 1.0f); } private void onScaleInternal(float focusX, float focusY, float scale) { if (mScroll || (mLayoutManager != null && !mLayoutManager.canScale())) { return; } mScale = true; if (mLayoutManager != null) { mLayoutManager.onScale(focusX, focusY, scale); } } private void onScaleEndInternal() { } private void onDownInternal() { mScale = false; mScroll = false; mFirstScroll = true; if (mLayoutManager != null) { mLayoutManager.onDown(); } } private void onUpInternal() { if (mLayoutManager != null) { mLayoutManager.onUp(); } } private void onPointerDownInternal() { if (!mScroll && (mLayoutManager != null && mLayoutManager.canScale())) { mScale = true; } } private void onPointerUpInternal() { } private void setLayoutModeInternal(int layoutMode) { if (mLayoutMode == layoutMode) { return; } mLayoutMode = layoutMode; if (mLayoutManager == null) { return; } switch (mLayoutMode) { case LAYOUT_LEFT_TO_RIGHT -> { if (mLayoutManager == mPagerLayoutManager) { // mPagerLayoutManager already attached, just change mode mPagerLayoutManager.setMode(PagerLayoutManager.MODE_LEFT_TO_RIGHT); } else { ensurePagerLayoutManager(); mPagerLayoutManager.setMode(PagerLayoutManager.MODE_LEFT_TO_RIGHT); int index = mLayoutManager.getInternalCurrentIndex(); mPagerLayoutManager.onAttach(mLayoutManager.onDetach()); mPagerLayoutManager.setCurrentIndex(index); mLayoutManager = mPagerLayoutManager; } } case LAYOUT_RIGHT_TO_LEFT -> { if (mLayoutManager == mPagerLayoutManager) { // mPagerLayoutManager already attached, just change mode mPagerLayoutManager.setMode(PagerLayoutManager.MODE_RIGHT_TO_LEFT); } else { ensurePagerLayoutManager(); mPagerLayoutManager.setMode(PagerLayoutManager.MODE_RIGHT_TO_LEFT); int index = mLayoutManager.getInternalCurrentIndex(); mPagerLayoutManager.onAttach(mLayoutManager.onDetach()); mPagerLayoutManager.setCurrentIndex(index); mLayoutManager = mPagerLayoutManager; } } case LAYOUT_TOP_TO_BOTTOM -> { ensureScrollLayoutManager(); int index = mLayoutManager.getInternalCurrentIndex(); mScrollLayoutManager.onAttach(mLayoutManager.onDetach()); mScrollLayoutManager.setCurrentIndex(index); mLayoutManager = mScrollLayoutManager; } } requestFill(); } private void setCurrentPageInternal(int page) { if (mLayoutManager != null) { mLayoutManager.setCurrentIndex(page); } else { mIndex = page; } } private void pageLeftInternal() { if (mLayoutManager != null) { mLayoutManager.onPageLeft(); } } private void pageRightInternal() { if (mLayoutManager != null) { mLayoutManager.onPageRight(); } } private void setScaleModeInternal(int scaleMode) { mScaleMode = scaleMode; if (mPagerLayoutManager != null) { mPagerLayoutManager.setScaleMode(scaleMode); } } private void setStartPositionInternal(int startPosition) { mStartPosition = startPosition; if (mPagerLayoutManager != null) { mPagerLayoutManager.setStartPosition(startPosition); } } void forceFill() { mRequestFill = true; fill(); } private void fill() { GalleryUtils.assertInRenderThread(); if (!mRequestFill) { return; } // Disable request layout mEnableRequestFill = false; if (mLayoutManager != null) { mLayoutManager.onFill(); } mEnableRequestFill = true; mRequestFill = false; } private void dispatchMethod() { List methodListTemp = mMethodListTemp; List argsListTemp = mArgsListTemp; synchronized (this) { if (mMethodList.isEmpty()) { return; } methodListTemp.addAll(mMethodList); argsListTemp.addAll(mArgsList); mMethodList.clear(); mArgsList.clear(); } for (int i = 0, n = methodListTemp.size(); i < n; i++) { int method = methodListTemp.get(i); Object[] args = argsListTemp.get(i); switch (method) { case METHOD_ON_SINGLE_TAP_UP -> onSingleTapUpInternal(); case METHOD_ON_SINGLE_TAP_CONFIRMED -> onSingleTapConfirmedInternal((Float) args[0], (Float) args[1]); case METHOD_ON_DOUBLE_TAP -> onDoubleTapInternal(); case METHOD_ON_DOUBLE_TAP_CONFIRMED -> onDoubleTapConfirmedInternal((Float) args[0], (Float) args[1]); case METHOD_ON_LONG_PRESS -> onLongPressInternal((Float) args[0], (Float) args[1]); case METHOD_ON_SCROLL -> onScrollInternal((Float) args[0], (Float) args[1], (Float) args[2], (Float) args[3], (Float) args[4], (Float) args[5]); case METHOD_ON_FLING -> onFlingInternal((Float) args[0], (Float) args[1]); case METHOD_ON_SCALE_BEGIN -> onScaleBeginInternal((Float) args[0], (Float) args[1]); case METHOD_ON_SCALE -> onScaleInternal((Float) args[0], (Float) args[1], (Float) args[2]); case METHOD_ON_SCALE_END -> onScaleEndInternal(); case METHOD_ON_DOWN -> onDownInternal(); case METHOD_ON_UP -> onUpInternal(); case METHOD_ON_POINTER_DOWN -> onPointerDownInternal(); case METHOD_ON_POINTER_UP -> onPointerUpInternal(); case METHOD_SET_LAYOUT_MODE -> setLayoutModeInternal((Integer) args[0]); case METHOD_SET_CURRENT_PAGE -> setCurrentPageInternal((Integer) args[0]); case METHOD_PAGE_LEFT -> pageLeftInternal(); case METHOD_PAGE_RIGHT -> pageRightInternal(); case METHOD_SET_SCALE_MODE -> setScaleModeInternal((Integer) args[0]); case METHOD_SET_START_POSITION -> setStartPositionInternal((Integer) args[0]); case METHOD_ON_ATTACH_TO_ROOT -> onAttachToRootInternal(); case METHOD_SET_PAGER_INTERVAL -> setPagerIntervalInternal((Integer) args[0]); case METHOD_SET_SCROLL_INTERVAL -> setScrollIntervalInternal((Integer) args[0]); } } methodListTemp.clear(); argsListTemp.clear(); } @Override public void render(GLCanvas canvas) { mWillFill = true; int oldCurrentIndex = mCurrentIndex.get(); // Dispatch method dispatchMethod(); long time = AnimationTime.get(); if (mLayoutManager != null && mLayoutManager.onUpdateAnimation(time)) { invalidate(); } fill(); mWillFill = false; super.render(canvas); int newCurrentIndex; if (mLayoutManager != null) { newCurrentIndex = mLayoutManager.getCurrentIndex(); } else { newCurrentIndex = GalleryPageView.INVALID_INDEX; } mCurrentIndex.lazySet(newCurrentIndex); if (oldCurrentIndex != newCurrentIndex && mListener != null) { mListener.onUpdateCurrentIndex(newCurrentIndex); } } public GalleryPageView findPageByIndex(int id) { if (mLayoutManager != null) { return mLayoutManager.findPageByIndex(id); } else { return null; } } GalleryPageView obtainPage() { GalleryPageView page = mGalleryPageViewPool.pop(); if (page == null) { page = new GalleryPageView(mPageTextTexture, mProgressColor, mBackgroundColor, mProgressSize, mPageMinHeight, mPageInfoInterval); } return page; } void releasePage(GalleryPageView page) { mGalleryPageViewPool.push(page); } GLTextureView obtainErrorView() { GLTextureView errorView; if (mErrorViewCache != null) { errorView = mErrorViewCache; mErrorViewCache = null; } else { errorView = new GLTextureView(); } return errorView; } void unbindErrorView(GLTextureView errorView) { Texture texture = errorView.getTexture(); if (texture != null) { errorView.setTexture(null); if (texture instanceof BasicTexture) { ((BasicTexture) texture).recycle(); } } } void bindErrorView(GLTextureView errorView, String error) { unbindErrorView(errorView); Texture texture = StringTexture.newInstance(error, mErrorTextSize, mErrorTextColor); errorView.setTexture(texture); } void releaseErrorView(GLTextureView errorView) { unbindErrorView(errorView); mErrorViewCache = errorView; } @IntDef({LAYOUT_LEFT_TO_RIGHT, LAYOUT_RIGHT_TO_LEFT, LAYOUT_TOP_TO_BOTTOM}) @Retention(RetentionPolicy.SOURCE) public @interface LayoutMode { } @IntDef({SCALE_ORIGIN, SCALE_FIT_WIDTH, SCALE_FIT_HEIGHT, SCALE_FIT, SCALE_FIXED}) @Retention(RetentionPolicy.SOURCE) public @interface ScaleMode { } @IntDef({START_POSITION_TOP_LEFT, START_POSITION_TOP_RIGHT, START_POSITION_BOTTOM_LEFT, START_POSITION_BOTTOM_RIGHT, START_POSITION_CENTER}) @Retention(RetentionPolicy.SOURCE) public @interface StartPosition { } public interface Listener { void onUpdateCurrentIndex(int index); void onTapSliderArea(); void onTapMenuArea(); void onTapErrorText(int index); void onLongPressPage(int index); } public static class Builder { private final Context mContext; private final Adapter mAdapter; private Listener mListener; private int mLayoutMode = LAYOUT_LEFT_TO_RIGHT; private int mScaleMode = SCALE_FIT; private int mStartPosition = START_POSITION_TOP_LEFT; private int mStartPage = 0; private int mBackgroundColor = Color.BLACK; private int mPagerInterval = 48; private int mScrollInterval = 24; private int mPageMinHeight = 256; private int mPageInfoInterval = 24; private int mProgressColor = Color.WHITE; private int mProgressSize = 56; private int mPageTextColor = Color.WHITE; private int mPageTextSize = 56; private Typeface mPageTextTypeface = Typeface.DEFAULT; private int mErrorTextColor = Color.RED; private int mErrorTextSize = 24; private String mEmptyString = "Empty"; public Builder(@NonNull Context context, @NonNull Adapter adapter) { mContext = context; mAdapter = adapter; } public Builder setListener(Listener listener) { mListener = listener; return this; } public Builder setLayoutMode(@LayoutMode int layoutMode) { mLayoutMode = layoutMode; return this; } public Builder setScaleMode(@ScaleMode int scaleMode) { mScaleMode = scaleMode; return this; } public Builder setStartPosition(@StartPosition int startPosition) { mStartPosition = startPosition; return this; } public Builder setStartPage(int startPage) { mStartPage = startPage; return this; } public Builder setBackgroundColor(int backgroundColor) { mBackgroundColor = backgroundColor; return this; } public Builder setPagerInterval(int pagerInterval) { mPagerInterval = pagerInterval; return this; } public Builder setScrollInterval(int scrollInterval) { mScrollInterval = scrollInterval; return this; } public Builder setPageMinHeight(int pageMinHeight) { mPageMinHeight = pageMinHeight; return this; } public Builder setPageInfoInterval(int pageInfoInterval) { mPageInfoInterval = pageInfoInterval; return this; } public Builder setProgressColor(int progressColor) { mProgressColor = progressColor; return this; } public Builder setProgressSize(int progressSize) { mProgressSize = progressSize; return this; } public Builder setPageTextColor(int pageTextColor) { mPageTextColor = pageTextColor; return this; } public Builder setPageTextSize(int pageTextSize) { mPageTextSize = pageTextSize; return this; } public Builder setPageTextTypeface(Typeface pageTextTypeface) { mPageTextTypeface = pageTextTypeface; return this; } public Builder setErrorTextColor(int errorTextColor) { mErrorTextColor = errorTextColor; return this; } public Builder setErrorTextSize(int errorTextSize) { mErrorTextSize = errorTextSize; return this; } public Builder setEmptyString(String emptyString) { mEmptyString = emptyString; return this; } public GalleryView build() { return new GalleryView(this); } } public static abstract class Adapter { protected GalleryView mGalleryView; private void setGalleryView(@NonNull GalleryView galleryView) { mGalleryView = galleryView; } public void bind(GalleryPageView view, int index) { onBind(view, index); view.setIndex(index); } public void unbind(GalleryPageView view) { onUnbind(view, view.getIndex()); view.setIndex(GalleryPageView.INVALID_INDEX); } public abstract void onBind(GalleryPageView view, int index); public abstract void onUnbind(GalleryPageView view, int index); public abstract int size(); } public static abstract class LayoutManager { protected GalleryView mGalleryView; public LayoutManager(@NonNull GalleryView galleryView) { mGalleryView = galleryView; } public abstract void onAttach(Adapter iterator); public abstract Adapter onDetach(); public abstract void onFill(); public abstract void onDown(); public abstract void onUp(); public abstract void onDoubleTapConfirmed(float x, float y); public abstract void onScroll(float dx, float dy, float totalX, float totalY, float x, float y); public abstract void onFling(float velocityX, float velocityY); public abstract boolean canScale(); public abstract void onScale(float focusX, float focusY, float scale); public abstract boolean onUpdateAnimation(long time); public abstract void onDataChanged(); public abstract void onPageLeft(); public abstract void onPageRight(); public abstract boolean isTapOrPressDisable(); public abstract GalleryPageView findPageByIndex(int index); /** * @return {@link GalleryPageView#INVALID_INDEX} for error */ public abstract int getCurrentIndex(); public abstract void setCurrentIndex(int index); public abstract int getIndexUnder(float x, float y); abstract int getInternalCurrentIndex(); protected void placeCenter(GLView view) { int spec = GLView.MeasureSpec.makeMeasureSpec(GLView.LayoutParams.WRAP_CONTENT, GLView.LayoutParams.WRAP_CONTENT); view.measure(spec, spec); int viewWidth = view.getMeasuredWidth(); int viewHeight = view.getMeasuredHeight(); int viewLeft = mGalleryView.getWidth() / 2 - viewWidth / 2; int viewTop = mGalleryView.getHeight() / 2 - viewHeight / 2; view.layout(viewLeft, viewTop, viewLeft + viewWidth, viewTop + viewHeight); } } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/GestureRecognizer.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import android.content.Context; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import androidx.annotation.NonNull; // This class aggregates three gesture detectors: GestureDetector, // ScaleGestureDetector, and DownUpDetector. class GestureRecognizer { @SuppressWarnings("unused") private static final String TAG = "GestureRecognizer"; private final GestureDetector mGestureDetector; private final ScaleGestureDetector mScaleDetector; private final DownUpDetector mDownUpDetector; private final Listener mListener; public GestureRecognizer(Context context, Listener listener) { mListener = listener; MyGestureListener gestureListener = new MyGestureListener(); mGestureDetector = new GestureDetector(context, gestureListener, null /* ignoreMultitouch */); mGestureDetector.setOnDoubleTapListener(gestureListener); mScaleDetector = new ScaleGestureDetector(context, new MyScaleListener()); mDownUpDetector = new DownUpDetector(new MyDownUpListener()); } public void onTouchEvent(MotionEvent event) { mScaleDetector.onTouchEvent(event); mGestureDetector.onTouchEvent(event); mDownUpDetector.onTouchEvent(event); } public boolean isDown() { return mDownUpDetector.isDown(); } public interface Listener { boolean onSingleTapUp(float x, float y); boolean onSingleTapConfirmed(float x, float y); boolean onDoubleTap(float x, float y); boolean onDoubleTapConfirmed(float x, float y); void onLongPress(float x, float y); boolean onScroll(float dx, float dy, float totalX, float totalY, float x, float y); /** * @param velocityX Finger from top to bottom is positive * @param velocityY Finger from left to right is positive */ boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY); boolean onScaleBegin(float focusX, float focusY); boolean onScale(float focusX, float focusY, float scale); void onScaleEnd(); void onDown(float x, float y); void onUp(); void onPointerDown(float x, float y); void onPointerUp(); } private class MyGestureListener extends GestureDetector.SimpleOnGestureListener { @Override public boolean onSingleTapUp(MotionEvent e) { return mListener.onSingleTapUp(e.getX(), e.getY()); } @Override public boolean onSingleTapConfirmed(MotionEvent e) { return mListener.onSingleTapConfirmed(e.getX(), e.getY()); } @Override public boolean onDoubleTapEvent(MotionEvent e) { if (e.getAction() == MotionEvent.ACTION_UP) { return mListener.onDoubleTapConfirmed(e.getX(), e.getY()); } else { return true; } } @Override public boolean onDoubleTap(MotionEvent e) { return mListener.onDoubleTap(e.getX(), e.getY()); } @Override public void onLongPress(MotionEvent e) { mListener.onLongPress(e.getX(), e.getY()); } @Override public boolean onScroll(MotionEvent e1, @NonNull MotionEvent e2, float dx, float dy) { if (e1 == null) return false; return mListener.onScroll(dx, dy, e2.getX() - e1.getX(), e2.getY() - e1.getY(), e2.getX(), e2.getY()); } @Override public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { return mListener.onFling(e1, e2, velocityX, velocityY); } } private class MyScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { @Override public boolean onScaleBegin(ScaleGestureDetector detector) { return mListener.onScaleBegin(detector.getFocusX(), detector.getFocusY()); } @Override public boolean onScale(ScaleGestureDetector detector) { return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor()); } @Override public void onScaleEnd(@NonNull ScaleGestureDetector detector) { mListener.onScaleEnd(); } } private class MyDownUpListener implements DownUpDetector.DownUpListener { @Override public void onDown(MotionEvent e) { mListener.onDown(e.getX(), e.getY()); } @Override public void onUp(MotionEvent e) { mListener.onUp(); } @Override public void onPointerDown(MotionEvent e) { mListener.onPointerDown(e.getX(), e.getY()); } @Override public void onPointerUp(MotionEvent e) { mListener.onPointerUp(); } } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/ImageView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import android.graphics.Rect; import android.graphics.RectF; import com.hippo.glview.anim.AlphaAnimation; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.glrenderer.Texture; import com.hippo.glview.image.ImageTexture; import com.hippo.glview.view.GLView; import com.hippo.yorozuya.AnimationUtils; import com.hippo.yorozuya.MathUtils; import java.util.Arrays; class ImageView extends GLView implements ImageTexture.Callback { public static final int SCALE_ORIGIN = 0; public static final int SCALE_FIT_WIDTH = 1; public static final int SCALE_FIT_HEIGHT = 2; public static final int SCALE_FIT = 3; public static final int SCALE_FIXED = 4; public static final int START_POSITION_TOP_LEFT = 0; public static final int START_POSITION_TOP_RIGHT = 1; public static final int START_POSITION_BOTTOM_LEFT = 2; public static final int START_POSITION_BOTTOM_RIGHT = 3; public static final int START_POSITION_CENTER = 4; // TODO adjust scale max and min according to image size and screen size private static final float SCALE_MIN = 1 / 10.0f; private static final float SCALE_MAX = 10.0f; private static final long ALPHA_ANIMATION_DURING = 300L; private final RectF mDst = new RectF(); private final RectF mSrcActual = new RectF(); private final RectF mDstActual = new RectF(); private final Rect mValidRect = new Rect(); private final AlphaAnimation mAlphaAnimation; private ImageTexture mImageTexture; private int mTextureWidth; private int mTextureHeight; private int mScaleMode = SCALE_FIT; private int mStartPosition = START_POSITION_TOP_RIGHT; private float mScaleValue = 1.0f; private float mScale = 1.0f; private boolean mScaleOffsetDirty = true; private boolean mPositionInRootDirty = true; public ImageView() { mAlphaAnimation = new AlphaAnimation(0.0f, 1.0f); mAlphaAnimation.setDuration(ALPHA_ANIMATION_DURING); mAlphaAnimation.setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR); } @Override protected int getSuggestedMinimumWidth() { return Math.max(super.getSuggestedMinimumWidth(), mImageTexture == null ? 0 : mTextureWidth); } @Override protected int getSuggestedMinimumHeight() { return Math.max(super.getSuggestedMinimumHeight(), mImageTexture == null ? 0 : mTextureHeight); } @Override protected void onMeasure(int widthSpec, int heightSpec) { if (mImageTexture == null) { super.onMeasure(widthSpec, heightSpec); } else { float ratio = (float) mTextureWidth / mTextureHeight; int widthSize = MeasureSpec.getSize(widthSpec); int heightSize = MeasureSpec.getSize(heightSpec); int widthMode = MeasureSpec.getMode(widthSpec); int heightMode = MeasureSpec.getMode(heightSpec); int measureWidth = -1; int measureHeight = -1; if (widthMode == MeasureSpec.EXACTLY) { measureWidth = widthSize; if (heightMode == MeasureSpec.EXACTLY) { measureHeight = heightSize; } else { measureHeight = (int) (widthSize / ratio); if (heightMode == MeasureSpec.AT_MOST) { measureHeight = Math.min(measureHeight, heightSize); } } } else if (heightMode == MeasureSpec.EXACTLY) { measureHeight = heightSize; measureWidth = (int) (heightSize * ratio); if (widthMode == MeasureSpec.AT_MOST) { measureWidth = Math.min(measureWidth, widthSize); } } if (measureWidth == -1 || measureHeight == -1) { super.onMeasure(widthSpec, heightSpec); } else { setMeasuredSize(measureWidth, measureHeight); } } } @Override protected void onSizeChanged(int newW, int newH, int oldW, int oldH) { mScaleOffsetDirty = true; mPositionInRootDirty = true; } @Override protected void onPositionInRootChanged(int x, int y, int oldX, int oldY) { mPositionInRootDirty = true; if (mImageTexture != null) { getValidRect(mValidRect); if (!mValidRect.isEmpty()) { mImageTexture.start(); } else { mImageTexture.stop(); } } } public void getScaleDefault(float[] scaleDefault) { if (mImageTexture == null) { return; } scaleDefault[0] = 1.0f; scaleDefault[1] = (float) getWidth() / mTextureWidth; scaleDefault[2] = (float) getHeight() / mTextureHeight; scaleDefault[3] = Math.max(scaleDefault[1], scaleDefault[2]) * 2; scaleDefault[0] = MathUtils.clamp(scaleDefault[0], SCALE_MIN, SCALE_MAX); scaleDefault[1] = MathUtils.clamp(scaleDefault[1], SCALE_MIN, SCALE_MAX); scaleDefault[2] = MathUtils.clamp(scaleDefault[2], SCALE_MIN, SCALE_MAX); scaleDefault[3] = MathUtils.clamp(scaleDefault[3], SCALE_MIN, SCALE_MAX); Arrays.sort(scaleDefault); } public ImageTexture getImageTexture() { return mImageTexture; } public void setImageTexture(ImageTexture imageTexture) { // Remove callback if (mImageTexture != null) { mImageTexture.setCallback(null); mImageTexture.stop(); } int oldTextureWidth = mTextureWidth; int oldTextureHeight = mTextureHeight; mImageTexture = imageTexture; if (imageTexture != null) { imageTexture.setCallback(this); mTextureWidth = imageTexture.getWidth(); mTextureHeight = imageTexture.getHeight(); // Avoid zero and negative if (mTextureWidth <= 0) { mTextureWidth = 1; } if (mTextureHeight <= 0) { mTextureHeight = 1; } // Start alpha animation, do not show animation for image has no valid rect getValidRect(mValidRect); if (!mValidRect.isEmpty()) { startAnimation(mAlphaAnimation, true); mImageTexture.start(); } } else { mTextureWidth = 1; mTextureHeight = 1; } mScaleOffsetDirty = true; mPositionInRootDirty = true; if (oldTextureWidth != mTextureWidth || oldTextureHeight != mTextureHeight) { requestLayout(); } } public boolean isLoaded() { return mImageTexture != null; } public boolean canFling() { if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); if (mScaleOffsetDirty) { return false; } } return mDst.left < 0.0f || mDst.top < 0.0f || mDst.right > getWidth() || mDst.bottom > getHeight(); } public int getMaxDx() { if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); if (mScaleOffsetDirty) { return 0; } } return Math.max(0, -(int) mDst.left); } public int getMinDx() { if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); if (mScaleOffsetDirty) { return 0; } } return Math.min(0, getWidth() - (int) mDst.right); } public int getMaxDy() { if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); if (mScaleOffsetDirty) { return 0; } } return Math.max(0, -(int) mDst.top); } public int getMinDy() { if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); if (mScaleOffsetDirty) { return 0; } } return Math.min(0, getHeight() - (int) mDst.bottom); } public float getScale() { return mScale; } /** * If target is shorter then screen, make it in screen center. If target is * longer then parent, make sure target fill parent over */ private void adjustPosition() { RectF dst = mDst; int screenWidth = getWidth(); int screenHeight = getHeight(); float targetWidth = dst.width(); float targetHeight = dst.height(); if (targetWidth > screenWidth) { float fixXOffset = dst.left; if (fixXOffset > 0) { dst.left -= fixXOffset; dst.right -= fixXOffset; } else if ((fixXOffset = screenWidth - dst.right) > 0) { dst.left += fixXOffset; dst.right += fixXOffset; } } else { float left = (screenWidth - targetWidth) / 2; dst.offsetTo(left, dst.top); } if (targetHeight > screenHeight) { float fixYOffset = dst.top; if (fixYOffset > 0) { dst.top -= fixYOffset; dst.bottom -= fixYOffset; } else if ((fixYOffset = screenHeight - dst.bottom) > 0) { dst.top += fixYOffset; dst.bottom += fixYOffset; } } else { float top = (screenHeight - targetHeight) / 2; dst.offsetTo(dst.left, top); } } public void setScaleOffset(int scaleMode, int startPosition, float scaleValue) { mScaleMode = scaleMode; mStartPosition = startPosition; mScaleValue = scaleValue; int screenWidth = getWidth(); int screenHeight = getHeight(); if (mImageTexture == null || screenWidth == 0 || screenHeight == 0) { mScaleOffsetDirty = true; return; } int textureWidth = mTextureWidth; int textureHeight = mTextureHeight; // Set scale float targetWidth; float targetHeight; switch (scaleMode) { case SCALE_ORIGIN -> { mScale = 1.0f; targetWidth = textureWidth; targetHeight = textureHeight; } case SCALE_FIT_WIDTH -> { mScale = (float) screenWidth / textureWidth; targetWidth = screenWidth; targetHeight = textureHeight * mScale; } case SCALE_FIT_HEIGHT -> { mScale = (float) screenHeight / textureHeight; targetWidth = textureWidth * mScale; targetHeight = screenHeight; } case SCALE_FIT -> { float scaleX = (float) screenWidth / textureWidth; float scaleY = (float) screenHeight / textureHeight; if (scaleX < scaleY) { mScale = scaleX; targetWidth = screenWidth; targetHeight = textureHeight * scaleX; } else { mScale = scaleY; targetWidth = textureWidth * scaleY; targetHeight = screenHeight; } } default -> { mScale = scaleValue; targetWidth = textureWidth * scaleValue; targetHeight = textureHeight * scaleValue; } } // adjust scale, not too big, not too small if (mScale < SCALE_MIN) { mScale = SCALE_MIN; targetWidth = textureWidth * SCALE_MIN; targetHeight = textureHeight * SCALE_MIN; } else if (mScale > SCALE_MAX) { mScale = SCALE_MAX; targetWidth = textureWidth * SCALE_MAX; targetHeight = textureHeight * SCALE_MAX; } // Set mDst.left and mDst.right RectF dst = mDst; switch (startPosition) { case START_POSITION_TOP_LEFT -> { dst.left = 0; dst.top = 0; } case START_POSITION_TOP_RIGHT -> { dst.left = screenWidth - targetWidth; dst.top = 0; } case START_POSITION_BOTTOM_LEFT -> { dst.left = 0; dst.top = screenHeight - targetHeight; } case START_POSITION_BOTTOM_RIGHT -> { dst.left = screenWidth - targetWidth; dst.top = screenHeight - targetHeight; } default -> { dst.left = (screenWidth - targetWidth) / 2; dst.top = (screenHeight - targetHeight) / 2; } } // Set mDst.right and mDst.bottom dst.right = dst.left + targetWidth; dst.bottom = dst.top + targetHeight; // adjust position adjustPosition(); mScaleOffsetDirty = false; mPositionInRootDirty = true; } public void scroll(int dx, int dy, int[] remain) { // Only work after layout if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } if (mScaleOffsetDirty) { remain[0] = dx; remain[1] = dy; return; } RectF dst = mDst; int screenWidth = getWidth(); int screenHeight = getHeight(); float targetWidth = dst.width(); float targetHeight = dst.height(); if (targetWidth > screenWidth) { dst.left -= dx; dst.right -= dx; float fixXOffset = dst.left; if (fixXOffset > 0) { dst.left -= fixXOffset; dst.right -= fixXOffset; remain[0] = -(int) fixXOffset; } else if ((fixXOffset = screenWidth - dst.right) > 0) { dst.left += fixXOffset; dst.right += fixXOffset; remain[0] = (int) fixXOffset; } else { remain[0] = 0; } } else { remain[0] = dx; } if (targetHeight > screenHeight) { dst.top -= dy; dst.bottom -= dy; float fixYOffset = dst.top; if (fixYOffset > 0) { dst.top -= fixYOffset; dst.bottom -= fixYOffset; remain[1] = -(int) fixYOffset; } else if ((fixYOffset = screenHeight - dst.bottom) > 0) { dst.top += fixYOffset; dst.bottom += fixYOffset; remain[1] = (int) fixYOffset; } else { remain[1] = 0; } } else { remain[1] = dy; } if (dx != remain[0] || dy != remain[1]) { mPositionInRootDirty = true; invalidate(); } } public void scale(float focusX, float focusY, float scale) { // Only work after layout if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } if (mScaleOffsetDirty) { return; } if ((mScale == SCALE_MAX && scale >= 1.0f) || (mScale == SCALE_MIN && scale < 1.0f)) { return; } float newScale = mScale * scale; newScale = MathUtils.clamp(newScale, SCALE_MIN, SCALE_MAX); mScale = newScale; RectF dst = mDst; float left = (focusX - ((focusX - dst.left) * scale)); float top = (focusY - ((focusY - dst.top) * scale)); dst.set(left, top, (left + (mImageTexture.getWidth() * newScale)), (top + (mImageTexture.getHeight() * newScale))); // adjust position adjustPosition(); mPositionInRootDirty = true; invalidate(); } private void applyPositionInRoot() { int width = mImageTexture.getWidth(); int height = mImageTexture.getHeight(); RectF dst = mDst; RectF dstActual = mDstActual; RectF srcActual = mSrcActual; dstActual.set(dst); getValidRect(mValidRect); if (dstActual.intersect(mValidRect.left, mValidRect.top, mValidRect.right, mValidRect.bottom)) { srcActual.left = MathUtils.lerp(0, width, MathUtils.delerp(dst.left, dst.right, dstActual.left)); srcActual.right = MathUtils.lerp(0, width, MathUtils.delerp(dst.left, dst.right, dstActual.right)); srcActual.top = MathUtils.lerp(0, height, MathUtils.delerp(dst.top, dst.bottom, dstActual.top)); srcActual.bottom = MathUtils.lerp(0, height, MathUtils.delerp(dst.top, dst.bottom, dstActual.bottom)); } else { // Can't be seen, set src and dst empty srcActual.setEmpty(); dstActual.setEmpty(); } mPositionInRootDirty = false; } @Override public void onRender(GLCanvas canvas) { Texture texture = mImageTexture; if (texture == null) { return; } if (mScaleOffsetDirty) { setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } if (mPositionInRootDirty) { applyPositionInRoot(); } if (!mSrcActual.isEmpty()) { texture.draw(canvas, mSrcActual, mDstActual); } } @Override public void invalidateImageTexture(ImageTexture who) { invalidate(); } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/PagerLayoutManager.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import android.content.Context; import android.graphics.Rect; import android.util.Log; import android.view.animation.Interpolator; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import com.hippo.glview.anim.Animation; import com.hippo.glview.view.GLView; import com.hippo.glview.widget.GLProgressView; import com.hippo.glview.widget.GLTextureView; import com.hippo.yorozuya.AnimationUtils; import com.hippo.yorozuya.AssertUtils; import com.hippo.yorozuya.MathUtils; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; class PagerLayoutManager extends GalleryView.LayoutManager { public static final int MODE_LEFT_TO_RIGHT = 0; public static final int MODE_RIGHT_TO_LEFT = 1; private static final String TAG = PagerLayoutManager.class.getSimpleName(); private static final Interpolator SMOOTH_SCROLLER_INTERPOLATOR = t -> { t -= 1.0f; return t * t * t * t * t + 1.0f; }; private final int[] mScrollRemain = new int[2]; private final float[] mScaleDefault = new float[4]; private final SmoothScroller mSmoothScroller; private final PageFling mPageFling; private final SmoothScaler mSmoothScaler; private final Rect mTempRect = new Rect(); private GalleryView.Adapter mAdapter; private GLProgressView mProgress; private String mErrorStr; private GLTextureView mErrorView; private GalleryPageView mPrevious; private GalleryPageView mCurrent; private GalleryPageView mNext; @Mode private int mMode = MODE_RIGHT_TO_LEFT; private int mScaleMode; private int mStartPosition; private float mScaleValue; private int mOffset; private boolean mCanScrollBetweenPages = false; private boolean mStopAnimationFinger; private int mInterval; // Current index private int mIndex; public PagerLayoutManager(Context context, @NonNull GalleryView galleryView, int scaleMode, int startPoint, float scaleValue, int interval) { super(galleryView); mScaleMode = scaleMode; mStartPosition = startPoint; mScaleValue = scaleValue; mInterval = interval; mSmoothScroller = new SmoothScroller(); mPageFling = new PageFling(context); mSmoothScaler = new SmoothScaler(); } public void setInterval(int interval) { if (mInterval == interval) { return; } if (mAdapter != null) { int index = getInternalCurrentIndex(); GalleryView.Adapter adapter = onDetach(); mInterval = interval; onAttach(adapter); setCurrentIndex(index); mGalleryView.requestFill(); } else { mInterval = interval; } } private void resetParameters() { mOffset = 0; mCanScrollBetweenPages = false; mStopAnimationFinger = false; } private boolean cancelAllAnimations() { boolean running = mSmoothScroller.isRunning() || mPageFling.isRunning() || mSmoothScaler.isRunning(); mSmoothScroller.cancel(); mPageFling.cancel(); mSmoothScaler.cancel(); return running; } public void setMode(@Mode int mode) { if (mMode == mode) { return; } mMode = mode; if (mAdapter != null) { // It is attached, refill // Cancel all animations cancelAllAnimations(); // Remove all view removeProgress(); removeErrorView(); removeAllPages(); // Reset parameters resetParameters(); // Request fill mGalleryView.requestFill(); } } public void setScaleMode(int scaleMode) { if (mScaleMode == scaleMode) { return; } mScaleMode = scaleMode; if (mCurrent != null) { mCurrent.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } if (mPrevious != null) { mPrevious.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } if (mNext != null) { mNext.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } } public void setStartPosition(int startPosition) { if (mStartPosition == startPosition) { return; } mStartPosition = startPosition; if (mCurrent != null) { mCurrent.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } if (mPrevious != null) { mPrevious.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } if (mNext != null) { mNext.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } } @Override public void onAttach(GalleryView.Adapter adapter) { AssertUtils.assertNull("The PagerLayoutManager is attached", mAdapter); AssertUtils.assertNotNull("The adapter is null", adapter); mAdapter = adapter; // Reset parameters resetParameters(); } private void removeProgress() { if (mProgress != null) { mGalleryView.removeComponent(mProgress); mProgress = null; } } private void removeErrorView() { if (mErrorView != null) { mGalleryView.removeComponent(mErrorView); mGalleryView.releaseErrorView(mErrorView); mErrorView = null; mErrorStr = null; } } private void removePage(@NonNull GalleryPageView page) { mGalleryView.removeComponent(page); mAdapter.unbind(page); mGalleryView.releasePage(page); } private void removeAllPages() { // Remove gallery view if (mPrevious != null) { removePage(mPrevious); mPrevious = null; } if (mCurrent != null) { removePage(mCurrent); mCurrent = null; } if (mNext != null) { removePage(mNext); mNext = null; } } @Override public GalleryView.Adapter onDetach() { AssertUtils.assertNotNull("The PagerLayoutManager is not attached", mAdapter); // Cancel all animations cancelAllAnimations(); // Remove all view removeProgress(); removeErrorView(); removeAllPages(); // Clear iterator GalleryView.Adapter adapter = mAdapter; mAdapter = null; return adapter; } private GalleryPageView getLeftPage() { return mMode == MODE_LEFT_TO_RIGHT ? mPrevious : mNext; } private GalleryPageView getRightPage() { return mMode == MODE_LEFT_TO_RIGHT ? mNext : mPrevious; } private GalleryPageView obtainPage() { GalleryPageView page = mGalleryView.obtainPage(); page.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); return page; } private void layoutPage(GalleryPageView page, int widthSpec, int heightSpec, int left, int right, int bottom) { Rect rect = mTempRect; page.getValidRect(rect); boolean oldValid = !rect.isEmpty(); page.measure(widthSpec, heightSpec); page.layout(left, 0, right, bottom); page.getValidRect(rect); boolean newValid = !rect.isEmpty(); if (!oldValid && newValid) { page.getImageView().setScaleOffset(mScaleMode, mStartPosition, mScaleValue); } } @Override public void onFill() { GalleryView.Adapter adapter = mAdapter; GalleryView galleryView = mGalleryView; AssertUtils.assertNotNull("The PagerLayoutManager is not attached", adapter); int width = galleryView.getWidth(); int height = galleryView.getHeight(); int size = adapter.size(); if (size == 0) { // Get empty, show error text String errorStr = galleryView.getEmptyStr(); // Remove progress and all pages removeProgress(); removeAllPages(); // Ensure error view if (mErrorView == null) { mErrorView = galleryView.obtainErrorView(); galleryView.addComponent(mErrorView); } // Update error string if (!errorStr.equals(mErrorStr)) { mErrorStr = errorStr; galleryView.bindErrorView(mErrorView, errorStr); } // Place error view center placeCenter(mErrorView); } else { // Remove progress and error view removeProgress(); removeErrorView(); // Ensure index in range int index = mIndex; if (index < 0) { index = 0; mIndex = index; removeAllPages(); Log.e(TAG, "index < 0, index = " + index); } else if (index >= size) { index = size - 1; mIndex = index; removeAllPages(); Log.e(TAG, "index >= size, index = " + index + ", size = " + size); } // Ensure pages if (mCurrent == null) { mCurrent = obtainPage(); galleryView.addComponent(mCurrent); adapter.bind(mCurrent, index); } if (mPrevious == null && index > 0) { mPrevious = obtainPage(); galleryView.addComponent(mPrevious); adapter.bind(mPrevious, index - 1); } else if (mPrevious != null && index == 0) { removePage(mPrevious); } if (mNext == null && index < size - 1) { mNext = obtainPage(); galleryView.addComponent(mNext); adapter.bind(mNext, index + 1); } else if (mNext != null && index == size - 1) { removePage(mNext); } GalleryPageView leftPage = getLeftPage(); GalleryPageView rightPage = getRightPage(); // Fix offset final int min = rightPage == null ? 0 : -width - mInterval + 1; final int max = leftPage == null ? 0 : width + mInterval - 1; mOffset = MathUtils.clamp(mOffset, min, max); // Measure and layout pages final int offset = mOffset; final int widthSpec = GLView.MeasureSpec.makeMeasureSpec(width, GLView.MeasureSpec.EXACTLY); final int heightSpec = GLView.MeasureSpec.makeMeasureSpec(height, GLView.MeasureSpec.EXACTLY); if (mCurrent != null) { layoutPage(mCurrent, widthSpec, heightSpec, offset, width + offset, height); } if (leftPage != null) { layoutPage(leftPage, widthSpec, heightSpec, -mInterval - width + offset, -mInterval + offset, height); } if (rightPage != null) { layoutPage(rightPage, widthSpec, heightSpec, width + mInterval + offset, width + mInterval + width + offset, height); } } } @Override public void onDown() { mStopAnimationFinger = cancelAllAnimations(); } @Override public void onUp() { if (mCurrent == null) { return; } // Scroll if (mOffset != 0) { int width = mGalleryView.getWidth(); int dx; if (mOffset >= mInterval && getLeftPage() != null) { dx = mOffset - width - mInterval; } else if (mOffset <= -mInterval && getRightPage() != null) { dx = mOffset + width + mInterval; } else { dx = mOffset; } final float pageDelta = 7 * (float) Math.abs(mOffset) / (width + mInterval); int duration = (int) ((pageDelta + 1) * 100); mSmoothScroller.startSmoothScroll(dx, duration); } } @Override public void onDoubleTapConfirmed(float x, float y) { if (mCurrent == null || !mCurrent.getImageView().isLoaded()) { return; } float[] scales = mScaleDefault; ImageView image = mCurrent.getImageView(); image.getScaleDefault(scales); float scale = image.getScale(); float endScale = scales[0]; for (float value : scales) { if (scale < value - 0.01f) { endScale = value; break; } } mSmoothScaler.startSmoothScaler(x, y, scale, endScale, 300); } private void pagePrevious() { if (mIndex <= 0) { return; } mIndex--; if (mNext != null) { removePage(mNext); } mNext = mCurrent; mCurrent = mPrevious; mPrevious = null; if (mIndex > 0) { mPrevious = obtainPage(); mGalleryView.addComponent(mPrevious); mAdapter.bind(mPrevious, mIndex - 1); } } private void pageNext() { GalleryView.Adapter adapter = mAdapter; int size = adapter.size(); if (mIndex >= size - 1) { return; } mIndex++; if (mPrevious != null) { removePage(mPrevious); } mPrevious = mCurrent; mCurrent = mNext; mNext = null; if (mIndex < size - 1) { mNext = obtainPage(); mGalleryView.addComponent(mNext); adapter.bind(mNext, mIndex + 1); } } private void pageLeft() { if (mMode == MODE_LEFT_TO_RIGHT) { pagePrevious(); } else { pageNext(); } } private void pageRight() { if (mMode == MODE_LEFT_TO_RIGHT) { pageNext(); } else { pagePrevious(); } } private int scrollBetweenPages(int dx) { GalleryPageView leftPage = getLeftPage(); GalleryPageView rightPage = getRightPage(); int width = mGalleryView.getWidth(); int remain; if (dx < 0) { // Try to show left int limit; if (leftPage == null) { limit = 0; } else { limit = width + mInterval; } if (dx > mOffset - limit) { remain = 0; mOffset -= dx; } else { // Go to left page if left page not null if (leftPage != null) { pageLeft(); } remain = dx + limit - mOffset; mOffset = 0; } } else { // Try to show right int limit; if (rightPage == null) { limit = 0; } else { limit = -width - mInterval; } if (dx < mOffset - limit) { remain = 0; mOffset -= dx; } else { // Go to right page if right page not null if (rightPage != null) { pageRight(); } remain = dx + limit - mOffset; mOffset = 0; } } return remain; } public void scrollInternal(float dx, float dy) { if (mCurrent == null) { return; } boolean needFill = false; boolean canImageScroll = true; int remainX = (int) dx; int remainY = (int) dy; if (mGalleryView.isFirstScroll()) { mCanScrollBetweenPages = Math.abs(dx) > Math.abs(dy) * 1.5; } while (remainX != 0 || remainY != 0) { if (mOffset == 0 && canImageScroll) { ImageView image = mCurrent.getImageView(); image.scroll(remainX, remainY, mScrollRemain); remainX = mScrollRemain[0]; remainY = mScrollRemain[1]; canImageScroll = false; } else if (remainX == 0 || (getLeftPage() == null && mOffset == 0 && remainX < 0) || (getRightPage() == null && mOffset == 0 && remainX > 0)) { // On edge remainX = 0; remainY = 0; } else if (mCanScrollBetweenPages) { remainX = scrollBetweenPages(remainX); canImageScroll = true; needFill = true; } else { remainX = 0; remainY = 0; } } if (needFill) { mGalleryView.requestFill(); } } @Override public void onScroll(float dx, float dy, float totalX, float totalY, float x, float y) { scrollInternal(dx, dy); } @Override public void onFling(float velocityX, float velocityY) { if (mCurrent == null || mOffset != 0 || !mCurrent.getImageView().isLoaded() || !mCurrent.getImageView().canFling()) { return; } ImageView image = mCurrent.getImageView(); mPageFling.startFling((int) velocityX, image.getMinDx(), image.getMaxDx(), (int) velocityY, image.getMinDy(), image.getMaxDy()); } @Override public boolean canScale() { return mCurrent != null && mOffset == 0 && mCurrent.getImageView().isLoaded(); } @Override public void onScale(float focusX, float focusY, float scale) { if (mCurrent == null || !mCurrent.getImageView().isLoaded()) { return; } mCurrent.getImageView().scale(focusX, focusY, scale); mScaleValue = mCurrent.getImageView().getScale(); } @Override public boolean onUpdateAnimation(long time) { boolean invalidate = mSmoothScroller.calculate(time); invalidate |= mPageFling.calculate(time); invalidate |= mSmoothScaler.calculate(time); return invalidate; } @Override public void onDataChanged() { AssertUtils.assertNotNull("The PagerLayoutManager is not attached", mAdapter); // Cancel all animations cancelAllAnimations(); // Remove all views removeProgress(); removeErrorView(); removeAllPages(); // Reset parameters resetParameters(); mGalleryView.requestFill(); } @Override public void onPageLeft() { int size = mAdapter.size(); if (size <= 0 || mCurrent == null) { return; } if (mMode == MODE_LEFT_TO_RIGHT) { if (mIndex != 0) { setCurrentIndex(mIndex - 1); } } else { if (mIndex < size - 1) { setCurrentIndex(mIndex + 1); } } } @Override public void onPageRight() { int size = mAdapter.size(); if (size <= 0 || mCurrent == null) { return; } if (mMode == MODE_LEFT_TO_RIGHT) { if (mIndex < size - 1) { setCurrentIndex(mIndex + 1); } } else { if (mIndex != 0) { setCurrentIndex(mIndex - 1); } } } @Override public boolean isTapOrPressDisable() { return mStopAnimationFinger; } @Override public GalleryPageView findPageByIndex(int index) { if (mCurrent != null && mCurrent.getIndex() == index) { return mCurrent; } if (mPrevious != null && mPrevious.getIndex() == index) { return mPrevious; } if (mNext != null && mNext.getIndex() == index) { return mNext; } return null; } @Override public int getCurrentIndex() { if (mCurrent != null) { return mCurrent.getIndex(); } else { return GalleryPageView.INVALID_INDEX; } } @Override public void setCurrentIndex(int index) { int size = mAdapter.size(); if (size <= 0) { // Can't get size now, assume size is MAX size = Integer.MAX_VALUE; } if (index == mIndex || index < 0 || index >= size) { return; } if (mCurrent == null) { mIndex = index; } else if (index == mIndex - 1) { // Cancel all animations cancelAllAnimations(); // Reset parameters resetParameters(); // Go to previous pagePrevious(); // Request fill mGalleryView.requestFill(); } else if (index == mIndex + 1) { // Cancel all animations cancelAllAnimations(); // Reset parameters resetParameters(); // Go to next pageNext(); // Request fill mGalleryView.requestFill(); } else { mIndex = index; // It is attached, refill // Cancel all animations cancelAllAnimations(); // Remove all view removeProgress(); removeErrorView(); removeAllPages(); // Reset parameters resetParameters(); // Request fill mGalleryView.requestFill(); } } @Override public int getIndexUnder(float x, float y) { if (mCurrent == null) { return GalleryPageView.INVALID_INDEX; } else { int intX = (int) x; int intY = (int) y; if (mCurrent.bounds().contains(intX, intY)) { return mCurrent.getIndex(); } else if (mPrevious != null && mPrevious.bounds().contains(intX, intY)) { return mPrevious.getIndex(); } else if (mNext != null && mNext.bounds().contains(intX, intY)) { return mNext.getIndex(); } else { return GalleryPageView.INVALID_INDEX; } } } @Override int getInternalCurrentIndex() { int currentIndex = getCurrentIndex(); if (currentIndex == GalleryPageView.INVALID_INDEX) { currentIndex = mIndex; } return currentIndex; } @IntDef({MODE_LEFT_TO_RIGHT, MODE_RIGHT_TO_LEFT}) @Retention(RetentionPolicy.SOURCE) private @interface Mode { } private class SmoothScroller extends Animation { private int mDx; private int mLastX; public SmoothScroller() { setInterpolator(SMOOTH_SCROLLER_INTERPOLATOR); } public void startSmoothScroll(int dx, int duration) { mDx = dx; mLastX = 0; setDuration(duration); start(); mGalleryView.invalidate(); } @Override protected void onCalculate(float progress) { int x = (int) (mDx * progress); int offsetX = x - mLastX; while (offsetX != 0) { int oldOffsetX = offsetX; offsetX = scrollBetweenPages(offsetX); // Avoid loop infinitely if (offsetX == oldOffsetX) { break; } else { mGalleryView.requestFill(); } } mLastX = x; } } private class PageFling extends Fling { private final int[] mTemp = new int[2]; private int mDx; private int mDy; private int mLastX; private int mLastY; public PageFling(Context context) { super(context); } public void startFling(int velocityX, int minX, int maxX, int velocityY, int minY, int maxY) { mDx = (int) (getSplineFlingDistance(velocityX) * Math.signum(velocityX)); mDy = (int) (getSplineFlingDistance(velocityY) * Math.signum(velocityY)); mLastX = 0; mLastY = 0; int durationX = getSplineFlingDuration(velocityX); int durationY = getSplineFlingDuration(velocityY); if (mDx < minX) { durationX = adjustDuration(mDx, minX, durationX); mDx = minX; } if (mDx > maxX) { durationX = adjustDuration(mDx, maxX, durationX); mDx = maxX; } if (mDy < minY) { durationY = adjustDuration(mDy, minY, durationY); mDy = minY; } if (mDy > maxY) { durationY = adjustDuration(mDy, maxY, durationY); mDy = maxY; } if (mDx == 0 && mDy == 0) { return; } setDuration(Math.max(durationX, durationY)); start(); mGalleryView.invalidate(); } @Override protected void onCalculate(float progress) { int x = (int) (mDx * progress); int y = (int) (mDy * progress); int offsetX = x - mLastX; int offsetY = y - mLastY; if (mCurrent != null && (offsetX != 0 || offsetY != 0)) { mCurrent.getImageView().scroll(-offsetX, -offsetY, mTemp); } mLastX = x; mLastY = y; } } private class SmoothScaler extends Animation { private float mFocusX; private float mFocusY; private float mStartScale; private float mEndScale; private float mLastScale; public SmoothScaler() { setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR); } public void startSmoothScaler(float focusX, float focusY, float startScale, float endScale, int duration) { mFocusX = focusX; mFocusY = focusY; mStartScale = startScale; mEndScale = endScale; mLastScale = startScale; setDuration(duration); start(); mGalleryView.invalidate(); } @Override protected void onCalculate(float progress) { if (mCurrent == null) { return; } float scale = MathUtils.lerp(mStartScale, mEndScale, progress); mCurrent.getImageView().scale(mFocusX, mFocusY, scale / mLastScale); mLastScale = scale; mScaleValue = scale; } } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/ScrollLayoutManager.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import android.content.Context; import android.graphics.Rect; import android.util.Log; import androidx.annotation.NonNull; import com.hippo.glview.anim.Animation; import com.hippo.glview.view.GLView; import com.hippo.glview.widget.GLProgressView; import com.hippo.glview.widget.GLTextureView; import com.hippo.yorozuya.AnimationUtils; import com.hippo.yorozuya.AssertUtils; import com.hippo.yorozuya.MathUtils; import java.util.Iterator; import java.util.LinkedList; import java.util.List; class ScrollLayoutManager extends GalleryView.LayoutManager { private static final String TAG = ScrollLayoutManager.class.getSimpleName(); private static final float RESERVATION = 0.5f; private static final float DEFAULT_SCALE = 1.0f; private static final float MAX_SCALE = 2.0f; private static final float MIN_SCALE = 0.3f; private static final float SCALE_ERROR = 0.01f; private static final int INVALID_TOP = Integer.MAX_VALUE; private final LinkedList mPages = new LinkedList<>(); private final LinkedList mTempPages = new LinkedList<>(); private final PageFling mPageFling; private final SmoothScaler mSmoothScaler; private GalleryView.Adapter mAdapter; private GLProgressView mProgress; private String mErrorStr; private GLTextureView mErrorView; private float mScale = DEFAULT_SCALE; private int mOffsetX; private int mOffsetY; private int mKeepTopPageIndex = GalleryPageView.INVALID_INDEX; private int mKeepTop = INVALID_TOP; private int mFirstShownPageIndex = GalleryPageView.INVALID_INDEX; private boolean mScrollUp; private boolean mFlingUp; private boolean mStopAnimationFinger; private int mInterval; // Current index private int mIndex; private int mBottomStateBottom; private boolean mBottomStateHasNext; public ScrollLayoutManager(Context context, @NonNull GalleryView galleryView, int interval) { super(galleryView); mInterval = interval; mPageFling = new PageFling(context); mSmoothScaler = new SmoothScaler(); } public void setInterval(int interval) { if (mInterval == interval) { return; } if (mAdapter != null) { int index = getInternalCurrentIndex(); GalleryView.Adapter adapter = onDetach(); mInterval = interval; onAttach(adapter); setCurrentIndex(index); mGalleryView.requestFill(); } else { mInterval = interval; } } private void resetParameters() { mScale = DEFAULT_SCALE; mOffsetX = 0; mOffsetY = 0; mKeepTopPageIndex = GalleryPageView.INVALID_INDEX; mKeepTop = INVALID_TOP; mFirstShownPageIndex = GalleryPageView.INVALID_INDEX; mScrollUp = false; mFlingUp = false; mStopAnimationFinger = false; } // Return true for animations are running private boolean cancelAllAnimations() { boolean running = mPageFling.isRunning() || mSmoothScaler.isRunning(); mPageFling.cancel(); mSmoothScaler.cancel(); return running; } @Override public void onAttach(GalleryView.Adapter adapter) { AssertUtils.assertNull("The ScrollLayoutManager is attached", mAdapter); AssertUtils.assertNotNull("The iterator is null", adapter); mAdapter = adapter; // Reset parameters resetParameters(); } private void removeProgress() { if (mProgress != null) { mGalleryView.removeComponent(mProgress); mProgress = null; } } private void removeErrorView() { if (mErrorView != null) { mGalleryView.removeComponent(mErrorView); mGalleryView.releaseErrorView(mErrorView); mErrorView = null; mErrorStr = null; } } private void removePage(@NonNull GalleryPageView page) { mGalleryView.removeComponent(page); mAdapter.unbind(page); mGalleryView.releasePage(page); } private void removeAllPages() { for (GalleryPageView page : mPages) { removePage(page); } mPages.clear(); } @Override public GalleryView.Adapter onDetach() { AssertUtils.assertNotNull("The PagerLayoutManager is not attached", mAdapter); // Cancel all animations cancelAllAnimations(); // Remove all view removeProgress(); removeErrorView(); removeAllPages(); // Clear iterator GalleryView.Adapter iterator = mAdapter; mAdapter = null; return iterator; } private GalleryPageView obtainPage() { GalleryPageView page = mGalleryView.obtainPage(); page.getImageView().setScaleOffset(ImageView.SCALE_FIT, ImageView.START_POSITION_TOP_RIGHT, 1.0f); return page; } private GalleryPageView getPageForIndex(List pages, int index, boolean remove) { for (Iterator iterator = pages.iterator(); iterator.hasNext(); ) { GalleryPageView page = iterator.next(); if (index == page.getIndex()) { if (remove) { iterator.remove(); } return page; } } return null; } private boolean isInScreen(GalleryPageView page, boolean includeFirst) { int height = mGalleryView.getHeight(); Rect bound = page.bounds(); int pageTop = bound.top; int pageBottom = bound.bottom; return (includeFirst && pageTop >= 0 && pageTop < height) || (pageBottom > 0 && pageBottom <= height) || (pageTop < 0 && pageBottom > height); } private float getReservation() { return Math.max(RESERVATION, (((1 + 2 * RESERVATION) * mScale) - 1) / 2); } private void fillPages(int startIndex, int startOffset) { final GalleryView.Adapter adapter = mAdapter; final GalleryView galleryView = mGalleryView; final LinkedList pages = mPages; final LinkedList tempPages = mTempPages; final int width = galleryView.getWidth(); final int height = galleryView.getHeight(); final int pageWidth = (int) (width * mScale); final int size = adapter.size(); final int interval = mInterval; final float reservation = getReservation(); final int minY = (int) (-height * reservation); final int maxY = (int) (height * (1 + reservation)); final int widthSpec = GLView.MeasureSpec.makeMeasureSpec(pageWidth, GLView.MeasureSpec.EXACTLY); final int heightSpec = GLView.MeasureSpec.makeMeasureSpec(height, GLView.MeasureSpec.UNSPECIFIED); // Fix start index and start offset if (startIndex < 0) { startIndex = 0; startOffset = 0; } else if (startIndex >= size) { startIndex = size - 1; startOffset = 0; } else if (startOffset < minY) { while (true) { GalleryPageView page = getPageForIndex(pages, startIndex, false); if (null == page) { startOffset = minY; break; } else { page.measure(widthSpec, heightSpec); if (startOffset + page.getHeight() > minY) { break; } else if (size - 1 == startIndex) { startOffset = 0; break; } else { ++startIndex; startOffset += page.getHeight() + interval; if (startOffset >= minY) { break; } } } } } else if (startOffset >= maxY) { if (0 == startIndex) { startOffset = 0; } else { --startIndex; int startBottomOffset = startOffset - interval; while (true) { GalleryPageView page = getPageForIndex(pages, startIndex, false); if (null == page) { startOffset = maxY - 1; break; } else { page.measure(widthSpec, heightSpec); startOffset = startBottomOffset - page.getHeight(); if (startOffset < maxY) { break; } else if (0 == startIndex) { startOffset = 0; break; } else { --startIndex; startBottomOffset = startOffset - interval; } } } } } // Put page to temp list tempPages.addAll(pages); pages.clear(); // Sanitize offsetX int margin = pageWidth - width; if (margin >= 0) { mOffsetX = MathUtils.clamp(mOffsetX, -margin, 0); } else { mOffsetX = -margin / 2; } // Layout start page GalleryPageView page = getPageForIndex(tempPages, startIndex, true); if (null == page) { page = obtainPage(); galleryView.addComponent(page); adapter.bind(page, startIndex); } pages.add(page); page.measure(widthSpec, heightSpec); page.layout(mOffsetX, startOffset, mOffsetX + pageWidth, startOffset + page.getMeasuredHeight()); // Prepare for check up and down int bottomOffset = startOffset - interval; int topOffset = startOffset + page.getMeasuredHeight() + interval; // Check up int index = startIndex - 1; while (bottomOffset > minY && index >= 0) { page = getPageForIndex(tempPages, index, true); if (null == page) { page = obtainPage(); galleryView.addComponent(page); adapter.bind(page, index); } pages.addFirst(page); page.measure(widthSpec, heightSpec); page.layout(mOffsetX, bottomOffset - page.getMeasuredHeight(), mOffsetX + pageWidth, bottomOffset); // Update bottomOffset -= page.getMeasuredHeight() + interval; index--; } // Avoid space in top page = pages.getFirst(); if (0 == page.getIndex() && page.bounds().top > 0) { int offset = -page.bounds().top; for (GalleryPageView p : pages) { p.offsetTopAndBottom(offset); } topOffset += offset; } // Check down index = startIndex + 1; while (topOffset < maxY && index < size) { page = getPageForIndex(tempPages, index, true); if (null == page) { page = obtainPage(); galleryView.addComponent(page); adapter.bind(page, index); } pages.addLast(page); page.measure(widthSpec, heightSpec); page.layout(mOffsetX, topOffset, mOffsetX + pageWidth, topOffset + page.getMeasuredHeight()); // Update topOffset += page.getMeasuredHeight() + interval; index++; } // Avoid space in bottom if (size - 1 == pages.getLast().getIndex()) { while (true) { page = pages.getLast(); int pagesBottom = page.bounds().bottom; if (pagesBottom >= height) { break; } page = pages.getFirst(); index = page.getIndex(); if (0 == index) { break; } --index; int pagesTop = page.bounds().top; page = getPageForIndex(tempPages, index, true); if (null == page) { page = obtainPage(); galleryView.addComponent(page); adapter.bind(page, index); } pages.addFirst(page); page.measure(widthSpec, heightSpec); int offset = Math.min(height - pagesBottom, page.getMeasuredHeight()); for (GalleryPageView p : pages) { p.offsetTopAndBottom(offset); } int bottom = pagesTop - interval + offset; page.layout(mOffsetX, bottom - page.getMeasuredHeight(), mOffsetX + pageWidth, bottom); } } // Remove remain page for (GalleryPageView p : tempPages) { removePage(p); } tempPages.clear(); // Update state if (!pages.isEmpty()) { page = pages.getFirst(); mIndex = page.getIndex(); mOffsetY = page.bounds().top; } } @Override public void onFill() { GalleryView.Adapter adapter = mAdapter; GalleryView galleryView = mGalleryView; AssertUtils.assertNotNull("The PagerLayoutManager is not attached", adapter); int size = adapter.size(); if (size == 0) { // Get empty, show error text String errorStr = galleryView.getEmptyStr(); // Remove progress and all pages removeProgress(); removeAllPages(); // Ensure error view if (mErrorView == null) { mErrorView = galleryView.obtainErrorView(); galleryView.addComponent(mErrorView); } // Update error string if (!errorStr.equals(mErrorStr)) { mErrorStr = errorStr; galleryView.bindErrorView(mErrorView, errorStr); } // Place error view center placeCenter(mErrorView); } else { // Remove progress and error view removeProgress(); removeErrorView(); // Ensure index in range int index = mIndex; if (index < 0) { Log.e(TAG, "index < 0, index = " + index); index = 0; mIndex = index; removeAllPages(); } else if (index >= size) { Log.e(TAG, "index >= size, index = " + index + ", size = " + size); index = size - 1; mIndex = index; removeAllPages(); } // Find keep index and keep top int keepTop = INVALID_TOP; int keepTopIndex; if (GalleryPageView.INVALID_INDEX != mKeepTopPageIndex) { keepTopIndex = mKeepTopPageIndex; keepTop = mKeepTop; mKeepTopPageIndex = GalleryPageView.INVALID_INDEX; } else if (GalleryPageView.INVALID_INDEX != mFirstShownPageIndex) { keepTopIndex = mFirstShownPageIndex; } else { keepTopIndex = GalleryPageView.INVALID_INDEX; } if (GalleryPageView.INVALID_INDEX != keepTopIndex && INVALID_TOP == keepTop) { keepTop = mOffsetY; for (GalleryPageView page : mPages) { // Check keep page if (keepTopIndex == page.getIndex()) { break; } keepTop += page.getHeight() + mInterval; } } int startIndex; int startOffset; if (GalleryPageView.INVALID_INDEX != keepTopIndex) { startIndex = keepTopIndex; startOffset = keepTop; } else { startIndex = mIndex; startOffset = mOffsetY; } fillPages(startIndex, startOffset); // Get first shown image mFirstShownPageIndex = GalleryPageView.INVALID_INDEX; for (GalleryPageView page : mPages) { // Check first shown loaded page if ((mScrollUp || mFlingUp) && !page.isLoaded()) { continue; } if (isInScreen(page, true)) { mFirstShownPageIndex = page.getIndex(); break; } } } } @Override public void onDown() { mScrollUp = false; mStopAnimationFinger = cancelAllAnimations(); } @Override public void onUp() { mScrollUp = false; } @Override public void onDoubleTapConfirmed(float x, float y) { if (mPages.isEmpty()) { return; } float startScale = mScale; float endScale; if (startScale < DEFAULT_SCALE - SCALE_ERROR) { endScale = DEFAULT_SCALE; } else if (startScale < MAX_SCALE - SCALE_ERROR) { endScale = MAX_SCALE; } else { endScale = DEFAULT_SCALE; } mSmoothScaler.startSmoothScaler(x, y, startScale, endScale, 300); } private void getBottomState() { List pages = mPages; int bottom = mOffsetY; int i = 0; for (GalleryPageView page : pages) { if (i != 0) { bottom += mInterval; } bottom += page.getHeight(); i++; } boolean hasNext = mIndex + pages.size() < mAdapter.size(); mBottomStateBottom = bottom; mBottomStateHasNext = hasNext; } // True for get top or bottom private boolean scrollInternal(float dx, float dy) { if (mPages.isEmpty()) { return false; } GalleryView galleryView = mGalleryView; int width = galleryView.getWidth(); int height = galleryView.getHeight(); int pageWidth = (int) (width * mScale); final float reservation = getReservation(); boolean requestFill = false; boolean result = false; int margin = pageWidth - width; int dxInt = (int) dx; if (margin > 0 && 0 != dxInt) { int oldOffsetX = mOffsetX; int exceptOffsetX = oldOffsetX - dxInt; mOffsetX = MathUtils.clamp(exceptOffsetX, -margin, 0); if (mOffsetX != oldOffsetX) { requestFill = true; } } int remainY = (int) dy; while (remainY != 0) { if (remainY < 0) { // Try to show top int limit; if (mIndex > 0) { limit = (int) (-height * reservation) + mInterval; } else { limit = 0; } if (mOffsetY - remainY <= limit) { mOffsetY -= remainY; remainY = 0; requestFill = true; } else { if (mIndex > 0) { mOffsetY = limit; // Offset one pixel to avoid infinite loop ++mOffsetY; ++remainY; galleryView.forceFill(); requestFill = false; } else { if (mOffsetY != limit) { mOffsetY = limit; requestFill = true; } remainY = 0; result = true; } } } else { // Try to show bottom getBottomState(); int bottom = mBottomStateBottom; boolean hasNext = mBottomStateHasNext; int limit; if (hasNext) { limit = (int) (height * (1 + reservation)) - mInterval; } else { limit = height; } // Fix limit for page not fill screen limit = Math.min(bottom, limit); if (bottom - remainY >= limit) { mOffsetY -= remainY; remainY = 0; requestFill = true; } else { if (hasNext) { mOffsetY -= bottom - limit; remainY = remainY + limit - bottom; // Offset one pixel to avoid infinite loop --mOffsetY; --remainY; galleryView.forceFill(); requestFill = false; } else { if (mOffsetY != limit) { mOffsetY -= bottom - limit; requestFill = true; } remainY = 0; result = true; } } } } if (requestFill) { mGalleryView.requestFill(); } return result; } @Override public void onScroll(float dx, float dy, float totalX, float totalY, float x, float y) { mKeepTopPageIndex = GalleryPageView.INVALID_INDEX; mKeepTop = INVALID_TOP; mScrollUp = dy < 0; scrollInternal(dx, dy); } @Override public void onFling(float velocityX, float velocityY) { if (mPages.isEmpty()) { return; } mKeepTopPageIndex = GalleryPageView.INVALID_INDEX; mKeepTop = INVALID_TOP; mFlingUp = velocityY > 0; int maxX; int minX; int width = mGalleryView.getWidth(); int pageWidth = (int) (width * mScale); int margin = pageWidth - width; if (margin > 0) { maxX = -mOffsetX; minX = -margin + mOffsetX; } else { maxX = 0; minX = 0; } int maxY; if (mIndex > 0) { maxY = Integer.MAX_VALUE; } else { maxY = -mOffsetY; } getBottomState(); int bottom = mBottomStateBottom; boolean hasNext = mBottomStateHasNext; int minY; if (hasNext) { minY = Integer.MIN_VALUE; } else { minY = mGalleryView.getHeight() - bottom; } mPageFling.startFling((int) velocityX, minX, maxX, (int) velocityY, minY, maxY); } @Override public boolean canScale() { return !mPages.isEmpty(); } @Override public void onScale(float focusX, float focusY, float scale) { if (mPages.isEmpty()) { return; } float oldScale = mScale; mScale = MathUtils.clamp(oldScale * scale, MIN_SCALE, MAX_SCALE); scale = mScale / oldScale; if (oldScale != mScale) { GalleryPageView page = null; // Keep scale page origin position for (GalleryPageView p : mPages) { if (p.bounds().top < focusY) { page = p; } else { break; } } if (null != page) { mKeepTopPageIndex = page.getIndex(); mKeepTop = page.bounds().top; mGalleryView.forceFill(); int oldKeepTop = mKeepTop; mKeepTop = INVALID_TOP; // Apply scroll int newOffsetX = (int) (focusX - ((focusX - mOffsetX) * scale)); int newKeepTop; if (page.isLoaded()) { newKeepTop = (int) (focusY - ((focusY - oldKeepTop) * scale)); } else { newKeepTop = oldKeepTop; } scrollInternal(mOffsetX - newOffsetX, oldKeepTop - newKeepTop); } else { Log.e(TAG, "Can't find target page"); mKeepTopPageIndex = GalleryPageView.INVALID_INDEX; mKeepTop = INVALID_TOP; mGalleryView.forceFill(); } } } @Override public boolean onUpdateAnimation(long time) { boolean invalidate = mPageFling.calculate(time); invalidate |= mSmoothScaler.calculate(time); return invalidate; } @Override public void onDataChanged() { AssertUtils.assertNotNull("The PagerLayoutManager is not attached", mAdapter); // Cancel all animations cancelAllAnimations(); // Remove all views removeProgress(); removeErrorView(); removeAllPages(); // Reset parameters resetParameters(); mGalleryView.requestFill(); } @Override public void onPageLeft() { if (mAdapter.size() <= 0 || mPages.isEmpty()) { return; } /////// // UP /////// GalleryView galleryView = mGalleryView; if (mIndex > 0 || mOffsetY < 0) { // Cancel all animations cancelAllAnimations(); // Get first shown page GalleryPageView previousPage = null; GalleryPageView firstShownPage = null; for (GalleryPageView p : mPages) { if (isInScreen(p, true)) { firstShownPage = p; break; } previousPage = p; } int height = galleryView.getHeight(); int maxOffset = height - mInterval; if (null == firstShownPage) { Log.e(TAG, "Can't find first shown page when paging left"); mOffsetY += height / 2; } else { int firstShownTop = firstShownPage.bounds().top; if (firstShownTop >= 0) { if (null == previousPage) { Log.e(TAG, "Can't find previous page when paging left and offsetY == 0"); mOffsetY += height / 2; } else { mOffsetY += Math.min(maxOffset, -previousPage.bounds().top); } } else { mOffsetY += Math.min(maxOffset, -firstShownTop); } } // Request fill mGalleryView.requestFill(); } } @Override public void onPageRight() { if (mAdapter.size() <= 0 || mPages.isEmpty()) { return; } ///////// // DOWN ///////// GalleryView galleryView = mGalleryView; getBottomState(); int bottom = mBottomStateBottom; boolean hasNext = mBottomStateHasNext; if (hasNext || bottom > galleryView.getHeight()) { // Cancel all animations cancelAllAnimations(); // Get first shown page GalleryPageView lastShownPage = null; GalleryPageView nextPage = null; for (GalleryPageView p : mPages) { if (isInScreen(p, true)) { lastShownPage = p; } else if (null != lastShownPage) { nextPage = p; break; } } int height = galleryView.getHeight(); int maxOffset = height - mInterval; if (null == lastShownPage) { Log.e(TAG, "Can't find last shown page when paging left"); mOffsetY -= height / 2; } else { int lastShownBottom = lastShownPage.bounds().bottom; if (lastShownBottom <= height) { if (null == nextPage) { Log.e(TAG, "Can't find previous page when paging left and offsetY == 0"); mOffsetY -= height / 2; } else { mOffsetY -= Math.min(maxOffset, nextPage.bounds().bottom - height); } } else { mOffsetY -= Math.min(maxOffset, lastShownBottom - height); } } // Request fill mGalleryView.requestFill(); } } @Override public boolean isTapOrPressDisable() { return mStopAnimationFinger; } @Override public GalleryPageView findPageByIndex(int index) { for (GalleryPageView page : mPages) { if (page.getIndex() == index) { return page; } } return null; } @Override public int getCurrentIndex() { int index = GalleryPageView.INVALID_INDEX; for (GalleryPageView page : mPages) { if (isInScreen(page, false)) { index = page.getIndex(); } } return index; } @Override public void setCurrentIndex(int index) { int size = mAdapter.size(); if (size <= 0) { // Can't get size now, assume size is MAX size = Integer.MAX_VALUE; } if (index < 0 || index >= size) { return; } mKeepTopPageIndex = index; mKeepTop = INVALID_TOP; if (mPages.isEmpty()) { mIndex = index; } else { // Fix the index page GalleryPageView targetPage = null; for (GalleryPageView page : mPages) { if (page.getIndex() == index) { targetPage = page; break; } } if (targetPage != null) { // Cancel all animations cancelAllAnimations(); mOffsetY -= targetPage.bounds().top; // Request fill mGalleryView.requestFill(); } else { mIndex = index; mOffsetY = 0; // Cancel all animations cancelAllAnimations(); // Remove all view removeProgress(); removeErrorView(); removeAllPages(); // Request fill mGalleryView.requestFill(); } } } @Override public int getIndexUnder(float x, float y) { if (!mPages.isEmpty()) { int intX = (int) x; int intY = (int) y; for (GalleryPageView page : mPages) { if (page.bounds().contains(intX, intY)) { return page.getIndex(); } } } return GalleryPageView.INVALID_INDEX; } @Override int getInternalCurrentIndex() { int currentIndex = getCurrentIndex(); if (currentIndex == GalleryPageView.INVALID_INDEX) { currentIndex = mIndex; } return currentIndex; } private class PageFling extends Fling { private int mDx; private int mDy; private int mLastX; private int mLastY; public PageFling(Context context) { super(context); } public void startFling(int velocityX, int minX, int maxX, int velocityY, int minY, int maxY) { mDx = (int) (getSplineFlingDistance(velocityX) * Math.signum(velocityX)); mDy = (int) (getSplineFlingDistance(velocityY) * Math.signum(velocityY)); mLastX = 0; mLastY = 0; int durationX = getSplineFlingDuration(velocityX); int durationY = getSplineFlingDuration(velocityY); if (mDx < minX) { durationX = adjustDuration(mDx, minX, durationX); mDx = minX; } if (mDx > maxX) { durationX = adjustDuration(mDx, maxX, durationX); mDx = maxX; } if (mDy < minY) { durationY = adjustDuration(mDy, minY, durationY); mDy = minY; } if (mDy > maxY) { durationY = adjustDuration(mDy, maxY, durationY); mDy = maxY; } if (mDx == 0 && mDy == 0) { return; } setDuration(Math.max(durationX, durationY)); start(); mGalleryView.invalidate(); } @Override protected void onCalculate(float progress) { int x = (int) (mDx * progress); int y = (int) (mDy * progress); int offsetX = x - mLastX; int offsetY = y - mLastY; if (scrollInternal(-offsetX, -offsetY)) { cancel(); onFinish(); } mLastX = x; mLastY = y; } @Override protected void onFinish() { mFlingUp = false; getBottomState(); } } private class SmoothScaler extends Animation { private float mFocusX; private float mFocusY; private float mStartScale; private float mEndScale; private float mLastScale; public SmoothScaler() { setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR); } public void startSmoothScaler(float focusX, float focusY, float startScale, float endScale, int duration) { mFocusX = focusX; mFocusY = focusY; mStartScale = startScale; mEndScale = endScale; mLastScale = startScale; setDuration(duration); start(); mGalleryView.invalidate(); } @Override protected void onCalculate(float progress) { if (mPages.isEmpty()) { return; } float scale = MathUtils.lerp(mStartScale, mEndScale, progress); onScale(mFocusX, mFocusY, scale / mLastScale); mLastScale = scale; } } } ================================================ FILE: app/src/main/java/com/hippo/glgallery/SimpleAdapter.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glgallery; import androidx.annotation.NonNull; import com.hippo.glview.image.ImageTexture; import com.hippo.glview.image.ImageWrapper; import com.hippo.glview.view.GLRootView; public class SimpleAdapter extends GalleryView.Adapter implements GalleryProvider.Listener { private final GalleryProvider mProvider; private final ImageTexture.Uploader mUploader; public SimpleAdapter(@NonNull GLRootView glRootView, @NonNull GalleryProvider provider) { mProvider = provider; mUploader = new ImageTexture.Uploader(glRootView); } public void clearUploader() { mUploader.clear(); } @Override public void onBind(GalleryPageView view, int index) { mProvider.request(index); view.showInfo(); view.setImage(null); view.setPage(index + 1); view.setProgress(GalleryPageView.PROGRESS_INDETERMINATE); view.setError(null, null); } @Override public void onUnbind(GalleryPageView view, int index) { mProvider.cancelRequest(index); view.setImage(null); view.setError(null, null); } @Override public int size() { return mProvider.getSize(); } @Override public void onDataChanged() { mGalleryView.onDataChanged(); } @Override public void onPageWait(int index) { GalleryPageView page = findPageByIndex(index); if (page != null) { page.showInfo(); page.setImage(null); page.setPage(index + 1); page.setProgress(GalleryPageView.PROGRESS_INDETERMINATE); page.setError(null, null); } } @Override public void onPagePercent(int index, float percent) { GalleryPageView page = findPageByIndex(index); if (page != null) { page.showInfo(); page.setImage(null); page.setPage(index + 1); page.setProgress(percent); page.setError(null, null); } } @Override public void onPageSucceed(int index, ImageWrapper image) { GalleryPageView page = findPageByIndex(index); if (page != null) { if (image.obtain()) { ImageTexture imageTexture = new ImageTexture(image); mUploader.addTexture(imageTexture); page.showImage(); page.setImage(imageTexture); page.setPage(index + 1); page.setProgress(GalleryPageView.PROGRESS_GONE); page.setError(null, null); } else { // The image is recycled, request again. // TODO request loop ? mProvider.request(index); } } } @Override public void onPageFailed(int index, String error) { GalleryPageView page = findPageByIndex(index); if (page != null) { page.showInfo(); page.setImage(null); page.setPage(index + 1); page.setProgress(GalleryPageView.PROGRESS_GONE); page.setError(error, mGalleryView); } } @Override public void onDataChanged(int index) { GalleryPageView page = findPageByIndex(index); if (page != null) { mProvider.request(index); } } private GalleryPageView findPageByIndex(int index) { return mGalleryView != null ? mGalleryView.findPageByIndex(index) : null; } } ================================================ FILE: app/src/main/java/com/hippo/glview/anim/AlphaAnimation.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.anim; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.yorozuya.MathUtils; public class AlphaAnimation extends CanvasAnimation { private final float mStartAlpha; private final float mEndAlpha; private float mCurrentAlpha; public AlphaAnimation(float from, float to) { mStartAlpha = from; mEndAlpha = to; mCurrentAlpha = from; } @Override public void apply(GLCanvas canvas) { canvas.multiplyAlpha(mCurrentAlpha); } @Override public int getCanvasSaveFlags() { return GLCanvas.SAVE_FLAG_ALPHA; } @Override protected void onCalculate(float progress) { mCurrentAlpha = MathUtils.clamp(mStartAlpha + (mEndAlpha - mStartAlpha) * progress, 0f, 1f); } } ================================================ FILE: app/src/main/java/com/hippo/glview/anim/Animation.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.anim; import android.os.SystemClock; import android.view.animation.Interpolator; import com.hippo.yorozuya.MathUtils; // Animation calculates a value according to the current input time. // // 1. First we need to use setDuration(int) to set the duration of the // animation. The duration is in milliseconds. // 2. Then we should call start(). The actual start time is the first value // passed to calculate(long). // 3. Each time we want to get an animation value, we call // calculate(long currentTimeMillis) to ask the Animation to calculate it. // The parameter passed to calculate(long) should be nonnegative. // 4. Use get() to get that value. // // In step 3, onCalculate(float progress) is called so subclasses can calculate // the value according to progress (progress is a value in [0,1]). // // Before onCalculate(float) is called, There is an optional interpolator which // can change the progress value. The interpolator can be set by // setInterpolator(Interpolator). If the interpolator is used, the value passed // to onCalculate may be (for example, the overshoot effect). // // The isActive() method returns true after the animation start() is called and // before calculate is passed a value which reaches the duration of the // animation. // // The start() method can be called again to restart the Animation. // abstract public class Animation { /** * When the animation reaches the end and repeatCount is INFINITE * or a positive value, the animation restarts from the beginning. */ public static final int RESTART = 1; /** * When the animation reaches the end and repeatCount is INFINITE * or a positive value, the animation reverses direction on every iteration. */ public static final int REVERSE = 2; /** * This value used used with the {@link #setRepeatCount(int)} property to repeat * the animation indefinitely. */ public static final int INFINITE = -1; private static final long ANIMATION_START = -1; private static final long NO_ANIMATION = -2; private long mStartTime = NO_ANIMATION; private long mDuration; private Interpolator mInterpolator; private int mRepeatCount; private int mRunnedCount; private long mLastFrameTime; public void setInterpolator(Interpolator interpolator) { mInterpolator = interpolator; } public void setDuration(long duration) { mDuration = duration; } public void setRepeatCount(int repeatCount) { mRepeatCount = repeatCount; } public boolean isRunning() { return mStartTime > 0 || mStartTime == ANIMATION_START; } public long getLastFrameTime() { return mLastFrameTime; } public void start() { if (mStartTime == NO_ANIMATION) { mStartTime = ANIMATION_START; mRunnedCount = 0; mLastFrameTime = 0; } } public void startAt(long time) { start(); setStartTime(time); } public void startNow() { start(); setStartTime(SystemClock.uptimeMillis()); } public void setStartTime(long time) { mStartTime = time; } public void cancel() { mStartTime = NO_ANIMATION; } public void reset() { mStartTime = ANIMATION_START; mRunnedCount = 0; mLastFrameTime = 0; } public boolean calculate(long currentTimeMillis) { if (mStartTime == NO_ANIMATION) { return false; } if (mStartTime == ANIMATION_START) { mStartTime = currentTimeMillis; } mLastFrameTime = currentTimeMillis; long elapse = currentTimeMillis - mStartTime; float x = MathUtils.clamp(mDuration == 0.0f ? 1.0f : (float) elapse / mDuration, 0.0f, 1.0f); // Avoid NaN Interpolator i = mInterpolator; onCalculate(i != null ? i.getInterpolation(x) : x); // It is ok to call cancel() in onCalculate() if (mStartTime != NO_ANIMATION && elapse >= mDuration) { mRunnedCount++; if (mRunnedCount >= mRepeatCount && mRepeatCount != INFINITE) { onFinish(); mStartTime = NO_ANIMATION; } else { mStartTime += elapse; } } return mStartTime != NO_ANIMATION; } abstract protected void onCalculate(float progress); protected void onFinish() { } } ================================================ FILE: app/src/main/java/com/hippo/glview/anim/CanvasAnimation.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.anim; import com.hippo.glview.glrenderer.GLCanvas; public abstract class CanvasAnimation extends Animation { public abstract int getCanvasSaveFlags(); public abstract void apply(GLCanvas canvas); } ================================================ FILE: app/src/main/java/com/hippo/glview/anim/FloatAnimation.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.anim; import com.hippo.yorozuya.MathUtils; public class FloatAnimation extends Animation { private float mFrom; private float mTo; private float mCurrent; public void setRange(float from, float to) { mFrom = from; mTo = to; } @Override protected void onCalculate(float progress) { mCurrent = MathUtils.lerp(mFrom, mTo, progress); } public float get() { return mCurrent; } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/BasicTexture.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.RectF; import android.util.Log; import com.hippo.yorozuya.MathUtils; import java.util.WeakHashMap; // BasicTexture is a Texture corresponds to a real GL texture. // The state of a BasicTexture indicates whether its data is loaded to GL memory. // If a BasicTexture is loaded into GL memory, it has a GL texture id. public abstract class BasicTexture implements Texture { protected static final int UNSPECIFIED = -1; protected static final int STATE_UNLOADED = 0; protected static final int STATE_LOADED = 1; protected static final int STATE_ERROR = -1; private static final String TAG = "BasicTexture"; // Log a warning if a texture is larger along a dimension private static final int MAX_TEXTURE_SIZE = 4096; private final static WeakHashMap sAllTextures = new WeakHashMap<>(); private static final ThreadLocal> sInFinalizer = new ThreadLocal<>(); protected int mId = -1; protected int mState; protected int mWidth = UNSPECIFIED; protected int mHeight = UNSPECIFIED; protected int mTextureWidth; protected int mTextureHeight; protected GLCanvas mCanvasRef = null; private boolean mHasBorder; protected BasicTexture(GLCanvas canvas, int id, int state) { setAssociatedCanvas(canvas); mId = id; mState = state; synchronized (sAllTextures) { sAllTextures.put(this, null); } } protected BasicTexture() { this(null, 0, STATE_UNLOADED); } // This is for deciding if we can call Bitmap's recycle(). // We cannot call Bitmap's recycle() in finalizer because at that point // the finalizer of Bitmap may already be called so recycle() will crash. public static boolean inFinalizer() { return sInFinalizer.get() != null; } public static void yieldAllTextures() { synchronized (sAllTextures) { for (BasicTexture t : sAllTextures.keySet()) { t.yield(); } } } public static void invalidateAllTextures() { synchronized (sAllTextures) { for (BasicTexture t : sAllTextures.keySet()) { t.mState = STATE_UNLOADED; t.setAssociatedCanvas(null); } } } protected void setAssociatedCanvas(GLCanvas canvas) { mCanvasRef = canvas; } /** * Sets the content size of this texture. In OpenGL, the actual texture * size must be of power of 2, the size of the content may be smaller. */ public void setSize(int width, int height) { mWidth = width; mHeight = height; mTextureWidth = width > 0 ? MathUtils.nextPowerOf2(width) : 0; mTextureHeight = height > 0 ? MathUtils.nextPowerOf2(height) : 0; if (mTextureWidth > MAX_TEXTURE_SIZE || mTextureHeight > MAX_TEXTURE_SIZE) { Log.w(TAG, String.format("texture is too large: %d x %d", mTextureWidth, mTextureHeight), new Exception()); } } public boolean isFlippedVertically() { return false; } public int getId() { return mId; } @Override public int getWidth() { return mWidth; } @Override public int getHeight() { return mHeight; } // Returns the width rounded to the next power of 2. public int getTextureWidth() { return mTextureWidth; } // Returns the height rounded to the next power of 2. public int getTextureHeight() { return mTextureHeight; } // Returns true if the texture has one pixel transparent border around the // actual content. This is used to avoid jigged edges. // // The jigged edges appear because we use GL_CLAMP_TO_EDGE for texture wrap // mode (GL_CLAMP is not available in OpenGL ES), so a pixel partially // covered by the texture will use the color of the edge texel. If we add // the transparent border, the color of the edge texel will be mixed with // appropriate amount of transparent. // // Currently our background is black, so we can draw the thumbnails without // enabling blending. public boolean hasBorder() { return mHasBorder; } protected void setBorder(boolean hasBorder) { mHasBorder = hasBorder; } @Override public void draw(GLCanvas canvas, int x, int y) { canvas.drawTexture(this, x, y, getWidth(), getHeight()); } @Override public void draw(GLCanvas canvas, int x, int y, int w, int h) { canvas.drawTexture(this, x, y, w, h); } @Override public void draw(GLCanvas canvas, RectF source, RectF target) { canvas.drawTexture(this, source, target); } // onBind is called before GLCanvas binds this texture. // It should make sure the data is uploaded to GL memory. abstract protected boolean onBind(GLCanvas canvas); // Returns the GL texture target for this texture (e.g. GL_TEXTURE_2D). abstract protected int getTarget(); public boolean isLoaded() { return mState == STATE_LOADED; } // recycle() is called when the texture will never be used again, // so it can free all resources. public void recycle() { freeResource(); } // yield() is called when the texture will not be used temporarily, // so it can free some resources. // The default implementation unloads the texture from GL memory, so // the subclass should make sure it can reload the texture to GL memory // later, or it will have to override this method. public void yield() { freeResource(); } private void freeResource() { GLCanvas canvas = mCanvasRef; if (canvas != null && mId != -1) { canvas.unloadTexture(this); mId = -1; // Don't free it again. } mState = STATE_UNLOADED; setAssociatedCanvas(null); } @Override protected void finalize() throws Throwable { try { sInFinalizer.set(BasicTexture.class); recycle(); sInFinalizer.remove(); } finally { super.finalize(); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/CanvasTexture.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.graphics.Canvas; // CanvasTexture is a texture whose content is the drawing on a Canvas. // The subclasses should override onDraw() to draw on the bitmap. // By default CanvasTexture is not opaque. abstract class CanvasTexture extends UploadedTexture { private final Config mConfig; public CanvasTexture(int width, int height) { mConfig = Config.ARGB_8888; setSize(width, height); setOpaque(false); } @Override protected Bitmap onGetBitmap() { Bitmap bitmap = Bitmap.createBitmap(mWidth, mHeight, mConfig); Canvas canvas = new Canvas(bitmap); onDraw(canvas, bitmap); return bitmap; } @Override protected void onFreeBitmap(Bitmap bitmap) { if (!inFinalizer()) { bitmap.recycle(); } } abstract protected void onDraw(Canvas canvas, Bitmap backing); } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/GLCanvas.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Rect; import android.graphics.RectF; // // GLCanvas gives a convenient interface to draw using OpenGL. // // When a rectangle is specified in this interface, it means the region // [x, x+width) * [y, y+height) // public interface GLCanvas { int SAVE_FLAG_ALL = 0xFFFFFFFF; int SAVE_FLAG_ALPHA = 0x01; int SAVE_FLAG_MATRIX = 0x02; GLId getGLId(); // Tells GLCanvas the size of the underlying GL surface. This should be // called before first drawing and when the size of GL surface is changed. // This is called by GLRoot and should not be called by the clients // who only want to draw on the GLCanvas. Both width and height must be // nonnegative. void setSize(int width, int height); // Clear the drawing buffers. This should only be used by GLRoot. void clearBuffer(); void clearBuffer(float[] argb); float getAlpha(); // Sets and gets the current alpha, alpha must be in [0, 1]. void setAlpha(float alpha); // (current alpha) = (current alpha) * alpha void multiplyAlpha(float alpha); // Change the current transform matrix. void translate(float x, float y, float z); void translate(float x, float y); void scale(float sx, float sy, float sz); void rotate(float angle, float x, float y, float z); void multiplyMatrix(float[] mMatrix, int offset); // Pushes the configuration state (matrix, and alpha) onto // a private stack. void save(); // Same as save(), but only save those specified in saveFlags. void save(int saveFlags); // Pops from the top of the stack as current configuration state (matrix, // alpha, and clip). This call balances a previous call to save(), and is // used to remove all modifications to the configuration state since the // last save call. void restore(); // Draws a line using the specified paint from (x1, y1) to (x2, y2). // (Both end points are included). void drawLine(float x1, float y1, float x2, float y2, GLPaint paint); // Draws a rectangle using the specified paint from (x1, y1) to (x2, y2). // (Both end points are included). void drawRect(float x1, float y1, float x2, float y2, GLPaint paint); // Draws a oval using the specified paint for (cx, cy, radiusX, radiusY) // (Both end points are included). void drawOval(float cx, float cy, float radiusX, float radiusY, GLPaint paint); // Draw a arc inside a rect void drawArc(float cx, float cy, float radiusX, float radiusY, float sweepAngle, GLPaint paint); // Fills the specified rectangle with the specified color. void fillRect(float x, float y, float width, float height, int color); // Fills the specified oval with the specified color. void fillOval(float cx, float cy, float radiusX, float radiusY, int color); // Fills the specified sector with the specified color. void fillSector(float cx, float cy, float radiusX, float radiusY, float sweepAngle, int color); // Draws a texture to the specified rectangle. void drawTexture(BasicTexture texture, int x, int y, int width, int height); void drawMesh(BasicTexture tex, int x, int y, int xyBuffer, int uvBuffer, int indexBuffer, int indexCount); // Draws the source rectangle part of the texture to the target rectangle. void drawTexture(BasicTexture texture, RectF source, RectF target); // Draw a texture with a specified texture transform. void drawTexture(BasicTexture texture, float[] mTextureTransform, int x, int y, int w, int h); // Draw two textures to the specified rectangle. The actual texture used is // from * (1 - ratio) + to * ratio // The two textures must have the same size. void drawMixed(BasicTexture from, int toColor, float ratio, int x, int y, int w, int h); // Draw a region of a texture and a specified color to the specified // rectangle. The actual color used is from * (1 - ratio) + to * ratio. // The region of the texture is defined by parameter "src". The target // rectangle is specified by parameter "target". void drawMixed(BasicTexture from, int toColor, float ratio, RectF src, RectF target); // Unloads the specified texture from the canvas. The resource allocated // to draw the texture will be released. The specified texture will return // to the unloaded state. This function should be called only from // BasicTexture or its descendant boolean unloadTexture(BasicTexture texture); // Delete the specified buffer object, similar to unloadTexture. void deleteBuffer(int bufferId); // Delete the textures and buffers in GL side. This function should only be // called in the GL thread. void deleteRecycledResources(); // Dump statistics information and clear the counters. For debug only. void dumpStatisticsAndClear(); void beginRenderTarget(RawTexture texture); void endRenderTarget(); /** * Sets texture parameters to use GL_CLAMP_TO_EDGE for both * GL_TEXTURE_WRAP_S and GL_TEXTURE_WRAP_T. Sets texture parameters to be * GL_LINEAR for GL_TEXTURE_MIN_FILTER and GL_TEXTURE_MAG_FILTER. * bindTexture() must be called prior to this. * * @param texture The texture to set parameters on. */ void setTextureParameters(BasicTexture texture); /** * Initializes the texture to a size by calling texImage2D on it. * * @param texture The texture to initialize the size. * @param format The texture format (e.g. GL_RGBA) * @param type The texture type (e.g. GL_UNSIGNED_BYTE) */ void initializeTextureSize(BasicTexture texture, int format, int type); /** * Initializes the texture to a size by calling texImage2D on it. * * @param texture The texture to initialize the size. * @param bitmap The bitmap to initialize the bitmap with. */ void initializeTexture(BasicTexture texture, Bitmap bitmap); /** * Calls glTexSubImage2D to upload a bitmap to the texture. * * @param texture The target texture to write to. * @param xOffset Specifies a texel offset in the x direction within the * texture array. * @param yOffset Specifies a texel offset in the y direction within the * texture array. * @param format The texture format (e.g. GL_RGBA) * @param type The texture type (e.g. GL_UNSIGNED_BYTE) */ void texSubImage2D(BasicTexture texture, int xOffset, int yOffset, Bitmap bitmap, int format, int type); /** * Generates buffers and uploads the buffer data. * * @param buffer The buffer to upload * @return The buffer ID that was generated. */ int uploadBuffer(java.nio.FloatBuffer buffer); /** * Generates buffers and uploads the element array buffer data. * * @param buffer The buffer to upload * @return The buffer ID that was generated. */ int uploadBuffer(java.nio.ByteBuffer buffer); /** * After LightCycle makes GL calls, this method is called to restore the GL * configuration to the one expected by GLCanvas. */ void recoverFromLightCycle(); /** * Gets the bounds given by x, y, width, and height as well as the internal * matrix state. There is no special handling for non-90-degree rotations. * It only considers the lower-left and upper-right corners as the bounds. * * @param bounds The output bounds to write to. * @param x The left side of the input rectangle. * @param y The bottom of the input rectangle. * @param width The width of the input rectangle. * @param height The height of the input rectangle. */ void getBounds(Rect bounds, int x, int y, int width, int height); } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/GLES11Canvas.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.Rect; import android.graphics.RectF; import android.opengl.GLU; import android.opengl.GLUtils; import android.opengl.Matrix; import android.util.Log; import com.hippo.yorozuya.MathUtils; import com.hippo.yorozuya.collect.IntList; import java.nio.Buffer; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.FloatBuffer; import java.util.ArrayList; import java.util.Locale; import javax.microedition.khronos.opengles.GL10; import javax.microedition.khronos.opengles.GL11; import javax.microedition.khronos.opengles.GL11Ext; import javax.microedition.khronos.opengles.GL11ExtensionPack; public class GLES11Canvas implements GLCanvas { @SuppressWarnings("unused") private static final String TAG = "GLCanvasImp"; private static final float OPAQUE_ALPHA = 0.95f; private static final int COUNT_FILL_VERTEX = 4; private static final int COUNT_LINE_VERTEX = 2; private static final int COUNT_RECT_VERTEX = 4; private static final int COUNT_CIRCLE_VERTEX = 120; // multiple of 4 private static final int OFFSET_FILL_RECT = 0; private static final int OFFSET_DRAW_LINE = OFFSET_FILL_RECT + COUNT_FILL_VERTEX; private static final int OFFSET_DRAW_RECT = OFFSET_DRAW_LINE + COUNT_LINE_VERTEX; private static final int OFFSET_FILL_CIRCLE = OFFSET_DRAW_RECT + COUNT_RECT_VERTEX; private static final int OFFSET_DRAW_CIRCLE = OFFSET_FILL_CIRCLE + 1; private static final int OFFSET_LAST = OFFSET_DRAW_CIRCLE + COUNT_CIRCLE_VERTEX + 1; private static final float[] BOX_COORDINATES = new float[OFFSET_LAST * 2]; // TODO: the code only work for 2D should get fixed for 3D or removed private static final int MSKEW_X = 4; private static final int MSKEW_Y = 1; private static final int MSCALE_X = 0; private static final int MSCALE_Y = 5; private static final float[] sCropRect = new float[4]; private static final GLId mGLId = new GLES11IdImpl(); static { float[] temp = { 0, 0, // Fill rectangle 1, 0, 0, 1, 1, 1, 0, 0, // Draw line 1, 1, 0, 0, // Draw rectangle outline 0, 1, 1, 1, 1, 0, 0.5f, 0.5f // Fill circle }; System.arraycopy(temp, 0, BOX_COORDINATES, 0, temp.length); // Draw circle int arrayOffset = OFFSET_DRAW_CIRCLE * 2; for (int i = 0, n = COUNT_CIRCLE_VERTEX / 4; i <= n; i++) { float value = (float) Math.sin(MathUtils.radians(90f / n * i)) / 2; float positive = value + 0.5f; float negative = -value + 0.5f; BOX_COORDINATES[arrayOffset + i * 2 + 1] = positive; BOX_COORDINATES[arrayOffset + n * 2 - i * 2] = positive; BOX_COORDINATES[arrayOffset + n * 2 + i * 2] = negative; BOX_COORDINATES[arrayOffset + n * 4 - i * 2 + 1] = positive; BOX_COORDINATES[arrayOffset + n * 4 + i * 2 + 1] = negative; BOX_COORDINATES[arrayOffset + n * 6 - i * 2] = negative; BOX_COORDINATES[arrayOffset + n * 6 + i * 2] = positive; BOX_COORDINATES[arrayOffset + n * 8 - i * 2 + 1] = negative; } } private final float[] mMatrixValues = new float[16]; private final float[] mTextureMatrixValues = new float[16]; // The results of mapPoints are stored in this buffer, and the order is // x1, y1, x2, y2. private final float[] mMapPointsBuffer = new float[4]; private final float[] mTextureColor = new float[4]; private final ArrayList mTargetStack = new ArrayList<>(); private final ArrayList mRestoreStack = new ArrayList<>(); private final RectF mDrawTextureSourceRect = new RectF(); private final RectF mDrawTextureTargetRect = new RectF(); private final float[] mTempMatrix = new float[32]; private final IntList mUnboundTextures = new IntList(); private final IntList mDeleteBuffers = new IntList(); private final GL11 mGL; private final int mBoxCoords; private final GLState mGLState; private final boolean mBlendEnabled = true; private final int[] mFrameBuffer = new int[1]; // Drawing statistics int mCountDrawLine; int mCountFillRect; int mCountDrawMesh; int mCountTextureRect; int mCountTextureOES; private float mAlpha; private ConfigState mRecycledRestoreAction; private int mScreenWidth; private int mScreenHeight; private RawTexture mTargetTexture; public GLES11Canvas(GL11 gl) { mGL = gl; mGLState = new GLState(gl); // First create an nio buffer, then create a VBO from it. int size = BOX_COORDINATES.length * Float.SIZE / Byte.SIZE; FloatBuffer xyBuffer = allocateDirectNativeOrderBuffer(size).asFloatBuffer(); xyBuffer.put(BOX_COORDINATES, 0, BOX_COORDINATES.length).position(0); int[] name = new int[1]; mGLId.glGenBuffers(1, name, 0); mBoxCoords = name[0]; gl.glBindBuffer(GL11.GL_ARRAY_BUFFER, mBoxCoords); gl.glBufferData(GL11.GL_ARRAY_BUFFER, xyBuffer.capacity() * (Float.SIZE / Byte.SIZE), xyBuffer, GL11.GL_STATIC_DRAW); gl.glVertexPointer(2, GL11.GL_FLOAT, 0, 0); gl.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0); // Enable the texture coordinate array for Texture 1 gl.glClientActiveTexture(GL11.GL_TEXTURE1); gl.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0); gl.glClientActiveTexture(GL11.GL_TEXTURE0); gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); // mMatrixValues and mAlpha will be initialized in setSize() } private static ByteBuffer allocateDirectNativeOrderBuffer(int size) { return ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder()); } // This function changes the source coordinate to the texture coordinates. // It also clips the source and target coordinates if it is beyond the // bound of the texture. private static void convertCoordinate(RectF source, RectF target, BasicTexture texture) { int width = texture.getWidth(); int height = texture.getHeight(); int texWidth = texture.getTextureWidth(); int texHeight = texture.getTextureHeight(); // Convert to texture coordinates source.left /= texWidth; source.right /= texWidth; source.top /= texHeight; source.bottom /= texHeight; // Clip if the rendering range is beyond the bound of the texture. float xBound = (float) width / texWidth; if (source.right > xBound) { target.right = target.left + target.width() * (xBound - source.left) / source.width(); source.right = xBound; } float yBound = (float) height / texHeight; if (source.bottom > yBound) { target.bottom = target.top + target.height() * (yBound - source.top) / source.height(); source.bottom = yBound; } } private static boolean isMatrixRotatedOrFlipped(float[] matrix) { final float eps = 1e-5f; return Math.abs(matrix[MSKEW_X]) > eps || Math.abs(matrix[MSKEW_Y]) > eps || matrix[MSCALE_X] < -eps || matrix[MSCALE_Y] > eps; } private static void checkFramebufferStatus(GL11ExtensionPack gl11ep) { int status = gl11ep.glCheckFramebufferStatusOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES); if (status != GL11ExtensionPack.GL_FRAMEBUFFER_COMPLETE_OES) { String msg = switch (status) { case GL11ExtensionPack.GL_FRAMEBUFFER_INCOMPLETE_FORMATS_OES -> "FRAMEBUFFER_FORMATS"; case GL11ExtensionPack.GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT_OES -> "FRAMEBUFFER_ATTACHMENT"; case GL11ExtensionPack.GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT_OES -> "FRAMEBUFFER_MISSING_ATTACHMENT"; case GL11ExtensionPack.GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER_OES -> "FRAMEBUFFER_DRAW_BUFFER"; case GL11ExtensionPack.GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER_OES -> "FRAMEBUFFER_READ_BUFFER"; case GL11ExtensionPack.GL_FRAMEBUFFER_UNSUPPORTED_OES -> "FRAMEBUFFER_UNSUPPORTED"; case GL11ExtensionPack.GL_FRAMEBUFFER_INCOMPLETE_DIMENSIONS_OES -> "FRAMEBUFFER_INCOMPLETE_DIMENSIONS"; default -> ""; }; throw new RuntimeException(msg + ":" + Integer.toHexString(status)); } } @Override public void setSize(int width, int height) { if (mTargetTexture == null) { mScreenWidth = width; mScreenHeight = height; } mAlpha = 1.0f; GL11 gl = mGL; gl.glViewport(0, 0, width, height); gl.glMatrixMode(GL11.GL_PROJECTION); gl.glLoadIdentity(); GLU.gluOrtho2D(gl, 0, width, 0, height); gl.glMatrixMode(GL11.GL_MODELVIEW); gl.glLoadIdentity(); float[] matrix = mMatrixValues; Matrix.setIdentityM(matrix, 0); // to match the graphic coordinate system in android, we flip it vertically. if (mTargetTexture == null) { Matrix.translateM(matrix, 0, 0, height, 0); Matrix.scaleM(matrix, 0, 1, -1, 1); } } @Override public float getAlpha() { return mAlpha; } @Override public void setAlpha(float alpha) { mAlpha = alpha; } @Override public void multiplyAlpha(float alpha) { mAlpha *= alpha; } @Override public void drawRect(float x, float y, float width, float height, GLPaint paint) { GL11 gl = mGL; mGLState.setColorMode(paint.getColor(), mAlpha); mGLState.setLineWidth(paint.getLineWidth()); saveTransform(); translate(x, y); scale(width, height, 1); gl.glLoadMatrixf(mMatrixValues, 0); gl.glDrawArrays(GL11.GL_LINE_LOOP, OFFSET_DRAW_RECT, COUNT_RECT_VERTEX); restoreTransform(); mCountDrawLine++; } @Override public void drawLine(float x1, float y1, float x2, float y2, GLPaint paint) { GL11 gl = mGL; mGLState.setColorMode(paint.getColor(), mAlpha); mGLState.setLineWidth(paint.getLineWidth()); saveTransform(); translate(x1, y1); scale(x2 - x1, y2 - y1, 1); gl.glLoadMatrixf(mMatrixValues, 0); gl.glDrawArrays(GL11.GL_LINE_STRIP, OFFSET_DRAW_LINE, COUNT_LINE_VERTEX); restoreTransform(); mCountDrawLine++; } @Override public void drawOval(float cx, float cy, float radiusX, float radiusY, GLPaint paint) { float halfLineWidth = paint.getLineWidth() / 2; fillOval(cx, cy, radiusX + halfLineWidth, radiusY + halfLineWidth, paint.getColor()); fillOval(cx, cy, radiusX - halfLineWidth, radiusY - halfLineWidth, paint.getBackgroundColor()); } @Override public void drawArc(float cx, float cy, float radiusX, float radiusY, float sweepAngle, GLPaint paint) { float halfLineWidth = paint.getLineWidth() / 2; fillSector(cx, cy, radiusX + halfLineWidth, radiusY + halfLineWidth, sweepAngle, paint.getColor()); fillOval(cx, cy, radiusX - halfLineWidth, radiusY - halfLineWidth, paint.getBackgroundColor()); } @Override public void fillRect(float x, float y, float width, float height, int color) { mGLState.setColorMode(color, mAlpha); GL11 gl = mGL; saveTransform(); translate(x, y); scale(width, height, 1); gl.glLoadMatrixf(mMatrixValues, 0); gl.glDrawArrays(GL11.GL_TRIANGLE_STRIP, OFFSET_FILL_RECT, COUNT_FILL_VERTEX); restoreTransform(); mCountFillRect++; } @Override public void fillOval(float cx, float cy, float radiusX, float radiusY, int color) { mGLState.setColorMode(color, mAlpha); GL11 gl = mGL; saveTransform(); translate(cx - radiusX, cy - radiusY); scale(radiusX * 2, radiusY * 2, 1); gl.glLoadMatrixf(mMatrixValues, 0); gl.glDrawArrays(GL11.GL_TRIANGLE_FAN, OFFSET_FILL_CIRCLE, OFFSET_LAST - OFFSET_FILL_CIRCLE); restoreTransform(); mCountFillRect++; } @Override public void fillSector(float cx, float cy, float radiusX, float radiusY, float sweepAngle, int color) { float conjugateAngle = Math.abs(360 - MathUtils.positiveModulo(sweepAngle, 360)); int conjugateCount = Math.round(COUNT_CIRCLE_VERTEX * conjugateAngle / 360); if (conjugateCount == 0) { // It is a circle fillOval(cx, cy, radiusX, radiusY, color); } else if (conjugateCount < COUNT_CIRCLE_VERTEX) { mGLState.setColorMode(color, mAlpha); GL11 gl = mGL; saveTransform(); translate(cx - radiusX, cy - radiusY); scale(radiusX * 2, radiusY * 2, 1); gl.glLoadMatrixf(mMatrixValues, 0); gl.glDrawArrays(GL11.GL_TRIANGLE_FAN, OFFSET_FILL_CIRCLE, OFFSET_LAST - OFFSET_FILL_CIRCLE - conjugateCount); restoreTransform(); mCountFillRect++; } } @Override public void translate(float x, float y, float z) { Matrix.translateM(mMatrixValues, 0, x, y, z); } // This is a faster version of translate(x, y, z) because // (1) we knows z = 0, (2) we inline the Matrix.translateM call, // (3) we unroll the loop @Override public void translate(float x, float y) { float[] m = mMatrixValues; m[12] += m[0] * x + m[4] * y; m[13] += m[1] * x + m[5] * y; m[14] += m[2] * x + m[6] * y; m[15] += m[3] * x + m[7] * y; } @Override public void scale(float sx, float sy, float sz) { Matrix.scaleM(mMatrixValues, 0, sx, sy, sz); } @Override public void rotate(float angle, float x, float y, float z) { if (angle == 0) return; float[] temp = mTempMatrix; Matrix.setRotateM(temp, 0, angle, x, y, z); Matrix.multiplyMM(temp, 16, mMatrixValues, 0, temp, 0); System.arraycopy(temp, 16, mMatrixValues, 0, 16); } @Override public void multiplyMatrix(float[] matrix, int offset) { float[] temp = mTempMatrix; Matrix.multiplyMM(temp, 0, mMatrixValues, 0, matrix, offset); System.arraycopy(temp, 0, mMatrixValues, 0, 16); } private void textureRect(float x, float y, float width, float height) { GL11 gl = mGL; saveTransform(); translate(x, y); scale(width, height, 1); gl.glLoadMatrixf(mMatrixValues, 0); gl.glDrawArrays(GL11.GL_TRIANGLE_STRIP, OFFSET_FILL_RECT, 4); restoreTransform(); mCountTextureRect++; } @Override public void drawMesh(BasicTexture tex, int x, int y, int xyBuffer, int uvBuffer, int indexBuffer, int indexCount) { float alpha = mAlpha; if (notBindTexture(tex)) return; mGLState.setBlendEnabled(mBlendEnabled && (!tex.isOpaque() || alpha < OPAQUE_ALPHA)); mGLState.setTextureAlpha(alpha); // Reset the texture matrix. We will set our own texture coordinates // below. setTextureCoords(0, 0, 1, 1); saveTransform(); translate(x, y); mGL.glLoadMatrixf(mMatrixValues, 0); mGL.glBindBuffer(GL11.GL_ARRAY_BUFFER, xyBuffer); mGL.glVertexPointer(2, GL11.GL_FLOAT, 0, 0); mGL.glBindBuffer(GL11.GL_ARRAY_BUFFER, uvBuffer); mGL.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0); mGL.glBindBuffer(GL11.GL_ELEMENT_ARRAY_BUFFER, indexBuffer); mGL.glDrawElements(GL11.GL_TRIANGLE_STRIP, indexCount, GL11.GL_UNSIGNED_BYTE, 0); mGL.glBindBuffer(GL11.GL_ARRAY_BUFFER, mBoxCoords); mGL.glVertexPointer(2, GL11.GL_FLOAT, 0, 0); mGL.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0); restoreTransform(); mCountDrawMesh++; } // Transforms two points by the given matrix m. The result // {x1', y1', x2', y2'} are stored in mMapPointsBuffer and also returned. private float[] mapPoints(float[] m, int x1, int y1, int x2, int y2) { float[] r = mMapPointsBuffer; // Multiply m and (x1 y1 0 1) to produce (x3 y3 z3 w3). z3 is unused. float x3 = m[0] * x1 + m[4] * y1 + m[12]; float y3 = m[1] * x1 + m[5] * y1 + m[13]; float w3 = m[3] * x1 + m[7] * y1 + m[15]; r[0] = x3 / w3; r[1] = y3 / w3; // Same for x2 y2. float x4 = m[0] * x2 + m[4] * y2 + m[12]; float y4 = m[1] * x2 + m[5] * y2 + m[13]; float w4 = m[3] * x2 + m[7] * y2 + m[15]; r[2] = x4 / w4; r[3] = y4 / w4; return r; } private void drawBoundTexture( BasicTexture texture, int x, int y, int width, int height) { // Test whether it has been rotated or flipped, if so, glDrawTexiOES // won't work if (isMatrixRotatedOrFlipped(mMatrixValues)) { if (texture.hasBorder()) { setTextureCoords( 1.0f / texture.getTextureWidth(), 1.0f / texture.getTextureHeight(), (texture.getWidth() - 1.0f) / texture.getTextureWidth(), (texture.getHeight() - 1.0f) / texture.getTextureHeight()); } else { setTextureCoords(0, 0, (float) texture.getWidth() / texture.getTextureWidth(), (float) texture.getHeight() / texture.getTextureHeight()); } textureRect(x, y, width, height); } else { // draw the rect from bottom-left to top-right float[] points = mapPoints( mMatrixValues, x, y + height, x + width, y); x = (int) (points[0] + 0.5f); y = (int) (points[1] + 0.5f); width = (int) (points[2] + 0.5f) - x; height = (int) (points[3] + 0.5f) - y; if (width > 0 && height > 0) { ((GL11Ext) mGL).glDrawTexiOES(x, y, 0, width, height); mCountTextureOES++; } } } @Override public void drawTexture( BasicTexture texture, int x, int y, int width, int height) { drawTexture(texture, x, y, width, height, mAlpha); } private void drawTexture(BasicTexture texture, int x, int y, int width, int height, float alpha) { if (width <= 0 || height <= 0) return; mGLState.setBlendEnabled(mBlendEnabled && (!texture.isOpaque() || alpha < OPAQUE_ALPHA)); if (notBindTexture(texture)) return; mGLState.setTextureAlpha(alpha); drawBoundTexture(texture, x, y, width, height); } @Override public void drawTexture(BasicTexture texture, RectF source, RectF target) { if (target.width() <= 0 || target.height() <= 0) return; // Copy the input to avoid changing it. mDrawTextureSourceRect.set(source); mDrawTextureTargetRect.set(target); source = mDrawTextureSourceRect; target = mDrawTextureTargetRect; mGLState.setBlendEnabled(mBlendEnabled && (!texture.isOpaque() || mAlpha < OPAQUE_ALPHA)); if (notBindTexture(texture)) return; convertCoordinate(source, target, texture); setTextureCoords(source); mGLState.setTextureAlpha(mAlpha); textureRect(target.left, target.top, target.width(), target.height()); } @Override public void drawTexture(BasicTexture texture, float[] mTextureTransform, int x, int y, int w, int h) { mGLState.setBlendEnabled(mBlendEnabled && (!texture.isOpaque() || mAlpha < OPAQUE_ALPHA)); if (notBindTexture(texture)) return; setTextureCoords(mTextureTransform); mGLState.setTextureAlpha(mAlpha); textureRect(x, y, w, h); } @Override public void drawMixed(BasicTexture from, int toColor, float ratio, int x, int y, int w, int h) { drawMixed(from, toColor, ratio, x, y, w, h, mAlpha); } private boolean notBindTexture(BasicTexture texture) { if (!texture.onBind(this)) return true; int target = texture.getTarget(); mGLState.setTextureTarget(target); mGL.glBindTexture(target, texture.getId()); return false; } private void setTextureColor(float r, float g, float b, float alpha) { float[] color = mTextureColor; color[0] = r; color[1] = g; color[2] = b; color[3] = alpha; } private void setMixedColor(int toColor, float ratio, float alpha) { // // The formula we want: // alpha * ((1 - ratio) * from + ratio * to) // // The formula that GL supports is in the form of: // combo * from + (1 - combo) * to * scale // // So, we have combo = alpha * (1 - ratio) // and scale = alpha * ratio / (1 - combo) // float combo = alpha * (1 - ratio); float scale = alpha * ratio / (1 - combo); // Specify the interpolation factor via the alpha component of // GL_TEXTURE_ENV_COLORs. // RGB component are get from toColor and will used as SRC1 float colorScale = scale * (toColor >>> 24) / (0xff * 0xff); setTextureColor(((toColor >>> 16) & 0xff) * colorScale, ((toColor >>> 8) & 0xff) * colorScale, (toColor & 0xff) * colorScale, combo); GL11 gl = mGL; gl.glTexEnvfv(GL11.GL_TEXTURE_ENV, GL11.GL_TEXTURE_ENV_COLOR, mTextureColor, 0); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_COMBINE_RGB, GL11.GL_INTERPOLATE); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_COMBINE_ALPHA, GL11.GL_INTERPOLATE); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC1_RGB, GL11.GL_CONSTANT); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND1_RGB, GL11.GL_SRC_COLOR); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC1_ALPHA, GL11.GL_CONSTANT); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND1_ALPHA, GL11.GL_SRC_ALPHA); // Wire up the interpolation factor for RGB. gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC2_RGB, GL11.GL_CONSTANT); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND2_RGB, GL11.GL_SRC_ALPHA); // Wire up the interpolation factor for alpha. gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC2_ALPHA, GL11.GL_CONSTANT); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND2_ALPHA, GL11.GL_SRC_ALPHA); } @Override public void drawMixed(BasicTexture from, int toColor, float ratio, RectF source, RectF target) { if (target.width() <= 0 || target.height() <= 0) return; if (ratio <= 0.01f) { drawTexture(from, source, target); return; } else if (ratio >= 1) { fillRect(target.left, target.top, target.width(), target.height(), toColor); return; } float alpha = mAlpha; // Copy the input to avoid changing it. mDrawTextureSourceRect.set(source); mDrawTextureTargetRect.set(target); source = mDrawTextureSourceRect; target = mDrawTextureTargetRect; mGLState.setBlendEnabled(mBlendEnabled && (!from.isOpaque() || Color.alpha(toColor) != 255 || alpha < OPAQUE_ALPHA)); if (notBindTexture(from)) return; // Interpolate the RGB and alpha values between both textures. mGLState.setTexEnvMode(GL11.GL_COMBINE); setMixedColor(toColor, ratio, alpha); convertCoordinate(source, target, from); setTextureCoords(source); textureRect(target.left, target.top, target.width(), target.height()); mGLState.setTexEnvMode(GL11.GL_REPLACE); } private void drawMixed(BasicTexture from, int toColor, float ratio, int x, int y, int width, int height, float alpha) { // change from 0 to 0.01f to prevent getting divided by zero below if (ratio <= 0.01f) { drawTexture(from, x, y, width, height, alpha); return; } else if (ratio >= 1) { fillRect(x, y, width, height, toColor); return; } mGLState.setBlendEnabled(mBlendEnabled && (!from.isOpaque() || Color.alpha(toColor) != 255 || alpha < OPAQUE_ALPHA)); if (notBindTexture(from)) return; // Interpolate the RGB and alpha values between both textures. mGLState.setTexEnvMode(GL11.GL_COMBINE); setMixedColor(toColor, ratio, alpha); drawBoundTexture(from, x, y, width, height); mGLState.setTexEnvMode(GL11.GL_REPLACE); } @Override public void clearBuffer(float[] argb) { if (argb != null && argb.length == 4) { mGL.glClearColor(argb[1], argb[2], argb[3], argb[0]); } else { mGL.glClearColor(0, 0, 0, 1); } mGL.glClear(GL10.GL_COLOR_BUFFER_BIT); } @Override public void clearBuffer() { clearBuffer(null); } private void setTextureCoords(RectF source) { setTextureCoords(source.left, source.top, source.right, source.bottom); } private void setTextureCoords(float left, float top, float right, float bottom) { mGL.glMatrixMode(GL11.GL_TEXTURE); mTextureMatrixValues[0] = right - left; mTextureMatrixValues[5] = bottom - top; mTextureMatrixValues[10] = 1; mTextureMatrixValues[12] = left; mTextureMatrixValues[13] = top; mTextureMatrixValues[15] = 1; mGL.glLoadMatrixf(mTextureMatrixValues, 0); mGL.glMatrixMode(GL11.GL_MODELVIEW); } private void setTextureCoords(float[] mTextureTransform) { mGL.glMatrixMode(GL11.GL_TEXTURE); mGL.glLoadMatrixf(mTextureTransform, 0); mGL.glMatrixMode(GL11.GL_MODELVIEW); } // unloadTexture and deleteBuffer can be called from the finalizer thread, // so we synchronized on the mUnboundTextures object. @Override public boolean unloadTexture(BasicTexture t) { synchronized (mUnboundTextures) { if (!t.isLoaded()) return false; mUnboundTextures.add(t.mId); return true; } } @Override public void deleteBuffer(int bufferId) { synchronized (mUnboundTextures) { mDeleteBuffers.add(bufferId); } } @Override public void deleteRecycledResources() { synchronized (mUnboundTextures) { IntList ids = mUnboundTextures; if (!ids.isEmpty()) { mGLId.glDeleteTextures(mGL, ids.size(), ids.getInternalArray(), 0); ids.clear(); } ids = mDeleteBuffers; if (!ids.isEmpty()) { mGLId.glDeleteBuffers(mGL, ids.size(), ids.getInternalArray(), 0); ids.clear(); } } } @Override public void save() { save(SAVE_FLAG_ALL); } @Override public void save(int saveFlags) { ConfigState config = obtainRestoreConfig(); if ((saveFlags & SAVE_FLAG_ALPHA) != 0) { config.mAlpha = mAlpha; } else { config.mAlpha = -1; } if ((saveFlags & SAVE_FLAG_MATRIX) != 0) { System.arraycopy(mMatrixValues, 0, config.mMatrix, 0, 16); } else { config.mMatrix[0] = Float.NEGATIVE_INFINITY; } mRestoreStack.add(config); } @Override public void restore() { if (mRestoreStack.isEmpty()) throw new IllegalStateException(); //noinspection SequencedCollectionMethodCanBeUsed ConfigState config = mRestoreStack.remove(mRestoreStack.size() - 1); config.restore(this); freeRestoreConfig(config); } private void freeRestoreConfig(ConfigState action) { action.mNextFree = mRecycledRestoreAction; mRecycledRestoreAction = action; } private ConfigState obtainRestoreConfig() { if (mRecycledRestoreAction != null) { ConfigState result = mRecycledRestoreAction; mRecycledRestoreAction = result.mNextFree; return result; } return new ConfigState(); } @Override public void dumpStatisticsAndClear() { String line = String.format( Locale.US, "MESH:%d, TEX_OES:%d, TEX_RECT:%d, FILL_RECT:%d, LINE:%d", mCountDrawMesh, mCountTextureRect, mCountTextureOES, mCountFillRect, mCountDrawLine); mCountDrawMesh = 0; mCountTextureRect = 0; mCountTextureOES = 0; mCountFillRect = 0; mCountDrawLine = 0; Log.d(TAG, line); } private void saveTransform() { System.arraycopy(mMatrixValues, 0, mTempMatrix, 0, 16); } private void restoreTransform() { System.arraycopy(mTempMatrix, 0, mMatrixValues, 0, 16); } private void setRenderTarget(RawTexture texture) { GL11ExtensionPack gl11ep = (GL11ExtensionPack) mGL; if (mTargetTexture == null && texture != null) { mGLId.glGenBuffers(1, mFrameBuffer, 0); gl11ep.glBindFramebufferOES( GL11ExtensionPack.GL_FRAMEBUFFER_OES, mFrameBuffer[0]); } if (mTargetTexture != null && texture == null) { gl11ep.glBindFramebufferOES(GL11ExtensionPack.GL_FRAMEBUFFER_OES, 0); gl11ep.glDeleteFramebuffersOES(1, mFrameBuffer, 0); } mTargetTexture = texture; if (texture == null) { setSize(mScreenWidth, mScreenHeight); } else { setSize(texture.getWidth(), texture.getHeight()); if (!texture.isLoaded()) texture.prepare(this); gl11ep.glFramebufferTexture2DOES( GL11ExtensionPack.GL_FRAMEBUFFER_OES, GL11ExtensionPack.GL_COLOR_ATTACHMENT0_OES, GL11.GL_TEXTURE_2D, texture.getId(), 0); checkFramebufferStatus(gl11ep); } } @Override public void endRenderTarget() { //noinspection SequencedCollectionMethodCanBeUsed RawTexture texture = mTargetStack.remove(mTargetStack.size() - 1); setRenderTarget(texture); restore(); // restore matrix and alpha } @Override public void beginRenderTarget(RawTexture texture) { save(); // save matrix and alpha mTargetStack.add(mTargetTexture); setRenderTarget(texture); } @Override public void setTextureParameters(BasicTexture texture) { int width = texture.getWidth(); int height = texture.getHeight(); // Define a vertically flipped crop rectangle for OES_draw_texture. // The four values in sCropRect are: left, bottom, width, and // height. Negative value of width or height means flip. sCropRect[0] = 0; sCropRect[1] = height; sCropRect[2] = width; sCropRect[3] = -height; // Set texture parameters. int target = texture.getTarget(); mGL.glBindTexture(target, texture.getId()); mGL.glTexParameterfv(target, GL11Ext.GL_TEXTURE_CROP_RECT_OES, sCropRect, 0); mGL.glTexParameteri(target, GL11.GL_TEXTURE_WRAP_S, GL11.GL_CLAMP_TO_EDGE); mGL.glTexParameteri(target, GL11.GL_TEXTURE_WRAP_T, GL11.GL_CLAMP_TO_EDGE); mGL.glTexParameterf(target, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR); mGL.glTexParameterf(target, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR); } @Override public void initializeTextureSize(BasicTexture texture, int format, int type) { int target = texture.getTarget(); mGL.glBindTexture(target, texture.getId()); int width = texture.getTextureWidth(); int height = texture.getTextureHeight(); mGL.glTexImage2D(target, 0, format, width, height, 0, format, type, null); } @Override public void initializeTexture(BasicTexture texture, Bitmap bitmap) { int target = texture.getTarget(); mGL.glBindTexture(target, texture.getId()); GLUtils.texImage2D(target, 0, bitmap, 0); } @Override public void texSubImage2D(BasicTexture texture, int xOffset, int yOffset, Bitmap bitmap, int format, int type) { int target = texture.getTarget(); mGL.glBindTexture(target, texture.getId()); GLUtils.texSubImage2D(target, 0, xOffset, yOffset, bitmap, format, type); } @Override public int uploadBuffer(FloatBuffer buf) { return uploadBuffer(buf, Float.SIZE / Byte.SIZE); } @Override public int uploadBuffer(ByteBuffer buf) { return uploadBuffer(buf, 1); } private int uploadBuffer(Buffer buf, int elementSize) { int[] bufferIds = new int[1]; mGLId.glGenBuffers(bufferIds.length, bufferIds, 0); int bufferId = bufferIds[0]; mGL.glBindBuffer(GL11.GL_ARRAY_BUFFER, bufferId); mGL.glBufferData(GL11.GL_ARRAY_BUFFER, buf.capacity() * elementSize, buf, GL11.GL_STATIC_DRAW); return bufferId; } @Override public void recoverFromLightCycle() { // This is only required for GLES20 } @Override public void getBounds(Rect bounds, int x, int y, int width, int height) { // This is only required for GLES20 } @Override public GLId getGLId() { return mGLId; } private static class GLState { private final GL11 mGL; private int mTexEnvMode = GL11.GL_REPLACE; private float mTextureAlpha = 1.0f; private int mTextureTarget = GL11.GL_TEXTURE_2D; private boolean mBlendEnabled = true; private float mLineWidth = 1.0f; public GLState(GL11 gl) { mGL = gl; // Disable unused state gl.glDisable(GL11.GL_LIGHTING); // Enable used features gl.glEnable(GL11.GL_DITHER); gl.glEnableClientState(GL10.GL_VERTEX_ARRAY); gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY); gl.glEnable(GL11.GL_TEXTURE_2D); gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_TEXTURE_ENV_MODE, GL11.GL_REPLACE); // Set the background color gl.glClearColor(0f, 0f, 0f, 0f); gl.glEnable(GL11.GL_BLEND); gl.glBlendFunc(GL11.GL_ONE, GL11.GL_ONE_MINUS_SRC_ALPHA); // We use 565 or 8888 format, so set the alignment to 2 bytes/pixel. gl.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 2); } public void setTexEnvMode(int mode) { if (mTexEnvMode == mode) return; mTexEnvMode = mode; mGL.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_TEXTURE_ENV_MODE, mode); } public void setLineWidth(float width) { if (mLineWidth == width) return; mLineWidth = width; mGL.glLineWidth(width); } public void setTextureAlpha(float alpha) { if (mTextureAlpha == alpha) return; mTextureAlpha = alpha; if (alpha >= OPAQUE_ALPHA) { // The alpha is need for those texture without alpha channel mGL.glColor4f(1, 1, 1, 1); setTexEnvMode(GL11.GL_REPLACE); } else { mGL.glColor4f(alpha, alpha, alpha, alpha); setTexEnvMode(GL11.GL_MODULATE); } } public void setColorMode(int color, float alpha) { setBlendEnabled(Color.alpha(color) != 255 || alpha < OPAQUE_ALPHA); // Set mTextureAlpha to an invalid value, so that it will reset // again in setTextureAlpha(float) later. mTextureAlpha = -1.0f; setTextureTarget(0); float prealpha = (color >>> 24) * alpha * 65535f / 255f / 255f; mGL.glColor4x( Math.round(((color >> 16) & 0xFF) * prealpha), Math.round(((color >> 8) & 0xFF) * prealpha), Math.round((color & 0xFF) * prealpha), Math.round(255 * prealpha)); } // target is a value like GL_TEXTURE_2D. If target = 0, texturing is disabled. public void setTextureTarget(int target) { if (mTextureTarget == target) return; if (mTextureTarget != 0) { mGL.glDisable(mTextureTarget); } mTextureTarget = target; if (mTextureTarget != 0) { mGL.glEnable(mTextureTarget); } } public void setBlendEnabled(boolean enabled) { if (mBlendEnabled == enabled) return; mBlendEnabled = enabled; if (enabled) { mGL.glEnable(GL11.GL_BLEND); } else { mGL.glDisable(GL11.GL_BLEND); } } } private static class ConfigState { float mAlpha; float[] mMatrix = new float[16]; ConfigState mNextFree; public void restore(GLES11Canvas canvas) { if (mAlpha >= 0) canvas.setAlpha(mAlpha); if (mMatrix[0] != Float.NEGATIVE_INFINITY) { System.arraycopy(mMatrix, 0, canvas.mMatrixValues, 0, 16); } } } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/GLES11IdImpl.java ================================================ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import javax.microedition.khronos.opengles.GL11; import javax.microedition.khronos.opengles.GL11ExtensionPack; /** * Open GL ES 1.1 implementation for generating and destroying texture IDs and * buffer IDs */ public class GLES11IdImpl implements GLId { // Mutex for sNextId private final static Object sLock = new Object(); private static int sNextId = 1; @Override public int generateTexture() { synchronized (sLock) { return sNextId++; } } @Override public void glGenBuffers(int n, int[] buffers, int offset) { synchronized (sLock) { while (n-- > 0) { buffers[offset + n] = sNextId++; } } } @Override public void glDeleteTextures(GL11 gl, int n, int[] textures, int offset) { synchronized (sLock) { gl.glDeleteTextures(n, textures, offset); } } @Override public void glDeleteBuffers(GL11 gl, int n, int[] buffers, int offset) { synchronized (sLock) { gl.glDeleteBuffers(n, buffers, offset); } } @Override public void glDeleteFramebuffers(GL11ExtensionPack gl11ep, int n, int[] buffers, int offset) { synchronized (sLock) { gl11ep.glDeleteFramebuffersOES(n, buffers, offset); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/GLES20Canvas.java ================================================ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.Rect; import android.graphics.RectF; import android.opengl.GLES20; import android.opengl.GLUtils; import android.opengl.Matrix; import android.util.Log; import com.hippo.yorozuya.MathUtils; import com.hippo.yorozuya.collect.IntList; import java.nio.Buffer; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.FloatBuffer; import java.util.ArrayList; import java.util.Arrays; import java.util.Locale; public class GLES20Canvas implements GLCanvas { // ************** Constants ********************** private static final String TAG = GLES20Canvas.class.getSimpleName(); private static final int FLOAT_SIZE = Float.SIZE / Byte.SIZE; private static final float OPAQUE_ALPHA = 0.95f; private static final int COORDS_PER_VERTEX = 2; private static final int VERTEX_STRIDE = COORDS_PER_VERTEX * FLOAT_SIZE; private static final int COUNT_FILL_VERTEX = 4; private static final int COUNT_LINE_VERTEX = 2; private static final int COUNT_RECT_VERTEX = 4; private static final int COUNT_CIRCLE_VERTEX = 120; // multiple of 4 private static final int OFFSET_FILL_RECT = 0; private static final int OFFSET_DRAW_LINE = OFFSET_FILL_RECT + COUNT_FILL_VERTEX; private static final int OFFSET_DRAW_RECT = OFFSET_DRAW_LINE + COUNT_LINE_VERTEX; private static final int OFFSET_FILL_CIRCLE = OFFSET_DRAW_RECT + COUNT_RECT_VERTEX; private static final int OFFSET_DRAW_CIRCLE = OFFSET_FILL_CIRCLE + 1; private static final int OFFSET_LAST = OFFSET_DRAW_CIRCLE + COUNT_CIRCLE_VERTEX + 1; private static final float[] BOX_COORDINATES = new float[OFFSET_LAST * 2]; private static final float[] BOUNDS_COORDINATES = { 0, 0, 0, 1, 1, 1, 0, 1, }; private static final String POSITION_ATTRIBUTE = "aPosition"; private static final String COLOR_UNIFORM = "uColor"; private static final String MATRIX_UNIFORM = "uMatrix"; private static final String TEXTURE_MATRIX_UNIFORM = "uTextureMatrix"; private static final String TEXTURE_SAMPLER_UNIFORM = "uTextureSampler"; private static final String ALPHA_UNIFORM = "uAlpha"; private static final String TEXTURE_COORD_ATTRIBUTE = "aTextureCoordinate"; private static final String DRAW_VERTEX_SHADER = "uniform mat4 " + MATRIX_UNIFORM + ";\n" + "attribute vec2 " + POSITION_ATTRIBUTE + ";\n" + "void main() {\n" + " vec4 pos = vec4(" + POSITION_ATTRIBUTE + ", 0.0, 1.0);\n" + " gl_Position = " + MATRIX_UNIFORM + " * pos;\n" + "}\n"; private static final String DRAW_FRAGMENT_SHADER = "precision mediump float;\n" + "uniform vec4 " + COLOR_UNIFORM + ";\n" + "void main() {\n" + " gl_FragColor = " + COLOR_UNIFORM + ";\n" + "}\n"; private static final String TEXTURE_VERTEX_SHADER = "uniform mat4 " + MATRIX_UNIFORM + ";\n" + "uniform mat4 " + TEXTURE_MATRIX_UNIFORM + ";\n" + "attribute vec2 " + POSITION_ATTRIBUTE + ";\n" + "varying vec2 vTextureCoord;\n" + "void main() {\n" + " vec4 pos = vec4(" + POSITION_ATTRIBUTE + ", 0.0, 1.0);\n" + " gl_Position = " + MATRIX_UNIFORM + " * pos;\n" + " vTextureCoord = (" + TEXTURE_MATRIX_UNIFORM + " * pos).xy;\n" + "}\n"; private static final String MESH_VERTEX_SHADER = "uniform mat4 " + MATRIX_UNIFORM + ";\n" + "attribute vec2 " + POSITION_ATTRIBUTE + ";\n" + "attribute vec2 " + TEXTURE_COORD_ATTRIBUTE + ";\n" + "varying vec2 vTextureCoord;\n" + "void main() {\n" + " vec4 pos = vec4(" + POSITION_ATTRIBUTE + ", 0.0, 1.0);\n" + " gl_Position = " + MATRIX_UNIFORM + " * pos;\n" + " vTextureCoord = " + TEXTURE_COORD_ATTRIBUTE + ";\n" + "}\n"; private static final String TEXTURE_FRAGMENT_SHADER = "precision mediump float;\n" + "varying vec2 vTextureCoord;\n" + "uniform float " + ALPHA_UNIFORM + ";\n" + "uniform sampler2D " + TEXTURE_SAMPLER_UNIFORM + ";\n" + "void main() {\n" + " gl_FragColor = texture2D(" + TEXTURE_SAMPLER_UNIFORM + ", vTextureCoord);\n" + " gl_FragColor *= " + ALPHA_UNIFORM + ";\n" + "}\n"; private static final int INITIAL_RESTORE_STATE_SIZE = 8; private static final int MATRIX_SIZE = 16; // Handle indices -- common private static final int INDEX_POSITION = 0; private static final int INDEX_MATRIX = 1; // Handle indices -- draw private static final int INDEX_COLOR = 2; // Handle indices -- texture private static final int INDEX_TEXTURE_MATRIX = 2; private static final int INDEX_TEXTURE_SAMPLER = 3; private static final int INDEX_ALPHA = 4; // Handle indices -- mesh private static final int INDEX_TEXTURE_COORD = 2; private static final GLId mGLId = new GLES20IdImpl(); static { float[] temp = { 0, 0, // Fill rectangle 1, 0, 0, 1, 1, 1, 0, 0, // Draw line 1, 1, 0, 0, // Draw rectangle outline 0, 1, 1, 1, 1, 0, 0.5f, 0.5f // Fill circle }; System.arraycopy(temp, 0, BOX_COORDINATES, 0, temp.length); // Draw circle int arrayOffset = OFFSET_DRAW_CIRCLE * 2; for (int i = 0, n = COUNT_CIRCLE_VERTEX / 4; i <= n; i++) { float value = (float) Math.sin(MathUtils.radians(90f / n * i)) / 2; float positive = value + 0.5f; float negative = -value + 0.5f; BOX_COORDINATES[arrayOffset + i * 2 + 1] = positive; BOX_COORDINATES[arrayOffset + n * 2 - i * 2] = positive; BOX_COORDINATES[arrayOffset + n * 2 + i * 2] = negative; BOX_COORDINATES[arrayOffset + n * 4 - i * 2 + 1] = positive; BOX_COORDINATES[arrayOffset + n * 4 + i * 2 + 1] = negative; BOX_COORDINATES[arrayOffset + n * 6 - i * 2] = negative; BOX_COORDINATES[arrayOffset + n * 6 + i * 2] = positive; BOX_COORDINATES[arrayOffset + n * 8 - i * 2 + 1] = negative; } } private final IntList mSaveFlags = new IntList(); // Projection matrix private final float[] mProjectionMatrix = new float[MATRIX_SIZE]; // GL programs private final int mDrawProgram; private final int mTextureProgram; private final int mMeshProgram; // GL buffer containing BOX_COORDINATES private final int mBoxCoordinates; private final IntList mUnboundTextures = new IntList(); private final IntList mDeleteBuffers = new IntList(); // Buffer for framebuffer IDs -- we keep track so we can switch the attached // texture. private final int[] mFrameBuffer = new int[1]; // Bound textures. private final ArrayList mTargetTextures = new ArrayList<>(); // Temporary variables used within calculations private final float[] mTempMatrix = new float[32]; private final float[] mTempColor = new float[4]; private final RectF mTempSourceRect = new RectF(); private final RectF mTempTargetRect = new RectF(); private final float[] mTempTextureMatrix = new float[MATRIX_SIZE]; private final int[] mTempIntArray = new int[1]; ShaderParameter[] mDrawParameters = { new AttributeShaderParameter(POSITION_ATTRIBUTE), // INDEX_POSITION new UniformShaderParameter(MATRIX_UNIFORM), // INDEX_MATRIX new UniformShaderParameter(COLOR_UNIFORM), // INDEX_COLOR }; ShaderParameter[] mTextureParameters = { new AttributeShaderParameter(POSITION_ATTRIBUTE), // INDEX_POSITION new UniformShaderParameter(MATRIX_UNIFORM), // INDEX_MATRIX new UniformShaderParameter(TEXTURE_MATRIX_UNIFORM), // INDEX_TEXTURE_MATRIX new UniformShaderParameter(TEXTURE_SAMPLER_UNIFORM), // INDEX_TEXTURE_SAMPLER new UniformShaderParameter(ALPHA_UNIFORM), // INDEX_ALPHA }; ShaderParameter[] mMeshParameters = { new AttributeShaderParameter(POSITION_ATTRIBUTE), // INDEX_POSITION new UniformShaderParameter(MATRIX_UNIFORM), // INDEX_MATRIX new AttributeShaderParameter(TEXTURE_COORD_ATTRIBUTE), // INDEX_TEXTURE_COORD new UniformShaderParameter(TEXTURE_SAMPLER_UNIFORM), // INDEX_TEXTURE_SAMPLER new UniformShaderParameter(ALPHA_UNIFORM), // INDEX_ALPHA }; // Keep track of restore state private float[] mMatrices = new float[INITIAL_RESTORE_STATE_SIZE * MATRIX_SIZE]; private float[] mAlphas = new float[INITIAL_RESTORE_STATE_SIZE]; private int mCurrentAlphaIndex = 0; private int mCurrentMatrixIndex = 0; // Viewport size private int mWidth; private int mHeight; // Screen size for when we aren't bound to a texture private int mScreenWidth; private int mScreenHeight; // Keep track of statistics for debugging private int mCountDrawMesh = 0; private int mCountTextureRect = 0; private int mCountFillRect = 0; private int mCountDrawLine = 0; public GLES20Canvas() { Matrix.setIdentityM(mTempTextureMatrix, 0); Matrix.setIdentityM(mMatrices, mCurrentMatrixIndex); mAlphas[mCurrentAlphaIndex] = 1f; mTargetTextures.add(null); FloatBuffer boxBuffer = createBuffer(BOX_COORDINATES); mBoxCoordinates = uploadBuffer(boxBuffer); int drawVertexShader = loadShader(GLES20.GL_VERTEX_SHADER, DRAW_VERTEX_SHADER); int textureVertexShader = loadShader(GLES20.GL_VERTEX_SHADER, TEXTURE_VERTEX_SHADER); int meshVertexShader = loadShader(GLES20.GL_VERTEX_SHADER, MESH_VERTEX_SHADER); int drawFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, DRAW_FRAGMENT_SHADER); int textureFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, TEXTURE_FRAGMENT_SHADER); mDrawProgram = assembleProgram(drawVertexShader, drawFragmentShader, mDrawParameters); mTextureProgram = assembleProgram(textureVertexShader, textureFragmentShader, mTextureParameters); mMeshProgram = assembleProgram(meshVertexShader, textureFragmentShader, mMeshParameters); GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA); checkError(); } private static FloatBuffer createBuffer(float[] values) { // First create an nio buffer, then create a VBO from it. int size = values.length * FLOAT_SIZE; FloatBuffer buffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder()) .asFloatBuffer(); buffer.put(values, 0, values.length).position(0); return buffer; } private static int loadShader(int type, String shaderCode) { // create a vertex shader type (GLES20.GL_VERTEX_SHADER) // or a fragment shader type (GLES20.GL_FRAGMENT_SHADER) int shader = GLES20.glCreateShader(type); // add the source code to the shader and compile it GLES20.glShaderSource(shader, shaderCode); checkError(); GLES20.glCompileShader(shader); checkError(); return shader; } private static void copyTextureCoordinates(BasicTexture texture, RectF outRect) { int left = 0; int top = 0; int right = texture.getWidth(); int bottom = texture.getHeight(); if (texture.hasBorder()) { left = 1; top = 1; right -= 1; bottom -= 1; } outRect.set(left, top, right, bottom); } // This function changes the source coordinate to the texture coordinates. // It also clips the source and target coordinates if it is beyond the // bound of the texture. private static void convertCoordinate(RectF source, RectF target, BasicTexture texture) { int width = texture.getWidth(); int height = texture.getHeight(); int texWidth = texture.getTextureWidth(); int texHeight = texture.getTextureHeight(); // Convert to texture coordinates source.left /= texWidth; source.right /= texWidth; source.top /= texHeight; source.bottom /= texHeight; // Clip if the rendering range is beyond the bound of the texture. float xBound = (float) width / texWidth; if (source.right > xBound) { target.right = target.left + target.width() * (xBound - source.left) / source.width(); source.right = xBound; } float yBound = (float) height / texHeight; if (source.bottom > yBound) { target.bottom = target.top + target.height() * (yBound - source.top) / source.height(); source.bottom = yBound; } } private static void checkFramebufferStatus() { int status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER); if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) { String msg = switch (status) { case GLES20.GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT -> "GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT"; case GLES20.GL_FRAMEBUFFER_INCOMPLETE_DIMENSIONS -> "GL_FRAMEBUFFER_INCOMPLETE_DIMENSIONS"; case GLES20.GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT -> "GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT"; case GLES20.GL_FRAMEBUFFER_UNSUPPORTED -> "GL_FRAMEBUFFER_UNSUPPORTED"; default -> ""; }; throw new RuntimeException(msg + ":" + Integer.toHexString(status)); } } public static void checkError() { int error = GLES20.glGetError(); if (error != 0) { Throwable t = new Throwable(); Log.e(TAG, "GL error: " + error, t); } } @SuppressWarnings("unused") private static void printMatrix(String message, float[] m, int offset) { StringBuilder b = new StringBuilder(message); for (int i = 0; i < MATRIX_SIZE; i++) { b.append(' '); if (i % 4 == 0) { b.append('\n'); } b.append(m[offset + i]); } Log.v(TAG, b.toString()); } private int assembleProgram(int vertexShader, int fragmentShader, ShaderParameter[] params) { int program = GLES20.glCreateProgram(); checkError(); if (program == 0) { throw new RuntimeException("Cannot create GL program: " + GLES20.glGetError()); } GLES20.glAttachShader(program, vertexShader); checkError(); GLES20.glAttachShader(program, fragmentShader); checkError(); GLES20.glLinkProgram(program); checkError(); int[] mLinkStatus = mTempIntArray; GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, mLinkStatus, 0); if (mLinkStatus[0] != GLES20.GL_TRUE) { Log.e(TAG, "Could not link program: "); Log.e(TAG, GLES20.glGetProgramInfoLog(program)); GLES20.glDeleteProgram(program); program = 0; } for (ShaderParameter param : params) { param.loadHandle(program); } return program; } @Override public void setSize(int width, int height) { mWidth = width; mHeight = height; GLES20.glViewport(0, 0, mWidth, mHeight); checkError(); Matrix.setIdentityM(mMatrices, mCurrentMatrixIndex); Matrix.orthoM(mProjectionMatrix, 0, 0, width, 0, height, -1, 1); if (getTargetTexture() == null) { mScreenWidth = width; mScreenHeight = height; Matrix.translateM(mMatrices, mCurrentMatrixIndex, 0, height, 0); Matrix.scaleM(mMatrices, mCurrentMatrixIndex, 1, -1, 1); } } @Override public void clearBuffer() { GLES20.glClearColor(0f, 0f, 0f, 1f); checkError(); GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); checkError(); } @Override public void clearBuffer(float[] argb) { GLES20.glClearColor(argb[1], argb[2], argb[3], argb[0]); checkError(); GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); checkError(); } @Override public float getAlpha() { return mAlphas[mCurrentAlphaIndex]; } @Override public void setAlpha(float alpha) { mAlphas[mCurrentAlphaIndex] = alpha; } @Override public void multiplyAlpha(float alpha) { setAlpha(getAlpha() * alpha); } @Override public void translate(float x, float y, float z) { Matrix.translateM(mMatrices, mCurrentMatrixIndex, x, y, z); } // This is a faster version of translate(x, y, z) because // (1) we knows z = 0, (2) we inline the Matrix.translateM call, // (3) we unroll the loop @Override public void translate(float x, float y) { int index = mCurrentMatrixIndex; float[] m = mMatrices; m[index + 12] += m[index] * x + m[index + 4] * y; m[index + 13] += m[index + 1] * x + m[index + 5] * y; m[index + 14] += m[index + 2] * x + m[index + 6] * y; m[index + 15] += m[index + 3] * x + m[index + 7] * y; } @Override public void scale(float sx, float sy, float sz) { Matrix.scaleM(mMatrices, mCurrentMatrixIndex, sx, sy, sz); } @Override public void rotate(float angle, float x, float y, float z) { if (angle == 0f) { return; } float[] temp = mTempMatrix; Matrix.setRotateM(temp, 0, angle, x, y, z); float[] matrix = mMatrices; int index = mCurrentMatrixIndex; Matrix.multiplyMM(temp, MATRIX_SIZE, matrix, index, temp, 0); System.arraycopy(temp, MATRIX_SIZE, matrix, index, MATRIX_SIZE); } @Override public void multiplyMatrix(float[] matrix, int offset) { float[] temp = mTempMatrix; float[] currentMatrix = mMatrices; int index = mCurrentMatrixIndex; Matrix.multiplyMM(temp, 0, currentMatrix, index, matrix, offset); System.arraycopy(temp, 0, currentMatrix, index, 16); } @Override public void save() { save(SAVE_FLAG_ALL); } @Override public void save(int saveFlags) { boolean saveAlpha = (saveFlags & SAVE_FLAG_ALPHA) == SAVE_FLAG_ALPHA; if (saveAlpha) { float currentAlpha = getAlpha(); mCurrentAlphaIndex++; if (mAlphas.length <= mCurrentAlphaIndex) { mAlphas = Arrays.copyOf(mAlphas, mAlphas.length * 2); } mAlphas[mCurrentAlphaIndex] = currentAlpha; } boolean saveMatrix = (saveFlags & SAVE_FLAG_MATRIX) == SAVE_FLAG_MATRIX; if (saveMatrix) { int currentIndex = mCurrentMatrixIndex; mCurrentMatrixIndex += MATRIX_SIZE; if (mMatrices.length <= mCurrentMatrixIndex) { mMatrices = Arrays.copyOf(mMatrices, mMatrices.length * 2); } System.arraycopy(mMatrices, currentIndex, mMatrices, mCurrentMatrixIndex, MATRIX_SIZE); } mSaveFlags.add(saveFlags); } @Override public void restore() { int restoreFlags = mSaveFlags.removeAt(mSaveFlags.size() - 1); boolean restoreAlpha = (restoreFlags & SAVE_FLAG_ALPHA) == SAVE_FLAG_ALPHA; if (restoreAlpha) { mCurrentAlphaIndex--; } boolean restoreMatrix = (restoreFlags & SAVE_FLAG_MATRIX) == SAVE_FLAG_MATRIX; if (restoreMatrix) { mCurrentMatrixIndex -= MATRIX_SIZE; } } @Override public void drawLine(float x1, float y1, float x2, float y2, GLPaint paint) { draw(GLES20.GL_LINE_STRIP, OFFSET_DRAW_LINE, COUNT_LINE_VERTEX, x1, y1, x2 - x1, y2 - y1, paint); mCountDrawLine++; } @Override public void drawRect(float x, float y, float width, float height, GLPaint paint) { draw(GLES20.GL_LINE_LOOP, OFFSET_DRAW_RECT, COUNT_RECT_VERTEX, x, y, width, height, paint); mCountDrawLine++; } @Override public void drawOval(float cx, float cy, float radiusX, float radiusY, GLPaint paint) { float halfLineWidth = paint.getLineWidth() / 2; fillOval(cx, cy, radiusX + halfLineWidth, radiusY + halfLineWidth, paint.getColor()); fillOval(cx, cy, radiusX - halfLineWidth, radiusY - halfLineWidth, paint.getBackgroundColor()); } @Override public void drawArc(float cx, float cy, float radiusX, float radiusY, float sweepAngle, GLPaint paint) { float halfLineWidth = paint.getLineWidth() / 2; fillSector(cx, cy, radiusX + halfLineWidth, radiusY + halfLineWidth, sweepAngle, paint.getColor()); fillOval(cx, cy, radiusX - halfLineWidth, radiusY - halfLineWidth, paint.getBackgroundColor()); } private void draw(int type, int offset, int count, float x, float y, float width, float height, GLPaint paint) { draw(type, offset, count, x, y, width, height, paint.getColor(), paint.getLineWidth()); } private void draw(int type, int offset, int count, float x, float y, float width, float height, int color, float lineWidth) { prepareDraw(offset, color, lineWidth); draw(mDrawParameters, type, count, x, y, width, height); } private void prepareDraw(int offset, int color, float lineWidth) { GLES20.glUseProgram(mDrawProgram); checkError(); if (lineWidth > 0) { GLES20.glLineWidth(lineWidth); checkError(); } float[] colorArray = getColor(color); boolean blendingEnabled = (colorArray[3] < 1f); enableBlending(blendingEnabled); if (blendingEnabled) { GLES20.glBlendColor(colorArray[0], colorArray[1], colorArray[2], colorArray[3]); checkError(); } GLES20.glUniform4fv(mDrawParameters[INDEX_COLOR].handle, 1, colorArray, 0); setPosition(mDrawParameters, offset); checkError(); } private float[] getColor(int color) { float alpha = ((color >>> 24) & 0xFF) / 255f * getAlpha(); float red = ((color >>> 16) & 0xFF) / 255f * alpha; float green = ((color >>> 8) & 0xFF) / 255f * alpha; float blue = (color & 0xFF) / 255f * alpha; mTempColor[0] = red; mTempColor[1] = green; mTempColor[2] = blue; mTempColor[3] = alpha; return mTempColor; } private void enableBlending(boolean enableBlending) { if (enableBlending) { GLES20.glEnable(GLES20.GL_BLEND); checkError(); } else { GLES20.glDisable(GLES20.GL_BLEND); checkError(); } } private void setPosition(ShaderParameter[] params, int offset) { GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mBoxCoordinates); checkError(); GLES20.glVertexAttribPointer(params[INDEX_POSITION].handle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, VERTEX_STRIDE, offset * VERTEX_STRIDE); checkError(); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); checkError(); } private void draw(ShaderParameter[] params, int type, int count, float x, float y, float width, float height) { setMatrix(params, x, y, width, height); int positionHandle = params[INDEX_POSITION].handle; GLES20.glEnableVertexAttribArray(positionHandle); checkError(); GLES20.glDrawArrays(type, 0, count); checkError(); GLES20.glDisableVertexAttribArray(positionHandle); checkError(); } private void setMatrix(ShaderParameter[] params, float x, float y, float width, float height) { Matrix.translateM(mTempMatrix, 0, mMatrices, mCurrentMatrixIndex, x, y, 0f); Matrix.scaleM(mTempMatrix, 0, width, height, 1f); Matrix.multiplyMM(mTempMatrix, MATRIX_SIZE, mProjectionMatrix, 0, mTempMatrix, 0); GLES20.glUniformMatrix4fv(params[INDEX_MATRIX].handle, 1, false, mTempMatrix, MATRIX_SIZE); checkError(); } @Override public void fillRect(float x, float y, float width, float height, int color) { draw(GLES20.GL_TRIANGLE_STRIP, OFFSET_FILL_RECT, COUNT_FILL_VERTEX, x, y, width, height, color, 0f); mCountFillRect++; } @Override public void fillOval(float cx, float cy, float radiusX, float radiusY, int color) { draw(GLES20.GL_TRIANGLE_FAN, OFFSET_FILL_CIRCLE, OFFSET_LAST - OFFSET_FILL_CIRCLE, cx - radiusX, cy - radiusY, radiusX * 2, radiusY * 2, color, 0f); mCountFillRect++; } @Override public void fillSector(float cx, float cy, float radiusX, float radiusY, float sweepAngle, int color) { float conjugateAngle = Math.abs(360 - MathUtils.positiveModulo(sweepAngle, 360)); int conjugateCount = Math.round(COUNT_CIRCLE_VERTEX * conjugateAngle / 360); if (conjugateCount == 0) { // It is a circle fillOval(cx, cy, radiusX, radiusY, color); } else if (conjugateCount < COUNT_CIRCLE_VERTEX) { draw(GLES20.GL_TRIANGLE_FAN, OFFSET_FILL_CIRCLE, OFFSET_LAST - OFFSET_FILL_CIRCLE - conjugateCount, cx - radiusX, cy - radiusY, radiusX * 2, radiusY * 2, color, 0f); mCountFillRect++; } } @Override public void drawTexture(BasicTexture texture, int x, int y, int width, int height) { if (width <= 0 || height <= 0) { return; } copyTextureCoordinates(texture, mTempSourceRect); mTempTargetRect.set(x, y, x + width, y + height); convertCoordinate(mTempSourceRect, mTempTargetRect, texture); drawTextureRect(texture, mTempSourceRect, mTempTargetRect); } @Override public void drawTexture(BasicTexture texture, RectF source, RectF target) { if (target.width() <= 0 || target.height() <= 0) { return; } mTempSourceRect.set(source); mTempTargetRect.set(target); convertCoordinate(mTempSourceRect, mTempTargetRect, texture); drawTextureRect(texture, mTempSourceRect, mTempTargetRect); } @Override public void drawTexture(BasicTexture texture, float[] textureTransform, int x, int y, int w, int h) { if (w <= 0 || h <= 0) { return; } mTempTargetRect.set(x, y, x + w, y + h); drawTextureRect(texture, textureTransform, mTempTargetRect); } private void drawTextureRect(BasicTexture texture, RectF source, RectF target) { setTextureMatrix(source); drawTextureRect(texture, mTempTextureMatrix, target); } private void setTextureMatrix(RectF source) { mTempTextureMatrix[0] = source.width(); mTempTextureMatrix[5] = source.height(); mTempTextureMatrix[12] = source.left; mTempTextureMatrix[13] = source.top; } private void drawTextureRect(BasicTexture texture, float[] textureMatrix, RectF target) { ShaderParameter[] params = mTextureParameters; prepareTexture(texture, mTextureProgram, params); setPosition(params, OFFSET_FILL_RECT); GLES20.glUniformMatrix4fv(params[INDEX_TEXTURE_MATRIX].handle, 1, false, textureMatrix, 0); checkError(); if (texture.isFlippedVertically()) { save(SAVE_FLAG_MATRIX); translate(0, target.centerY()); scale(1, -1, 1); translate(0, -target.centerY()); } draw(params, GLES20.GL_TRIANGLE_STRIP, COUNT_FILL_VERTEX, target.left, target.top, target.width(), target.height()); if (texture.isFlippedVertically()) { restore(); } mCountTextureRect++; } private void prepareTexture(BasicTexture texture, int program, ShaderParameter[] params) { GLES20.glUseProgram(program); checkError(); enableBlending(!texture.isOpaque() || getAlpha() < OPAQUE_ALPHA); GLES20.glActiveTexture(GLES20.GL_TEXTURE0); checkError(); texture.onBind(this); GLES20.glBindTexture(texture.getTarget(), texture.getId()); checkError(); GLES20.glUniform1i(params[INDEX_TEXTURE_SAMPLER].handle, 0); checkError(); GLES20.glUniform1f(params[INDEX_ALPHA].handle, getAlpha()); checkError(); } @Override public void drawMesh(BasicTexture texture, int x, int y, int xyBuffer, int uvBuffer, int indexBuffer, int indexCount) { prepareTexture(texture, mMeshProgram, mMeshParameters); GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer); checkError(); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, xyBuffer); checkError(); int positionHandle = mMeshParameters[INDEX_POSITION].handle; GLES20.glVertexAttribPointer(positionHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, VERTEX_STRIDE, 0); checkError(); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, uvBuffer); checkError(); int texCoordHandle = mMeshParameters[INDEX_TEXTURE_COORD].handle; GLES20.glVertexAttribPointer(texCoordHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, VERTEX_STRIDE, 0); checkError(); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); checkError(); GLES20.glEnableVertexAttribArray(positionHandle); checkError(); GLES20.glEnableVertexAttribArray(texCoordHandle); checkError(); setMatrix(mMeshParameters, x, y, 1, 1); GLES20.glDrawElements(GLES20.GL_TRIANGLE_STRIP, indexCount, GLES20.GL_UNSIGNED_BYTE, 0); checkError(); GLES20.glDisableVertexAttribArray(positionHandle); checkError(); GLES20.glDisableVertexAttribArray(texCoordHandle); checkError(); GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0); checkError(); mCountDrawMesh++; } @Override public void drawMixed(BasicTexture texture, int toColor, float ratio, int x, int y, int w, int h) { copyTextureCoordinates(texture, mTempSourceRect); mTempTargetRect.set(x, y, x + w, y + h); drawMixed(texture, toColor, ratio, mTempSourceRect, mTempTargetRect); } @Override public void drawMixed(BasicTexture texture, int toColor, float ratio, RectF source, RectF target) { if (target.width() <= 0 || target.height() <= 0) { return; } save(SAVE_FLAG_ALPHA); float currentAlpha = getAlpha(); float cappedRatio = Math.min(1f, Math.max(0f, ratio)); float textureAlpha = (1f - cappedRatio) * currentAlpha; setAlpha(textureAlpha); drawTexture(texture, source, target); if (0 != Color.alpha(toColor)) { float colorAlpha = cappedRatio * currentAlpha; setAlpha(colorAlpha); fillRect(target.left, target.top, target.width(), target.height(), toColor); } restore(); } @Override public boolean unloadTexture(BasicTexture texture) { boolean unload = texture.isLoaded(); if (unload) { synchronized (mUnboundTextures) { mUnboundTextures.add(texture.getId()); } } return unload; } @Override public void deleteBuffer(int bufferId) { synchronized (mUnboundTextures) { mDeleteBuffers.add(bufferId); } } @Override public void deleteRecycledResources() { synchronized (mUnboundTextures) { IntList ids = mUnboundTextures; if (!mUnboundTextures.isEmpty()) { mGLId.glDeleteTextures(null, ids.size(), ids.getInternalArray(), 0); ids.clear(); } ids = mDeleteBuffers; if (!ids.isEmpty()) { mGLId.glDeleteBuffers(null, ids.size(), ids.getInternalArray(), 0); ids.clear(); } } } @Override public void dumpStatisticsAndClear() { String line = String.format(Locale.US, "MESH:%d, TEX_RECT:%d, FILL_RECT:%d, LINE:%d", mCountDrawMesh, mCountTextureRect, mCountFillRect, mCountDrawLine); mCountDrawMesh = 0; mCountTextureRect = 0; mCountFillRect = 0; mCountDrawLine = 0; Log.d(TAG, line); } @Override public void endRenderTarget() { //noinspection SequencedCollectionMethodCanBeUsed RawTexture oldTexture = mTargetTextures.remove(mTargetTextures.size() - 1); RawTexture texture = getTargetTexture(); setRenderTarget(oldTexture, texture); restore(); // restore matrix and alpha } @Override public void beginRenderTarget(RawTexture texture) { save(); // save matrix and alpha and blending RawTexture oldTexture = getTargetTexture(); mTargetTextures.add(texture); setRenderTarget(oldTexture, texture); } private RawTexture getTargetTexture() { //noinspection SequencedCollectionMethodCanBeUsed return mTargetTextures.get(mTargetTextures.size() - 1); } private void setRenderTarget(BasicTexture oldTexture, RawTexture texture) { if (oldTexture == null && texture != null) { GLES20.glGenFramebuffers(1, mFrameBuffer, 0); checkError(); GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffer[0]); checkError(); } else if (oldTexture != null && texture == null) { GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0); checkError(); GLES20.glDeleteFramebuffers(1, mFrameBuffer, 0); checkError(); } if (texture == null) { setSize(mScreenWidth, mScreenHeight); } else { setSize(texture.getWidth(), texture.getHeight()); if (!texture.isLoaded()) { texture.prepare(this); } GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, texture.getTarget(), texture.getId(), 0); checkError(); checkFramebufferStatus(); } } @Override public void setTextureParameters(BasicTexture texture) { int target = texture.getTarget(); GLES20.glBindTexture(target, texture.getId()); checkError(); GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); GLES20.glTexParameterf(target, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameterf(target, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); } @Override public void initializeTextureSize(BasicTexture texture, int format, int type) { int target = texture.getTarget(); GLES20.glBindTexture(target, texture.getId()); checkError(); int width = texture.getTextureWidth(); int height = texture.getTextureHeight(); GLES20.glTexImage2D(target, 0, format, width, height, 0, format, type, null); } @Override public void initializeTexture(BasicTexture texture, Bitmap bitmap) { int target = texture.getTarget(); GLES20.glBindTexture(target, texture.getId()); checkError(); GLUtils.texImage2D(target, 0, bitmap, 0); } @Override public void texSubImage2D(BasicTexture texture, int xOffset, int yOffset, Bitmap bitmap, int format, int type) { int target = texture.getTarget(); GLES20.glBindTexture(target, texture.getId()); checkError(); GLUtils.texSubImage2D(target, 0, xOffset, yOffset, bitmap, format, type); } @Override public int uploadBuffer(FloatBuffer buf) { return uploadBuffer(buf, FLOAT_SIZE); } @Override public int uploadBuffer(ByteBuffer buf) { return uploadBuffer(buf, 1); } private int uploadBuffer(Buffer buffer, int elementSize) { mGLId.glGenBuffers(1, mTempIntArray, 0); checkError(); int bufferId = mTempIntArray[0]; GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, bufferId); checkError(); GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, buffer.capacity() * elementSize, buffer, GLES20.GL_STATIC_DRAW); checkError(); return bufferId; } @Override public void recoverFromLightCycle() { GLES20.glViewport(0, 0, mWidth, mHeight); GLES20.glDisable(GLES20.GL_DEPTH_TEST); GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA); checkError(); } @Override public void getBounds(Rect bounds, int x, int y, int width, int height) { Matrix.translateM(mTempMatrix, 0, mMatrices, mCurrentMatrixIndex, x, y, 0f); Matrix.scaleM(mTempMatrix, 0, width, height, 1f); Matrix.multiplyMV(mTempMatrix, MATRIX_SIZE, mTempMatrix, 0, BOUNDS_COORDINATES, 0); Matrix.multiplyMV(mTempMatrix, MATRIX_SIZE + 4, mTempMatrix, 0, BOUNDS_COORDINATES, 4); bounds.left = Math.round(mTempMatrix[MATRIX_SIZE]); bounds.right = Math.round(mTempMatrix[MATRIX_SIZE + 4]); bounds.top = Math.round(mTempMatrix[MATRIX_SIZE + 1]); bounds.bottom = Math.round(mTempMatrix[MATRIX_SIZE + 5]); bounds.sort(); } @Override public GLId getGLId() { return mGLId; } private abstract static class ShaderParameter { protected final String mName; public int handle; public ShaderParameter(String name) { mName = name; } public abstract void loadHandle(int program); } private static class UniformShaderParameter extends ShaderParameter { public UniformShaderParameter(String name) { super(name); } @Override public void loadHandle(int program) { handle = GLES20.glGetUniformLocation(program, mName); checkError(); } } private static class AttributeShaderParameter extends ShaderParameter { public AttributeShaderParameter(String name) { super(name); } @Override public void loadHandle(int program) { handle = GLES20.glGetAttribLocation(program, mName); checkError(); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/GLES20IdImpl.java ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.glview.glrenderer; import android.opengl.GLES20; import javax.microedition.khronos.opengles.GL11; import javax.microedition.khronos.opengles.GL11ExtensionPack; public class GLES20IdImpl implements GLId { private final int[] mTempIntArray = new int[1]; @Override public int generateTexture() { GLES20.glGenTextures(1, mTempIntArray, 0); GLES20Canvas.checkError(); return mTempIntArray[0]; } @Override public void glGenBuffers(int n, int[] buffers, int offset) { GLES20.glGenBuffers(n, buffers, offset); GLES20Canvas.checkError(); } @Override public void glDeleteTextures(GL11 gl, int n, int[] textures, int offset) { GLES20.glDeleteTextures(n, textures, offset); GLES20Canvas.checkError(); } @Override public void glDeleteBuffers(GL11 gl, int n, int[] buffers, int offset) { GLES20.glDeleteBuffers(n, buffers, offset); GLES20Canvas.checkError(); } @Override public void glDeleteFramebuffers(GL11ExtensionPack gl11ep, int n, int[] buffers, int offset) { GLES20.glDeleteFramebuffers(n, buffers, offset); GLES20Canvas.checkError(); } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/GLId.java ================================================ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import javax.microedition.khronos.opengles.GL11; import javax.microedition.khronos.opengles.GL11ExtensionPack; // This mimics corresponding GL functions. public interface GLId { int generateTexture(); void glGenBuffers(int n, int[] buffers, int offset); void glDeleteTextures(GL11 gl, int n, int[] textures, int offset); void glDeleteBuffers(GL11 gl, int n, int[] buffers, int offset); void glDeleteFramebuffers(GL11ExtensionPack gl11ep, int n, int[] buffers, int offset); } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/GLPaint.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; public class GLPaint { private float mLineWidth = 1f; private int mColor = 0; private int mBackgroundColor = 0; public int getColor() { return mColor; } public void setColor(int color) { mColor = color; } public int getBackgroundColor() { return mBackgroundColor; } public void setBackgroundColor(int backgroundColor) { mBackgroundColor = backgroundColor; } public float getLineWidth() { return mLineWidth; } public void setLineWidth(float width) { if (width < 0) { throw new IllegalArgumentException("width < 0"); } mLineWidth = width; } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/MovableTextTexture.java ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Typeface; import java.util.Arrays; // TODO support multiline /** * Works like movable type.
*
* Only support single line now */ public final class MovableTextTexture extends SpriteTexture { private final char[] mCharacters; private final float[] mWidths; private final float mHeight; private final float mMaxWidth; private MovableTextTexture(Bitmap bitmap, int count, int[] rects, char[] characters, float[] widths, float height, float maxWidth) { super(bitmap, false, count, rects); mCharacters = characters; mWidths = widths; mHeight = height; mMaxWidth = maxWidth; } /** * Create a TextTexture to draw text * * @param typeface the typeface * @param size text size * @param characters all Characters * @return the TextTexture */ public static MovableTextTexture create(Typeface typeface, int size, int color, char[] characters) { Paint paint = new Paint(); paint.setAntiAlias(true); paint.setTextSize(size); paint.setColor(color); paint.setTypeface(typeface); Paint.FontMetricsInt fmi = paint.getFontMetricsInt(); int fixed = fmi.bottom; int height = fmi.bottom - fmi.top; int length = characters.length; float[] widths = new float[length]; paint.getTextWidths(characters, 0, length, widths); // Calculate bitmap size float maxWidth = 0.0f; for (float f : widths) { maxWidth = Math.max(maxWidth, f); } int hCount = (int) Math.ceil(Math.sqrt(height / maxWidth * length)); int vCount = (int) Math.ceil(Math.sqrt(maxWidth / height * length)); if (hCount * (vCount - 1) > length) { vCount--; } if ((hCount - 1) * vCount > length) { hCount--; } Bitmap bitmap = Bitmap.createBitmap((int) Math.ceil(hCount * maxWidth), (int) (double) (vCount * height), Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); canvas.translate(0, height - fixed); // Draw int[] rects = new int[length * 4]; int x = 0; int y = 0; for (int i = 0; i < length; i++) { int offset = i * 4; rects[offset] = x; rects[offset + 1] = y; rects[offset + 2] = (int) widths[i]; rects[offset + 3] = height; canvas.drawText(characters, i, 1, x, y, paint); if (i % hCount == hCount - 1) { // The end of row x = 0; y += height; } else { x += (int) maxWidth; } } return new MovableTextTexture(bitmap, length, rects, characters, widths, height, maxWidth); } public int[] getTextIndexes(String text) { int length = text.length(); int[] indexes = new int[length]; for (int i = 0; i < length; i++) { char ch = text.charAt(i); indexes[i] = Arrays.binarySearch(mCharacters, ch); } return indexes; } public float getTextWidth(String text) { float width = 0.0f; for (int i = 0, n = text.length(); i < n; i++) { char ch = text.charAt(i); int index = Arrays.binarySearch(mCharacters, ch); if (index >= 0) { width += mWidths[index]; } else { width += mMaxWidth; } } return width; } public float getTextWidth(int[] indexes) { float width = 0.0f; for (int index : indexes) { if (index >= 0) { width += mWidths[index]; } else { width += mMaxWidth; } } return width; } public float getMaxWidth() { return mMaxWidth; } public float getTextHeight() { return mHeight; } public void drawText(GLCanvas canvas, String text, int x, int y) { for (int i = 0, n = text.length(); i < n; i++) { char ch = text.charAt(i); int index = Arrays.binarySearch(mCharacters, ch); if (index >= 0) { drawSprite(canvas, index, x, y); x += (int) mWidths[index]; } else { x += (int) mMaxWidth; } } } public void drawText(GLCanvas canvas, int[] indexes, int x, int y) { for (int index : indexes) { if (index >= 0) { drawSprite(canvas, index, x, y); x += (int) mWidths[index]; } else { x += (int) mMaxWidth; } } } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/NativeTexture.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.opengl.GLES20; import android.util.Log; import javax.microedition.khronos.opengles.GL11; public abstract class NativeTexture extends BasicTexture { private static final String TAG = NativeTexture.class.getSimpleName(); private boolean mContentValid = true; private boolean mOpaque = true; public static void checkError() { int error = GLES20.glGetError(); if (error != 0) { Throwable t = new Throwable(); Log.e(TAG, "GL error: " + error, t); } } public void invalidateContent() { mContentValid = false; } protected abstract void texImage(boolean init); private void uploadToCanvas(GLCanvas canvas) { // Get id mId = canvas.getGLId().generateTexture(); // Prepare canvas.setTextureParameters(this); // Call glTexImage2D GLES20.glBindTexture(getTarget(), mId); checkError(); texImage(true); checkError(); setAssociatedCanvas(canvas); mState = STATE_LOADED; mContentValid = true; } public void updateContent(GLCanvas canvas) { if (!isLoaded()) { uploadToCanvas(canvas); } else if (!mContentValid) { // Call glTexSubImage2D GLES20.glBindTexture(getTarget(), mId); checkError(); texImage(false); checkError(); mContentValid = true; } } /** * Whether the content on GPU is valid. */ public boolean isContentValid() { return isLoaded() && mContentValid; } @Override protected boolean onBind(GLCanvas canvas) { updateContent(canvas); return isContentValid(); } @Override protected int getTarget() { return GL11.GL_TEXTURE_2D; } @Override public boolean isOpaque() { return mOpaque; } public void setOpaque(boolean isOpaque) { mOpaque = isOpaque; } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/RawTexture.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.util.Log; import javax.microedition.khronos.opengles.GL11; public class RawTexture extends BasicTexture { private static final String TAG = "RawTexture"; private final boolean mOpaque; private boolean mIsFlipped; public RawTexture(int width, int height, boolean opaque) { mOpaque = opaque; setSize(width, height); } @Override public boolean isOpaque() { return mOpaque; } @Override public boolean isFlippedVertically() { return mIsFlipped; } public void setIsFlippedVertically(boolean isFlipped) { mIsFlipped = isFlipped; } protected void prepare(GLCanvas canvas) { GLId glId = canvas.getGLId(); mId = glId.generateTexture(); canvas.initializeTextureSize(this, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE); canvas.setTextureParameters(this); mState = STATE_LOADED; setAssociatedCanvas(canvas); } @Override protected boolean onBind(GLCanvas canvas) { if (isLoaded()) return true; Log.w(TAG, "lost the content due to context change"); return false; } @Override public void yield() { // we cannot free the texture because we have no backup. } @Override protected int getTarget() { return GL11.GL_TEXTURE_2D; } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/SpriteTexture.java ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.RectF; import com.hippo.yorozuya.AssertUtils; public class SpriteTexture extends TiledTexture { private final int mCount; private final int[] mRects; private final RectF mTempSource = new RectF(); private final RectF mTempTarget = new RectF(); public SpriteTexture(Bitmap bitmap, boolean isOpaque, int count, int[] rects) { super(bitmap, isOpaque); AssertUtils.assertEquals("rects.length must be count * 4", count * 4, rects.length); mCount = count; mRects = rects; } public int getCount() { return mCount; } public void drawSprite(GLCanvas canvas, int index, int x, int y) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; int sourceWidth = rects[offset + 2]; int sourceHeight = rects[offset + 3]; mTempSource.set(sourceX, sourceY, sourceX + sourceWidth, sourceY + sourceHeight); mTempTarget.set(x, y, x + sourceWidth, y + sourceHeight); draw(canvas, mTempSource, mTempTarget); } public void drawSprite(GLCanvas canvas, int index, int x, int y, int width, int height) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; mTempSource.set(sourceX, sourceY, sourceX + rects[offset + 2], sourceY + rects[offset + 3]); mTempTarget.set(x, y, x + width, y + height); draw(canvas, mTempSource, mTempTarget); } public void drawSpriteMixed(GLCanvas canvas, int index, int color, float ratio, int x, int y) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; int sourceWidth = rects[offset + 2]; int sourceHeight = rects[offset + 3]; mTempSource.set(sourceX, sourceY, sourceX + sourceWidth, sourceY + sourceHeight); mTempTarget.set(x, y, x + sourceWidth, y + sourceHeight); drawMixed(canvas, color, ratio, mTempSource, mTempTarget); } public void drawSpriteMixed(GLCanvas canvas, int index, int color, float ratio, int x, int y, int width, int height) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; mTempSource.set(sourceX, sourceY, sourceX + rects[offset + 2], sourceY + rects[offset + 3]); mTempTarget.set(x, y, x + width, y + height); drawMixed(canvas, color, ratio, mTempSource, mTempTarget); } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/StringTexture.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint.FontMetricsInt; import android.graphics.Typeface; import android.text.TextPaint; import android.text.TextUtils; // StringTexture is a texture shows the content of a specified String. // // To create a StringTexture, use the newInstance() method and specify // the String, the font size, and the color. public class StringTexture extends CanvasTexture { private final String mText; private final TextPaint mPaint; private final FontMetricsInt mMetrics; private StringTexture(String text, TextPaint paint, FontMetricsInt metrics, int width, int height) { super(width, height); mText = text; mPaint = paint; mMetrics = metrics; } public static TextPaint getDefaultPaint(float textSize, int color) { TextPaint paint = new TextPaint(); paint.setTextSize(textSize); paint.setAntiAlias(true); paint.setColor(color); paint.setShadowLayer(2f, 0f, 0f, Color.BLACK); return paint; } public static StringTexture newInstance( String text, float textSize, int color) { return newInstance(text, getDefaultPaint(textSize, color)); } public static StringTexture newInstance( String text, float textSize, int color, float lengthLimit, boolean isBold) { TextPaint paint = getDefaultPaint(textSize, color); if (isBold) { paint.setTypeface(Typeface.defaultFromStyle(Typeface.BOLD)); } if (lengthLimit > 0) { text = TextUtils.ellipsize( text, paint, lengthLimit, TextUtils.TruncateAt.END).toString(); } return newInstance(text, paint); } private static StringTexture newInstance(String text, TextPaint paint) { FontMetricsInt metrics = paint.getFontMetricsInt(); int width = (int) Math.ceil(paint.measureText(text)); int height = metrics.bottom - metrics.top; // The texture size needs to be at least 1x1. if (width <= 0) width = 1; if (height <= 0) height = 1; return new StringTexture(text, paint, metrics, width, height); } @Override protected void onDraw(Canvas canvas, Bitmap backing) { canvas.translate(0, -mMetrics.ascent); canvas.drawText(mText, 0, 0, mPaint); } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/Texture.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.RectF; // Texture is a rectangular image which can be drawn on GLCanvas. // The isOpaque() function gives a hint about whether the texture is opaque, // so the drawing can be done faster. // // This is the current texture hierarchy: // // Texture // -- BasicTexture // -- UploadedTexture // -- BitmapTexture // -- Tile // -- CanvasTexture // -- StringTexture // public interface Texture { int getWidth(); int getHeight(); void draw(GLCanvas canvas, int x, int y); void draw(GLCanvas canvas, int x, int y, int w, int h); void draw(GLCanvas canvas, RectF source, RectF target); boolean isOpaque(); } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/TiledTexture.java ================================================ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.graphics.RectF; import android.os.SystemClock; import com.hippo.glview.view.GLRoot; import java.util.ArrayDeque; import java.util.ArrayList; // This class is similar to BitmapTexture, except the bitmap is // split into tiles. By doing so, we may increase the time required to // upload the whole bitmap but we reduce the time of uploading each tile // so it make the animation more smooth and prevents jank. public class TiledTexture implements Texture { private static final int CONTENT_SIZE = 254; private static final int BORDER_SIZE = 1; private static final int TILE_SIZE = CONTENT_SIZE + 2 * BORDER_SIZE; private static final int INIT_CAPACITY = 8; // We are targeting at 60fps, so we have 16ms for each frame. // In this 16ms, we use about 4~8 ms to upload tiles. private static final long UPLOAD_TILE_LIMIT = 4; // ms private static final Object sFreeTileLock = new Object(); private static final Bitmap sUploadBitmap; private static final Canvas sCanvas; private static final Paint sBitmapPaint; private static final Paint sPaint; private static Tile sFreeTileHead = null; static { sUploadBitmap = Bitmap.createBitmap(TILE_SIZE, TILE_SIZE, Bitmap.Config.ARGB_8888); sCanvas = new Canvas(sUploadBitmap); sBitmapPaint = new Paint(Paint.FILTER_BITMAP_FLAG); sBitmapPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC)); sPaint = new Paint(); sPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC)); sPaint.setColor(Color.TRANSPARENT); } private final Tile[] mTiles; // Can be modified in different threads. // Should be protected by "synchronized." private final int mWidth; private final int mHeight; private final RectF mSrcRect = new RectF(); private final RectF mDestRect = new RectF(); private int mUploadIndex = 0; public TiledTexture(Bitmap bitmap, boolean isOpaque) { mWidth = bitmap.getWidth(); mHeight = bitmap.getHeight(); ArrayList list = new ArrayList<>(); for (int x = 0; x < mWidth; x += CONTENT_SIZE) { for (int y = 0; y < mHeight; y += CONTENT_SIZE) { Tile tile = obtainTile(); tile.offsetX = x; tile.offsetY = y; tile.bitmap = bitmap; tile.setSize( Math.min(CONTENT_SIZE, mWidth - x), Math.min(CONTENT_SIZE, mHeight - y)); tile.setOpaque(isOpaque); list.add(tile); } } mTiles = list.toArray(new Tile[0]); } private static void freeTile(Tile tile) { tile.invalidateContent(); tile.bitmap = null; synchronized (sFreeTileLock) { tile.nextFreeTile = sFreeTileHead; sFreeTileHead = tile; } } private static Tile obtainTile() { synchronized (sFreeTileLock) { Tile result = sFreeTileHead; if (result == null) return new Tile(); sFreeTileHead = result.nextFreeTile; result.nextFreeTile = null; return result; } } // We want to draw the "source" on the "target". // This method is to find the "output" rectangle which is // the corresponding area of the "src". // (x,y) target // (x0,y0) source +---------------+ // +----------+ | | // | src | | output | // | +--+ | linear map | +----+ | // | +--+ | ----------> | | | | // | | by (scaleX, scaleY) | +----+ | // +----------+ | | // Texture +---------------+ // Canvas private static void mapRect(RectF output, RectF src, float x0, float y0, float x, float y, float scaleX, float scaleY) { output.set(x + (src.left - x0) * scaleX, y + (src.top - y0) * scaleY, x + (src.right - x0) * scaleX, y + (src.bottom - y0) * scaleY); } private boolean uploadNextTile(GLCanvas canvas) { if (mUploadIndex == mTiles.length) return true; synchronized (mTiles) { Tile next = mTiles[mUploadIndex++]; // Make sure tile has not already been recycled by the time // this is called (race condition in onGLIdle) if (next.bitmap != null) { boolean hasBeenLoad = next.isLoaded(); next.updateContent(canvas); // It will take some time for a texture to be drawn for the first // time. When scrolling, we need to draw several tiles on the screen // at the same time. It may cause a UI jank even these textures has // been uploaded. if (!hasBeenLoad) next.draw(canvas, 0, 0); } } return mUploadIndex == mTiles.length; } public boolean isReady() { return mUploadIndex == mTiles.length; } // Can be called in UI thread. public void recycle() { synchronized (mTiles) { for (Tile mTile : mTiles) { freeTile(mTile); } } } // Draws a mixed color of this texture and a specified color onto the // a rectangle. The used color is: from * (1 - ratio) + to * ratio. public void drawMixed(GLCanvas canvas, int color, float ratio, int x, int y, int width, int height) { RectF src = mSrcRect; float scaleX = (float) width / mWidth; float scaleY = (float) height / mHeight; synchronized (mTiles) { for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); mapRect(mDestRect, src, 0, 0, x, y, scaleX, scaleY); src.offset(BORDER_SIZE - t.offsetX, BORDER_SIZE - t.offsetY); canvas.drawMixed(t, color, ratio, mSrcRect, mDestRect); } } } public void drawMixed(GLCanvas canvas, int color, float ratio, RectF source, RectF target) { RectF src = mSrcRect; float x0 = source.left; float y0 = source.top; float x = target.left; float y = target.top; float scaleX = target.width() / source.width(); float scaleY = target.height() / source.height(); synchronized (mTiles) { for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); if (!src.intersect(source)) continue; mapRect(mDestRect, src, x0, y0, x, y, scaleX, scaleY); src.offset(BORDER_SIZE - t.offsetX, BORDER_SIZE - t.offsetY); canvas.drawMixed(t, color, ratio, mSrcRect, mDestRect); } } } // Draws the texture on to the specified rectangle. @Override public void draw(GLCanvas canvas, int x, int y, int width, int height) { RectF src = mSrcRect; float scaleX = (float) width / mWidth; float scaleY = (float) height / mHeight; synchronized (mTiles) { for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); mapRect(mDestRect, src, 0, 0, x, y, scaleX, scaleY); src.offset(BORDER_SIZE - t.offsetX, BORDER_SIZE - t.offsetY); canvas.drawTexture(t, mSrcRect, mDestRect); } } } // Draws a sub region of this texture on to the specified rectangle. @Override public void draw(GLCanvas canvas, RectF source, RectF target) { RectF src = mSrcRect; RectF dest = mDestRect; float x0 = source.left; float y0 = source.top; float x = target.left; float y = target.top; float scaleX = target.width() / source.width(); float scaleY = target.height() / source.height(); synchronized (mTiles) { for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); if (!src.intersect(source)) continue; mapRect(dest, src, x0, y0, x, y, scaleX, scaleY); src.offset(BORDER_SIZE - t.offsetX, BORDER_SIZE - t.offsetY); canvas.drawTexture(t, src, dest); } } } @Override public int getWidth() { return mWidth; } @Override public int getHeight() { return mHeight; } @Override public void draw(GLCanvas canvas, int x, int y) { draw(canvas, x, y, mWidth, mHeight); } @Override public boolean isOpaque() { return false; } public static class Uploader implements GLRoot.OnGLIdleListener { private final ArrayDeque mTextures = new ArrayDeque<>(INIT_CAPACITY); private final GLRoot mGlRoot; private boolean mIsQueued = false; public Uploader(GLRoot glRoot) { mGlRoot = glRoot; } public synchronized void clear() { mTextures.clear(); } public synchronized void addTexture(TiledTexture t) { if (t.isReady()) return; mTextures.addLast(t); if (mIsQueued) return; mIsQueued = true; mGlRoot.addOnGLIdleListener(this); } @Override public boolean onGLIdle(GLCanvas canvas, boolean renderRequested) { ArrayDeque deque = mTextures; synchronized (this) { long now = SystemClock.uptimeMillis(); long dueTime = now + UPLOAD_TILE_LIMIT; while (now < dueTime && !deque.isEmpty()) { TiledTexture t = deque.peekFirst(); if (t != null && t.uploadNextTile(canvas)) { deque.removeFirst(); mGlRoot.requestRender(); } now = SystemClock.uptimeMillis(); } mIsQueued = !mTextures.isEmpty(); // return true to keep this listener in the queue return mIsQueued; } } } private static class Tile extends UploadedTexture { public int offsetX; public int offsetY; public Bitmap bitmap; public Tile nextFreeTile; public int contentWidth; public int contentHeight; @Override public void setSize(int width, int height) { contentWidth = width; contentHeight = height; mWidth = width + 2 * BORDER_SIZE; mHeight = height + 2 * BORDER_SIZE; mTextureWidth = TILE_SIZE; mTextureHeight = TILE_SIZE; } @Override protected Bitmap onGetBitmap() { // make a local copy of the reference to the bitmap, // since it might be null'd in a different thread. b/8694871 Bitmap localBitmapRef = bitmap; bitmap = null; if (localBitmapRef != null) { int x = BORDER_SIZE - offsetX; int y = BORDER_SIZE - offsetY; int r = localBitmapRef.getWidth() + x; int b = localBitmapRef.getHeight() + y; sCanvas.drawBitmap(localBitmapRef, x, y, sBitmapPaint); // draw borders if need if (x > 0) sCanvas.drawLine(x - 1, 0, x - 1, TILE_SIZE, sPaint); if (y > 0) sCanvas.drawLine(0, y - 1, TILE_SIZE, y - 1, sPaint); if (r < CONTENT_SIZE) sCanvas.drawLine(r, 0, r, TILE_SIZE, sPaint); if (b < CONTENT_SIZE) sCanvas.drawLine(0, b, TILE_SIZE, b, sPaint); } return sUploadBitmap; } @Override protected void onFreeBitmap(Bitmap bitmap) { // do nothing } } } ================================================ FILE: app/src/main/java/com/hippo/glview/glrenderer/UploadedTexture.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.glrenderer; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.opengl.GLUtils; import androidx.annotation.NonNull; import java.util.HashMap; import javax.microedition.khronos.opengles.GL11; // UploadedTextures use a Bitmap for the content of the texture. // // Subclasses should implement onGetBitmap() to provide the Bitmap and // implement onFreeBitmap(mBitmap) which will be called when the Bitmap // is not needed anymore. // // isContentValid() is meaningful only when the isLoaded() returns true. // It means whether the content needs to be updated. // // The user of this class should call recycle() when the texture is not // needed anymore. // // By default an UploadedTexture is opaque (so it can be drawn faster without // blending). The user or subclass can override it using setOpaque(). public abstract class UploadedTexture extends BasicTexture { // To prevent keeping allocation the borders, we store those used borders here. // Since the length will be power of two, it won't use too much memory. private static final HashMap sBorderLines = new HashMap<>(); private static final BorderKey sBorderKey = new BorderKey(); @SuppressWarnings("unused") private static final String TAG = "Texture"; private static final int UPLOAD_LIMIT = 100; private static int sUploadedCount; protected Bitmap mBitmap; private boolean mContentValid = true; // indicate this textures is being uploaded in background private boolean mIsUploading = false; private boolean mOpaque = true; private boolean mThrottled = false; private int mBorder; protected UploadedTexture() { this(false); } protected UploadedTexture(boolean hasBorder) { super(null, 0, STATE_UNLOADED); if (hasBorder) { setBorder(true); mBorder = 1; } } private static Bitmap getBorderLine( boolean vertical, Config config, int length) { BorderKey key = sBorderKey; key.vertical = vertical; key.config = config; key.length = length; Bitmap bitmap = sBorderLines.get(key); if (bitmap == null) { bitmap = vertical ? Bitmap.createBitmap(1, length, config) : Bitmap.createBitmap(length, 1, config); sBorderLines.put(key.clone(), bitmap); } return bitmap; } public static void resetUploadLimit() { sUploadedCount = 0; } public static boolean uploadLimitReached() { return sUploadedCount > UPLOAD_LIMIT; } protected void setIsUploading(boolean uploading) { mIsUploading = uploading; } public boolean isUploading() { return mIsUploading; } protected void setThrottled(boolean throttled) { mThrottled = throttled; } private Bitmap getBitmap() { if (mBitmap == null) { mBitmap = onGetBitmap(); if (mWidth == UNSPECIFIED) { int w = mBitmap.getWidth() + mBorder * 2; int h = mBitmap.getHeight() + mBorder * 2; setSize(w, h); } } return mBitmap; } private void freeBitmap() { if (mBitmap == null) { throw new IllegalStateException("mBitmap == null"); } onFreeBitmap(mBitmap); mBitmap = null; } @Override public int getWidth() { if (mWidth == UNSPECIFIED) getBitmap(); return mWidth; } @Override public int getHeight() { if (mWidth == UNSPECIFIED) getBitmap(); return mHeight; } protected abstract Bitmap onGetBitmap(); protected abstract void onFreeBitmap(Bitmap bitmap); public void invalidateContent() { if (mBitmap != null) freeBitmap(); mContentValid = false; mWidth = UNSPECIFIED; mHeight = UNSPECIFIED; } /** * Whether the content on GPU is valid. */ public boolean isContentValid() { return isLoaded() && mContentValid; } /** * Updates the content on GPU's memory. * * @param canvas canvas */ public void updateContent(GLCanvas canvas) { if (!isLoaded()) { if (mThrottled && ++sUploadedCount > UPLOAD_LIMIT) { return; } uploadToCanvas(canvas); } else if (!mContentValid) { Bitmap bitmap = getBitmap(); int format = GLUtils.getInternalFormat(bitmap); int type = GLUtils.getType(bitmap); canvas.texSubImage2D(this, mBorder, mBorder, bitmap, format, type); freeBitmap(); mContentValid = true; } } private void uploadToCanvas(GLCanvas canvas) { Bitmap bitmap = getBitmap(); if (bitmap != null) { try { int bWidth = bitmap.getWidth(); int bHeight = bitmap.getHeight(); int texWidth = getTextureWidth(); int texHeight = getTextureHeight(); if (bWidth > texWidth || bHeight > texHeight) { throw new IllegalStateException("bWidth > texWidth || bHeight > texHeight"); } // Upload the bitmap to a new texture. mId = canvas.getGLId().generateTexture(); canvas.setTextureParameters(this); if (bWidth == texWidth && bHeight == texHeight) { canvas.initializeTexture(this, bitmap); } else { int format = GLUtils.getInternalFormat(bitmap); int type = GLUtils.getType(bitmap); Config config = bitmap.getConfig(); canvas.initializeTextureSize(this, format, type); canvas.texSubImage2D(this, mBorder, mBorder, bitmap, format, type); if (mBorder > 0) { // Left border Bitmap line = getBorderLine(true, config, texHeight); canvas.texSubImage2D(this, 0, 0, line, format, type); // Top border line = getBorderLine(false, config, texWidth); canvas.texSubImage2D(this, 0, 0, line, format, type); } // Right border if (mBorder + bWidth < texWidth) { Bitmap line = getBorderLine(true, config, texHeight); canvas.texSubImage2D(this, mBorder + bWidth, 0, line, format, type); } // Bottom border if (mBorder + bHeight < texHeight) { Bitmap line = getBorderLine(false, config, texWidth); canvas.texSubImage2D(this, 0, mBorder + bHeight, line, format, type); } } } finally { freeBitmap(); } // Update texture state. setAssociatedCanvas(canvas); mState = STATE_LOADED; mContentValid = true; } else { mState = STATE_ERROR; throw new RuntimeException("Texture load fail, no bitmap"); } } @Override protected boolean onBind(GLCanvas canvas) { updateContent(canvas); return isContentValid(); } @Override protected int getTarget() { return GL11.GL_TEXTURE_2D; } @Override public boolean isOpaque() { return mOpaque; } public void setOpaque(boolean isOpaque) { mOpaque = isOpaque; } @Override public void recycle() { super.recycle(); if (mBitmap != null) freeBitmap(); } private static class BorderKey implements Cloneable { public boolean vertical; public Config config; public int length; @Override public int hashCode() { int x = config.hashCode() ^ length; return vertical ? x : -x; } @Override public boolean equals(Object object) { if (!(object instanceof BorderKey o)) return false; return vertical == o.vertical && config == o.config && length == o.length; } @NonNull @Override public BorderKey clone() { try { return (BorderKey) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } } } ================================================ FILE: app/src/main/java/com/hippo/glview/image/GLImageMovableTextView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.image; import android.graphics.Rect; import android.text.TextUtils; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.view.GLView; import com.hippo.glview.view.Gravity; public class GLImageMovableTextView extends GLView { ImageMovableTextTexture mTextTexture; private String mText = ""; private int[] mIndexes = new int[0]; private int mGravity = Gravity.NO_GRAVITY; private void generateIndexes() { if (mTextTexture == null || TextUtils.isEmpty(mText)) { mIndexes = new int[0]; } else { mIndexes = mTextTexture.getTextIndexes(mText); } } public void setTextTexture(ImageMovableTextTexture textTexture) { if (mTextTexture == textTexture) { return; } mTextTexture = textTexture; generateIndexes(); requestLayout(); } public void setText(String text) { if (text == null) { text = ""; } if (text.equals(mText)) { return; } mText = text; generateIndexes(); requestLayout(); } public void setGravity(int gravity) { if (mGravity != gravity) { mGravity = gravity; invalidate(); } } @Override protected int getSuggestedMinimumWidth() { if (mTextTexture == null) { return super.getSuggestedMinimumWidth(); } else { return Math.max((int) mTextTexture.getTextWidth(mIndexes) + mPaddings.left + mPaddings.right, super.getSuggestedMinimumWidth()); } } @Override protected int getSuggestedMinimumHeight() { if (mTextTexture == null) { return super.getSuggestedMinimumHeight(); } else { return Math.max((int) mTextTexture.getTextHeight() + mPaddings.top + mPaddings.bottom, super.getSuggestedMinimumHeight()); } } @Override public void onRender(GLCanvas canvas) { if (mTextTexture == null || mIndexes == null) { return; } Rect paddings = getPaddings(); int x = getDefaultBegin(getWidth(), (int) mTextTexture.getTextWidth(mIndexes), paddings.left, paddings.right, Gravity.getPosition(mGravity, Gravity.HORIZONTAL)); int y = getDefaultBegin(getHeight(), (int) mTextTexture.getTextHeight(), paddings.top, paddings.bottom, Gravity.getPosition(mGravity, Gravity.VERTICAL)); mTextTexture.drawText(canvas, mIndexes, x, y); } } ================================================ FILE: app/src/main/java/com/hippo/glview/image/ImageMovableTextTexture.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.image; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Typeface; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.image.Image; import java.util.Arrays; public class ImageMovableTextTexture extends ImageSpriteTexture { private final char[] mCharacters; private final float[] mWidths; private final float mHeight; private final float mMaxWidth; public ImageMovableTextTexture(@NonNull ImageWrapper image, int count, int[] rects, char[] characters, float[] widths, float height, float maxWidth) { super(image, count, rects); mCharacters = characters; mWidths = widths; mHeight = height; mMaxWidth = maxWidth; } /** * Create a TextTexture to draw text * * @param typeface the typeface * @param size text size * @param characters all Characters * @return the TextTexture */ @Nullable public static ImageMovableTextTexture create(Typeface typeface, int size, int color, char[] characters) { Paint paint = new Paint(); paint.setAntiAlias(true); paint.setTextSize(size); paint.setColor(color); paint.setTypeface(typeface); Paint.FontMetricsInt fmi = paint.getFontMetricsInt(); int fixed = fmi.bottom; int height = fmi.bottom - fmi.top; int length = characters.length; float[] widths = new float[length]; paint.getTextWidths(characters, 0, length, widths); // Calculate bitmap size float maxWidth = 0.0f; for (float f : widths) { maxWidth = Math.max(maxWidth, f); } int hCount = (int) Math.ceil(Math.sqrt(height / maxWidth * length)); int vCount = (int) Math.ceil(Math.sqrt(maxWidth / height * length)); if (hCount * (vCount - 1) > length) { vCount--; } if ((hCount - 1) * vCount > length) { hCount--; } Bitmap bitmap = Bitmap.createBitmap((int) Math.ceil(hCount * maxWidth), (int) (double) (vCount * height), Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); canvas.translate(0, height - fixed); // Draw int[] rects = new int[length * 4]; int x = 0; int y = 0; for (int i = 0; i < length; i++) { int offset = i * 4; rects[offset] = x; rects[offset + 1] = y; rects[offset + 2] = (int) widths[i]; rects[offset + 3] = height; canvas.drawText(characters, i, 1, x, y, paint); if (i % hCount == hCount - 1) { // The end of row x = 0; y += height; } else { x += (int) maxWidth; } } Image image = Image.create(bitmap); ImageWrapper imageWrapper = new ImageWrapper(image); if (imageWrapper.obtain()) { return new ImageMovableTextTexture(imageWrapper, length, rects, characters, widths, height, maxWidth); } else { return null; } } public int[] getTextIndexes(String text) { int length = text.length(); int[] indexes = new int[length]; for (int i = 0; i < length; i++) { char ch = text.charAt(i); indexes[i] = Arrays.binarySearch(mCharacters, ch); } return indexes; } public float getTextWidth(String text) { float width = 0.0f; for (int i = 0, n = text.length(); i < n; i++) { char ch = text.charAt(i); int index = Arrays.binarySearch(mCharacters, ch); if (index >= 0) { width += mWidths[index]; } else { width += mMaxWidth; } } return width; } public float getTextWidth(int[] indexes) { float width = 0.0f; for (int index : indexes) { if (index >= 0) { width += mWidths[index]; } else { width += mMaxWidth; } } return width; } public float getMaxWidth() { return mMaxWidth; } public float getTextHeight() { return mHeight; } public void drawText(GLCanvas canvas, String text, int x, int y) { for (int i = 0, n = text.length(); i < n; i++) { char ch = text.charAt(i); int index = Arrays.binarySearch(mCharacters, ch); if (index >= 0) { drawSprite(canvas, index, x, y); x += (int) mWidths[index]; } else { x += (int) mMaxWidth; } } } public void drawText(GLCanvas canvas, int[] indexes, int x, int y) { for (int index : indexes) { if (index >= 0) { drawSprite(canvas, index, x, y); x += (int) mWidths[index]; } else { x += (int) mMaxWidth; } } } } ================================================ FILE: app/src/main/java/com/hippo/glview/image/ImageSpriteTexture.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.image; import android.graphics.RectF; import androidx.annotation.NonNull; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.yorozuya.AssertUtils; public class ImageSpriteTexture extends ImageTexture { private final int mCount; private final int[] mRects; private final RectF mTempSource = new RectF(); private final RectF mTempTarget = new RectF(); public ImageSpriteTexture(@NonNull ImageWrapper image, int count, int[] rects) { super(image); AssertUtils.assertEquals("rects.length must be count * 4", count * 4, rects.length); mCount = count; mRects = rects; } public int getCount() { return mCount; } public void drawSprite(GLCanvas canvas, int index, int x, int y) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; int sourceWidth = rects[offset + 2]; int sourceHeight = rects[offset + 3]; mTempSource.set(sourceX, sourceY, sourceX + sourceWidth, sourceY + sourceHeight); mTempTarget.set(x, y, x + sourceWidth, y + sourceHeight); draw(canvas, mTempSource, mTempTarget); } public void drawSprite(GLCanvas canvas, int index, int x, int y, int width, int height) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; mTempSource.set(sourceX, sourceY, sourceX + rects[offset + 2], sourceY + rects[offset + 3]); mTempTarget.set(x, y, x + width, y + height); draw(canvas, mTempSource, mTempTarget); } public void drawSpriteMixed(GLCanvas canvas, int index, int color, float ratio, int x, int y) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; int sourceWidth = rects[offset + 2]; int sourceHeight = rects[offset + 3]; mTempSource.set(sourceX, sourceY, sourceX + sourceWidth, sourceY + sourceHeight); mTempTarget.set(x, y, x + sourceWidth, y + sourceHeight); drawMixed(canvas, color, ratio, mTempSource, mTempTarget); } public void drawSpriteMixed(GLCanvas canvas, int index, int color, float ratio, int x, int y, int width, int height) { int[] rects = mRects; int offset = index * 4; int sourceX = rects[offset]; int sourceY = rects[offset + 1]; mTempSource.set(sourceX, sourceY, sourceX + rects[offset + 2], sourceY + rects[offset + 3]); mTempTarget.set(x, y, x + width, y + height); drawMixed(canvas, color, ratio, mTempSource, mTempTarget); } } ================================================ FILE: app/src/main/java/com/hippo/glview/image/ImageTexture.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.image; import android.graphics.RectF; import android.graphics.drawable.Animatable; import android.os.Process; import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.glrenderer.NativeTexture; import com.hippo.glview.glrenderer.Texture; import com.hippo.glview.view.GLRoot; import com.hippo.yorozuya.thread.InfiniteThreadExecutor; import com.hippo.yorozuya.thread.PriorityThreadFactory; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.ref.WeakReference; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.LinkedList; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; public class ImageTexture implements Texture, Animatable { private static final int TILE_SMALL = 0; private static final int TILE_LARGE = 1; private static final int SMALL_CONTENT_SIZE = 254; private static final int SMALL_BORDER_SIZE = 1; private static final int SMALL_TILE_SIZE = SMALL_CONTENT_SIZE + 2 * SMALL_BORDER_SIZE; private static final int LARGE_CONTENT_SIZE = SMALL_CONTENT_SIZE * 2; private static final int LARGE_BORDER_SIZE = SMALL_BORDER_SIZE * 2; private static final int LARGE_TILE_SIZE = LARGE_CONTENT_SIZE + 2 * LARGE_BORDER_SIZE; private static final int INIT_CAPACITY = 8; // We are targeting at 60fps, so we have 16ms for each frame. // In this 16ms, we use about 4~8 ms to upload tiles. private static final long UPLOAD_TILE_LIMIT = 4; // ms private static final Executor sThreadExecutor; private static final Object sFreeTileLock = new Object(); private static Tile sSmallFreeTileHead = null; private static Tile sLargeFreeTileHead = null; static { sThreadExecutor = new InfiniteThreadExecutor(10 * 1000, new LinkedList<>(), new PriorityThreadFactory("ImageTexture$AnimateTask", Process.THREAD_PRIORITY_BACKGROUND)); } private final ImageWrapper mImage; private final Tile[] mTiles; // Can be modified in different threads. private final int mWidth; // Should be protected by "synchronized." private final int mHeight; private final boolean mOpaque; private final RectF mSrcRect = new RectF(); private final RectF mDestRect = new RectF(); private final AtomicBoolean mRunning = new AtomicBoolean(); private final AtomicBoolean mRequestAnimation = new AtomicBoolean(); private final AtomicBoolean mFrameDirty = new AtomicBoolean(); private final AtomicBoolean mNeedRelease = new AtomicBoolean(); private final AtomicBoolean mReleased = new AtomicBoolean(); private int mUploadIndex = 0; private boolean mImageBusy = false; private Runnable mAnimateRunnable = null; private WeakReference mCallback; /** * Call {@link ImageWrapper#obtain()} first */ public ImageTexture(@NonNull ImageWrapper image) { mImage = image; int width = mWidth = image.getWidth(); int height = mHeight = image.getHeight(); boolean opaque = mOpaque = image.isOpaque(); ArrayList list = new ArrayList<>(); for (int x = 0; x < width; x += LARGE_CONTENT_SIZE) { for (int y = 0; y < height; y += LARGE_CONTENT_SIZE) { int w = Math.min(LARGE_CONTENT_SIZE, width - x); int h = Math.min(LARGE_CONTENT_SIZE, height - y); if (w <= SMALL_CONTENT_SIZE) { Tile tile = obtainSmallTile(); tile.offsetX = x; tile.offsetY = y; tile.image = image; tile.setSize(TILE_SMALL, w, Math.min(SMALL_CONTENT_SIZE, h)); tile.setOpaque(opaque); list.add(tile); int nextHeight = h - SMALL_CONTENT_SIZE; if (nextHeight > 0) { Tile nextTile = obtainSmallTile(); nextTile.offsetX = x; nextTile.offsetY = y + SMALL_CONTENT_SIZE; nextTile.image = image; nextTile.setSize(TILE_SMALL, w, nextHeight); nextTile.setOpaque(opaque); list.add(nextTile); } } else if (h <= SMALL_CONTENT_SIZE) { Tile tile = obtainSmallTile(); tile.offsetX = x; tile.offsetY = y; tile.image = image; tile.setSize(TILE_SMALL, SMALL_CONTENT_SIZE, h); tile.setOpaque(opaque); list.add(tile); int nextWidth = w - SMALL_CONTENT_SIZE; Tile nextTile = obtainSmallTile(); nextTile.offsetX = x + SMALL_CONTENT_SIZE; nextTile.offsetY = y; nextTile.image = image; nextTile.setSize(TILE_SMALL, nextWidth, h); nextTile.setOpaque(opaque); list.add(nextTile); } else { Tile tile = obtainLargeTile(); tile.offsetX = x; tile.offsetY = y; tile.image = image; tile.setSize(TILE_LARGE, w, h); tile.setOpaque(opaque); list.add(tile); } } } mTiles = list.toArray(new Tile[0]); } private static Tile obtainSmallTile() { synchronized (sFreeTileLock) { Tile result = sSmallFreeTileHead; if (result == null) { return new Tile(); } else { sSmallFreeTileHead = result.nextFreeTile; result.nextFreeTile = null; } return result; } } private static Tile obtainLargeTile() { synchronized (sFreeTileLock) { Tile result = sLargeFreeTileHead; if (result == null) { return new Tile(); } else { sLargeFreeTileHead = result.nextFreeTile; result.nextFreeTile = null; } return result; } } // We want to draw the "source" on the "target". // This method is to find the "output" rectangle which is // the corresponding area of the "src". // (x,y) target // (x0,y0) source +---------------+ // +----------+ | | // | src | | output | // | +--+ | linear map | +----+ | // | +--+ | ----------> | | | | // | | by (scaleX, scaleY) | +----+ | // +----------+ | | // Texture +---------------+ // Canvas private static void mapRect(RectF output, RectF src, float x0, float y0, float x, float y, float scaleX, float scaleY) { output.set(x + (src.left - x0) * scaleX, y + (src.top - y0) * scaleY, x + (src.right - x0) * scaleX, y + (src.bottom - y0) * scaleY); } public Callback getCallback() { if (mCallback != null) { return mCallback.get(); } return null; } public final void setCallback(Callback cb) { mCallback = new WeakReference<>(cb); } public void invalidateSelf() { final Callback callback = getCallback(); if (callback != null) { callback.invalidateImageTexture(this); } } @Override public void start() { synchronized (mImage) { if (!mImageBusy) { mImageBusy = true; } else { mRequestAnimation.lazySet(true); return; } } boolean end = mReleased.get() || mImage.isImageRecycled() || mNeedRelease.get() || (!mImage.getAnimated()) || mRunning.get(); synchronized (mImage) { mImageBusy = false; } if (end) { return; } mRunning.lazySet(true); synchronized (mImage) { if (mAnimateRunnable == null) { Runnable runnable = new AnimateRunnable(); mAnimateRunnable = runnable; sThreadExecutor.execute(runnable); } } } @Override public void stop() { mRunning.lazySet(false); mRequestAnimation.lazySet(false); } @Override public boolean isRunning() { return mRunning.get(); } private boolean uploadNextTile(GLCanvas canvas) { if (mUploadIndex == mTiles.length) return true; synchronized (mTiles) { Tile next = mTiles[mUploadIndex++]; // Make sure tile has not already been recycled by the time // this is called (race condition in onGLIdle) if (next.image != null) { boolean hasBeenLoad = next.isLoaded(); next.updateContent(canvas); // It will take some time for a texture to be drawn for the first // time. When scrolling, we need to draw several tiles on the screen // at the same time. It may cause a UI jank even these textures has // been uploaded. if (!hasBeenLoad) next.draw(canvas, 0, 0); } } return mUploadIndex == mTiles.length; } @Override public int getWidth() { return mWidth; } @Override public int getHeight() { return mHeight; } private void syncFrame() { if (mFrameDirty.getAndSet(false)) { // invalid tiles for (Tile tile : mTiles) { tile.invalidateContent(); } mImage.setFrameUpdateAllowed(true); } } @Override public void draw(GLCanvas canvas, int x, int y) { draw(canvas, x, y, mWidth, mHeight); } // Draws the texture on to the specified rectangle. @Override public void draw(GLCanvas canvas, int x, int y, int w, int h) { RectF src = mSrcRect; RectF dest = mDestRect; float scaleX = (float) w / mWidth; float scaleY = (float) h / mHeight; syncFrame(); for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); mapRect(dest, src, 0, 0, x, y, scaleX, scaleY); src.offset(t.borderSize - t.offsetX, t.borderSize - t.offsetY); canvas.drawTexture(t, src, dest); } } // Draws a sub region of this texture on to the specified rectangle. @Override public void draw(GLCanvas canvas, RectF source, RectF target) { RectF src = mSrcRect; RectF dest = mDestRect; float x0 = source.left; float y0 = source.top; float x = target.left; float y = target.top; float scaleX = target.width() / source.width(); float scaleY = target.height() / source.height(); syncFrame(); for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); if (!src.intersect(source)) { continue; } mapRect(dest, src, x0, y0, x, y, scaleX, scaleY); src.offset(t.borderSize - t.offsetX, t.borderSize - t.offsetY); canvas.drawTexture(t, src, dest); } } // Draws a mixed color of this texture and a specified color onto the // a rectangle. The used color is: from * (1 - ratio) + to * ratio. public void drawMixed(GLCanvas canvas, int color, float ratio, int x, int y, int width, int height) { RectF src = mSrcRect; RectF dest = mDestRect; float scaleX = (float) width / mWidth; float scaleY = (float) height / mHeight; syncFrame(); for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); mapRect(dest, src, 0, 0, x, y, scaleX, scaleY); src.offset(t.borderSize - t.offsetX, t.borderSize - t.offsetY); canvas.drawMixed(t, color, ratio, src, dest); } } public void drawMixed(GLCanvas canvas, int color, float ratio, RectF source, RectF target) { RectF src = mSrcRect; RectF dest = mDestRect; float x0 = source.left; float y0 = source.top; float x = target.left; float y = target.top; float scaleX = target.width() / source.width(); float scaleY = target.height() / source.height(); syncFrame(); for (Tile t : mTiles) { src.set(0, 0, t.contentWidth, t.contentHeight); src.offset(t.offsetX, t.offsetY); if (!src.intersect(source)) { continue; } mapRect(dest, src, x0, y0, x, y, scaleX, scaleY); src.offset(t.borderSize - t.offsetX, t.borderSize - t.offsetY); canvas.drawMixed(t, color, ratio, src, dest); } } @Override public boolean isOpaque() { return mOpaque; } public boolean isReady() { return mUploadIndex == mTiles.length; } public void recycle() { mRunning.lazySet(false); for (Tile mTile : mTiles) { mTile.free(); } boolean releaseNow; synchronized (mImage) { if (!mImageBusy) { releaseNow = true; mImageBusy = true; } else { releaseNow = false; mNeedRelease.lazySet(true); } } if (releaseNow) { if (!mReleased.get()) { mImage.release(); mReleased.lazySet(true); } synchronized (mImage) { mImageBusy = false; } } } @IntDef({TILE_SMALL, TILE_LARGE}) @Retention(RetentionPolicy.SOURCE) private @interface TileType { } public interface Callback { void invalidateImageTexture(ImageTexture who); } public static class Uploader implements GLRoot.OnGLIdleListener { private final ArrayDeque mTextures = new ArrayDeque<>(INIT_CAPACITY); private final GLRoot mGlRoot; private boolean mIsQueued = false; public Uploader(GLRoot glRoot) { mGlRoot = glRoot; } public synchronized void clear() { mTextures.clear(); } public synchronized void addTexture(ImageTexture t) { if (t.isReady()) return; mTextures.addLast(t); if (mIsQueued) return; mIsQueued = true; mGlRoot.addOnGLIdleListener(this); } @Override public boolean onGLIdle(GLCanvas canvas, boolean renderRequested) { ArrayDeque deque = mTextures; synchronized (this) { long now = SystemClock.uptimeMillis(); long dueTime = now + UPLOAD_TILE_LIMIT; while (now < dueTime && !deque.isEmpty()) { ImageTexture t = deque.peekFirst(); if (t != null && t.uploadNextTile(canvas)) { deque.removeFirst(); mGlRoot.requestRender(); } now = SystemClock.uptimeMillis(); } mIsQueued = !mTextures.isEmpty(); // return true to keep this listener in the queue return mIsQueued; } } } private static class Tile extends NativeTexture { public int offsetX; public int offsetY; public ImageWrapper image; public Tile nextFreeTile; public int contentWidth; public int contentHeight; public int borderSize; @TileType private int mTileType; private static void freeSmallTile(Tile tile) { tile.invalidate(); synchronized (sFreeTileLock) { tile.nextFreeTile = sSmallFreeTileHead; sSmallFreeTileHead = tile; } } private static void freeLargeTile(Tile tile) { tile.invalidate(); synchronized (sFreeTileLock) { tile.nextFreeTile = sLargeFreeTileHead; sLargeFreeTileHead = tile; } } public void setSize(@TileType int tileType, int width, int height) { mTileType = tileType; int tileSize; if (tileType == TILE_SMALL) { borderSize = SMALL_BORDER_SIZE; tileSize = SMALL_TILE_SIZE; } else if (tileType == TILE_LARGE) { borderSize = LARGE_BORDER_SIZE; tileSize = LARGE_TILE_SIZE; } else { throw new IllegalStateException("Not support tile type: " + tileType); } contentWidth = width; contentHeight = height; mWidth = width + 2 * borderSize; mHeight = height + 2 * borderSize; mTextureWidth = tileSize; mTextureHeight = tileSize; } @Override protected void texImage(boolean init) { if (image != null && !image.isRecycled()) { int w, h; if (init) { w = mTextureWidth; h = mTextureHeight; } else { w = mWidth; h = mHeight; } image.texImage(init, offsetX - borderSize, offsetY - borderSize, w, h); } } private void invalidate() { invalidateContent(); image = null; } public void free() { switch (mTileType) { case TILE_SMALL: freeSmallTile(this); break; case TILE_LARGE: freeLargeTile(this); break; default: throw new IllegalStateException("Not support tile type: " + mTileType); } } } private class AnimateRunnable implements Runnable { @Override public void run() { if (!prepareAnimation()) return; if (mRequestAnimation.get()) { mRunning.lazySet(true); } runAnimationLoop(); if (mNeedRelease.get()) { performReleaseIfNeeded(); } } private boolean prepareAnimation() { synchronized (mImage) { if (mReleased.get() || mImage.isImageRecycled() || mImageBusy || mNeedRelease.get()) { mAnimateRunnable = null; return false; } mImageBusy = true; } synchronized (mImage) { mImageBusy = false; if (mNeedRelease.get() || !mImage.getAnimated()) { mAnimateRunnable = null; return false; } } return true; } private void runAnimationLoop() { long nextFrameTime = System.nanoTime(); long delay = mImage.getDelay(); while (true) { if (!shouldContinueAnimation()) { mAnimateRunnable = null; return; } setImageBusy(true); mImage.start(); mFrameDirty.lazySet(true); invalidateSelf(); setImageBusy(false); nextFrameTime += delay * 1000000; long sleepTimeMs = (nextFrameTime - System.nanoTime()) / 1000000; if (sleepTimeMs > 0) { try { //noinspection BusyWait Thread.sleep(sleepTimeMs); } catch (InterruptedException ignored) { } } else { nextFrameTime = System.nanoTime(); } } } private boolean shouldContinueAnimation() { synchronized (mImage) { return !(mReleased.get() || mImage.isImageRecycled() || mImageBusy || mNeedRelease.get()) && mRunning.get(); } } private void setImageBusy(boolean busy) { synchronized (mImage) { mImageBusy = busy; } } private void performReleaseIfNeeded() { while (mNeedRelease.get()) { synchronized (mImage) { if (mReleased.get() || mImage.isImageRecycled() || mImageBusy) { break; } mImageBusy = true; } if (!mReleased.get()) { mImage.release(); mReleased.lazySet(true); } setImageBusy(false); } } } } ================================================ FILE: app/src/main/java/com/hippo/glview/image/ImageWrapper.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.image; import android.graphics.Rect; import android.util.Log; import androidx.annotation.NonNull; import com.hippo.image.Image; /** * A wrapper for {@link Image}. It is useful for multi-usage. * It handles image recycle automatically. */ public class ImageWrapper { private static final String LOG_TAG = "ImageWrapper"; private final Image mImage; private final Rect mCut; private int mReferences; /** * Create ImageWrapper * * @param image the image should not be obtained or recycled. */ public ImageWrapper(@NonNull Image image) { mImage = image; mCut = new Rect(0, 0, image.getWidth(), image.getHeight()); } /** * Cuts this image to a specified region. * If the region is out of the image size, clamp the region. */ public void setCutRect(int left, int top, int right, int bottom) { mCut.left = Math.max(0, left); mCut.top = Math.max(0, top); mCut.right = Math.min(mImage.getWidth(), right); mCut.bottom = Math.min(mImage.getHeight(), bottom); if (mCut.isEmpty()) { // Empty mCut has unspecified behavior Log.e(LOG_TAG, "Cut rect is empty"); mCut.set(0, 0, mImage.getWidth(), mImage.getHeight()); } } /** * Cuts this image to a specified region. * The region is described in percent, {@code [0.0f, 1.0f]}. * If the region is out of the image size, clamp the region. */ public void setCutPercent(float left, float top, float right, float bottom) { setCutRect((int) (getWidth() * left), (int) (getHeight() * top), (int) (getWidth() * right), (int) (getHeight() * bottom)); } /** * Obtain the image * * @return false for the image is recycled and obtain failed */ public synchronized boolean obtain() { if (mImage.isRecycled()) { return false; } else { ++mReferences; return true; } } /** * Release the image */ public synchronized void release() { --mReferences; if (mReferences <= 0 && !mImage.isRecycled()) { mImage.recycle(); } } public boolean isImageRecycled() { return mImage.isRecycled(); } /** * @see Image#getAnimated() */ public Boolean getAnimated() { return mImage.getAnimated(); } /** * @see Image#getAnimated() */ public int getWidth() { return mCut.width(); } /** * @see Image#getHeight() */ public int getHeight() { return mCut.height(); } /** * @see Image#setFrameUpdateAllowed(boolean) */ public void setFrameUpdateAllowed(boolean allowed) { mImage.setFrameUpdateAllowed(allowed); } /** * @see Image#texImage(boolean, int, int, int, int) */ public void texImage(boolean init, int offsetX, int offsetY, int width, int height) { mImage.texImage(init, offsetX + mCut.left, offsetY + mCut.top, width, height); } /** * @see Image#start() */ public void start() { mImage.start(); } /** * @see Image#getDelay() */ public int getDelay() { return mImage.getDelay(); } /** * @see Image#isOpaque() */ public boolean isOpaque() { return mImage.isOpaque(); } /** * @see Image#isRecycled() */ public boolean isRecycled() { return mImage.isRecycled(); } } ================================================ FILE: app/src/main/java/com/hippo/glview/util/GalleryUtils.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.util; import android.graphics.Color; import com.hippo.yorozuya.AssertError; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; public class GalleryUtils { private static final List sRenderThreads = new CopyOnWriteArrayList<>(); public static float[] intColorToFloatARGBArray(int from) { return new float[]{ Color.alpha(from) / 255f, Color.red(from) / 255f, Color.green(from) / 255f, Color.blue(from) / 255f }; } // Below are used the detect using database in the render thread. It only // works most of the time, but that's ok because it's for debugging only. public static int floatARGBArrayTointColor(float[] from) { return Color.argb((int) (from[0] * 255), (int) (from[1] * 255), (int) (from[2] * 255), (int) (from[3] * 255)); } public static void setRenderThread() { sRenderThreads.add(Thread.currentThread().hashCode()); } public static boolean isRenderThread() { return sRenderThreads.contains(Thread.currentThread().hashCode()); } public static void assertInRenderThread() { if (!isRenderThread()) { throw new AssertError("Should not do this in non-render thread"); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/view/AnimationTime.java ================================================ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.view; import android.os.SystemClock; // // The animation time should ideally be the vsync time the frame will be // displayed, but that is an unknown time in the future. So we use the system // time just after eglSwapBuffers (when GLSurfaceView.onDrawFrame is called) // as a approximation. // public class AnimationTime { private static volatile long sTime; // Sets current time as the animation time. public static void update() { sTime = SystemClock.uptimeMillis(); } // Returns the animation time. public static long get() { return sTime; } public static long startTime() { sTime = SystemClock.uptimeMillis(); return sTime; } } ================================================ FILE: app/src/main/java/com/hippo/glview/view/GLRoot.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.view; import android.content.Context; import android.graphics.Matrix; import com.hippo.glview.anim.CanvasAnimation; import com.hippo.glview.glrenderer.GLCanvas; public interface GLRoot { void addOnGLIdleListener(OnGLIdleListener listener); void registerLaunchedAnimation(CanvasAnimation animation); void requestRenderForced(); void requestRender(); void requestLayoutContentPane(); void lockRenderThread(); void unlockRenderThread(); void setContentPane(GLView content); void setOrientationSource(OrientationSource source); int getDisplayRotation(); int getCompensation(); Matrix getCompensationMatrix(); void freeze(); void unfreeze(); void setLightsOutMode(boolean enabled); Context getContext(); int getWidth(); int getHeight(); // Listener will be called when GL is idle AND before each frame. // Mainly used for uploading textures. interface OnGLIdleListener { boolean onGLIdle(GLCanvas canvas, boolean renderRequested); } } ================================================ FILE: app/src/main/java/com/hippo/glview/view/GLRootView.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.view; import android.content.Context; import android.graphics.Matrix; import android.graphics.PixelFormat; import android.opengl.GLSurfaceView; import android.opengl.GLUtils; import android.os.Parcelable; import android.os.Process; import android.os.SystemClock; import android.util.AttributeSet; import android.util.Log; import android.util.SparseArray; import android.view.MotionEvent; import android.view.SurfaceHolder; import androidx.annotation.NonNull; import com.hippo.glview.anim.CanvasAnimation; import com.hippo.glview.glrenderer.BasicTexture; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.glrenderer.GLES11Canvas; import com.hippo.glview.glrenderer.GLES20Canvas; import com.hippo.glview.glrenderer.UploadedTexture; import com.hippo.glview.util.GalleryUtils; import com.hippo.yorozuya.AssertUtils; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; import javax.microedition.khronos.egl.EGL10; import javax.microedition.khronos.egl.EGLConfig; import javax.microedition.khronos.egl.EGLContext; import javax.microedition.khronos.egl.EGLDisplay; import javax.microedition.khronos.opengles.GL10; import javax.microedition.khronos.opengles.GL11; // TODO Call attachToRoot and detachFromRoot in render thread // The root component of all GLViews. The rendering is done in GL // thread while the event handling is done in the main thread. To synchronize // the two threads, the entry points of this package need to synchronize on the // GLRootView instance unless it can be proved that the rendering // thread won't access the same thing as the method. The entry points include: // (1) The public methods of HeadUpDisplay // (2) The public methods of CameraHeadUpDisplay // (3) The overridden methods in GLRootView. public class GLRootView extends GLSurfaceView implements GLRoot { private static final String TAG = "GLRootView"; private static final boolean DEBUG_FPS = false; private static final boolean DEBUG_INVALIDATE = false; private static final boolean DEBUG_DRAWING_STAT = false; private static final int FLAG_INITIALIZED = 1; private static final int FLAG_NEED_LAYOUT = 2; // mCompensationMatrix maps the coordinates of touch events. It is kept sync // with mCompensation. private final Matrix mCompensationMatrix = new Matrix(); private final ArrayList mAnimations = new ArrayList<>(); private final ArrayDeque mIdleListeners = new ArrayDeque<>(); private final IdleRunner mIdleRunner = new IdleRunner(); private final ReentrantLock mRenderLock = new ReentrantLock(); private final Condition mFreezeCondition = mRenderLock.newCondition(); private final Runnable mRequestRenderOnAnimationFrame = this::superRequestRender; private int mFrameCount = 0; private long mFrameCountingStart = 0; private int mInvalidateColor = 0; private GL11 mGL; private GLCanvas mCanvas; private GLView mContentView; private OrientationSource mOrientationSource; // mCompensation is the difference between the UI orientation on GLCanvas // and the framework orientation. See OrientationManager for details. private int mCompensation; private int mDisplayRotation; private int mFlags = FLAG_NEED_LAYOUT; private volatile boolean mRenderRequested = false; private boolean mFreeze; private boolean mInDownState = false; private int mEGLContextClientVersion; public GLRootView(Context context) { this(context, null); } @SuppressWarnings("deprecation") public GLRootView(Context context, AttributeSet attrs) { super(context, attrs); mFlags |= FLAG_INITIALIZED; setBackgroundDrawable(null); setEGLConfigChooser(new ConfigChooser()); setEGLContextFactory(new ContextFactory()); setRenderer(new GLRootRenderer()); getHolder().setFormat(PixelFormat.RGB_888); // Uncomment this to enable gl error check. // setDebugFlags(DEBUG_CHECK_GL_ERROR); } @Override public void registerLaunchedAnimation(CanvasAnimation animation) { // Register the newly launched animation so that we can set the start // time more precisely. (Usually, it takes much longer for first // rendering, so we set the animation start time as the time we // complete rendering) mAnimations.add(animation); } @Override public void addOnGLIdleListener(OnGLIdleListener listener) { synchronized (mIdleListeners) { mIdleListeners.addLast(listener); if (mCanvas != null) { // Wait for onSurfaceCreated mIdleRunner.enable(); } } } @Override public void setContentPane(GLView content) { if (mContentView == content) return; if (mContentView != null) { if (mInDownState) { long now = SystemClock.uptimeMillis(); MotionEvent cancelEvent = MotionEvent.obtain( now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0); mContentView.dispatchTouchEvent(cancelEvent); cancelEvent.recycle(); mInDownState = false; } mContentView.detachFromRoot(); BasicTexture.yieldAllTextures(); } mContentView = content; if (content != null) { content.attachToRoot(this); requestLayoutContentPane(); } } @Override public void requestRenderForced() { superRequestRender(); } @Override public void requestRender() { if (DEBUG_INVALIDATE) { StackTraceElement e = Thread.currentThread().getStackTrace()[4]; String caller = e.getFileName() + ":" + e.getLineNumber() + " "; Log.d(TAG, "invalidate: " + caller); } if (mRenderRequested) return; mRenderRequested = true; postOnAnimation(mRequestRenderOnAnimationFrame); } private void superRequestRender() { super.requestRender(); } @Override public void requestLayoutContentPane() { mRenderLock.lock(); try { if (mContentView == null || (mFlags & FLAG_NEED_LAYOUT) != 0) return; // "View" system will invoke onLayout() for initialization(bug ?), we // have to ignore it since the GLThread is not ready yet. if ((mFlags & FLAG_INITIALIZED) == 0) return; mFlags |= FLAG_NEED_LAYOUT; requestRender(); } finally { mRenderLock.unlock(); } } private void layoutContentPane() { mFlags &= ~FLAG_NEED_LAYOUT; int w = getWidth(); int h = getHeight(); int displayRotation; int compensation; // Get the new orientation values if (mOrientationSource != null) { displayRotation = mOrientationSource.getDisplayRotation(); compensation = mOrientationSource.getCompensation(); } else { displayRotation = 0; compensation = 0; } if (mCompensation != compensation) { mCompensation = compensation; if (mCompensation % 180 != 0) { mCompensationMatrix.setRotate(mCompensation); // move center to origin before rotation mCompensationMatrix.preTranslate((float) -w / 2, (float) -h / 2); // align with the new origin after rotation mCompensationMatrix.postTranslate((float) h / 2, (float) w / 2); } else { mCompensationMatrix.setRotate(mCompensation, (float) w / 2, (float) h / 2); } } mDisplayRotation = displayRotation; // Do the actual layout. if (mCompensation % 180 != 0) { int tmp = w; w = h; h = tmp; } Log.i(TAG, "layout content pane " + w + "x" + h + " (compensation " + mCompensation + ")"); if (mContentView != null && w != 0 && h != 0) { mContentView.measure(GLView.MeasureSpec.makeMeasureSpec(w, GLView.MeasureSpec.EXACTLY), GLView.MeasureSpec.makeMeasureSpec(h, GLView.MeasureSpec.EXACTLY)); mContentView.layout(0, 0, w, h); } // Uncomment this to dump the view hierarchy. //mContentView.dumpTree(""); } @Override protected void onLayout( boolean changed, int left, int top, int right, int bottom) { if (changed) requestLayoutContentPane(); } private void outputFps() { long now = System.nanoTime(); if (mFrameCountingStart == 0) { mFrameCountingStart = now; } else if ((now - mFrameCountingStart) > 1000000000) { Log.d(TAG, "fps: " + (double) mFrameCount * 1000000000 / (now - mFrameCountingStart)); mFrameCountingStart = now; mFrameCount = 0; } ++mFrameCount; } private void onDrawFrameLocked() { if (DEBUG_FPS) outputFps(); // release the unbound textures and deleted buffers. mCanvas.deleteRecycledResources(); // reset texture upload limit UploadedTexture.resetUploadLimit(); mRenderRequested = false; if ((mOrientationSource != null && mDisplayRotation != mOrientationSource.getDisplayRotation()) || (mFlags & FLAG_NEED_LAYOUT) != 0) { layoutContentPane(); } mCanvas.save(GLCanvas.SAVE_FLAG_ALL); rotateCanvas(-mCompensation); if (mContentView != null) { mContentView.render(mCanvas); } else { // Make sure we always draw something to prevent displaying garbage mCanvas.clearBuffer(); } mCanvas.restore(); if (!mAnimations.isEmpty()) { long now = AnimationTime.get(); for (int i = 0, n = mAnimations.size(); i < n; i++) { mAnimations.get(i).setStartTime(now); } mAnimations.clear(); } if (UploadedTexture.uploadLimitReached()) { requestRender(); } synchronized (mIdleListeners) { if (!mIdleListeners.isEmpty()) mIdleRunner.enable(); } if (DEBUG_INVALIDATE) { mCanvas.fillRect(10, 10, 5, 5, mInvalidateColor); mInvalidateColor = ~mInvalidateColor; } if (DEBUG_DRAWING_STAT) { mCanvas.dumpStatisticsAndClear(); } } private void rotateCanvas(int degrees) { if (degrees == 0) return; int w = getWidth(); int h = getHeight(); int cx = w / 2; int cy = h / 2; mCanvas.translate(cx, cy); mCanvas.rotate(degrees, 0, 0, 1); if (degrees % 180 != 0) { mCanvas.translate(-cy, -cx); } else { mCanvas.translate(-cx, -cy); } } @Override public boolean dispatchTouchEvent(MotionEvent event) { if (!isEnabled()) return false; int action = event.getAction(); if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { mInDownState = false; } else if (!mInDownState && action != MotionEvent.ACTION_DOWN) { return false; } if (mCompensation != 0) { event = MotionEvent.obtain(event); event.transform(mCompensationMatrix); } mRenderLock.lock(); try { // If this has been detached from root, we don't need to handle event boolean handled = mContentView != null && mContentView.dispatchTouchEvent(event); if (action == MotionEvent.ACTION_DOWN && handled) { mInDownState = true; } return handled; } finally { mRenderLock.unlock(); } } @Override public void lockRenderThread() { mRenderLock.lock(); } @Override public void unlockRenderThread() { mRenderLock.unlock(); } @Override public void onPause() { unfreeze(); if (mContentView != null) { mContentView.pause(); } super.onPause(); } @Override public void onResume() { if (mContentView != null) { mContentView.resume(); } super.onResume(); } @Override public void setOrientationSource(OrientationSource source) { mOrientationSource = source; } @Override public int getDisplayRotation() { return mDisplayRotation; } @Override public int getCompensation() { return mCompensation; } @Override public Matrix getCompensationMatrix() { return mCompensationMatrix; } @Override public void freeze() { mRenderLock.lock(); mFreeze = true; mRenderLock.unlock(); } @Override public void unfreeze() { mRenderLock.lock(); mFreeze = false; mFreezeCondition.signalAll(); mRenderLock.unlock(); } @Override public void setLightsOutMode(boolean enabled) { int flags = 0; if (enabled) { flags = SYSTEM_UI_FLAG_FULLSCREEN | SYSTEM_UI_FLAG_LAYOUT_STABLE; } setSystemUiVisibility(flags); } // We need to unfreeze in the following methods and in onPause(). // These methods will wait on GLThread. If we have freezed the GLRootView, // the GLThread will wait on main thread to call unfreeze and cause dead // lock. @Override public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) { unfreeze(); super.surfaceChanged(holder, format, w, h); } @Override public void surfaceCreated(SurfaceHolder holder) { unfreeze(); super.surfaceCreated(holder); } @Override public void surfaceDestroyed(SurfaceHolder holder) { unfreeze(); super.surfaceDestroyed(holder); } @Override protected void onDetachedFromWindow() { unfreeze(); super.onDetachedFromWindow(); } /** * {@inheritDoc} */ @Override protected void dispatchSaveInstanceState(@NonNull SparseArray container) { super.dispatchSaveInstanceState(container); if (mContentView != null) { mContentView.saveHierarchyState(container); } } /** * {@inheritDoc} */ @Override protected void dispatchRestoreInstanceState(@NonNull SparseArray container) { super.dispatchRestoreInstanceState(container); if (mContentView != null) { mContentView.restoreHierarchyState(container); } } @Override protected void finalize() throws Throwable { try { unfreeze(); } finally { super.finalize(); } } private class GLRootRenderer implements GLSurfaceView.Renderer { /** * Called when the context is created, possibly after automatic destruction. */ @Override public void onSurfaceCreated(GL10 gl1, EGLConfig config) { GL11 gl = (GL11) gl1; if (mGL != null) { // The GL Object has changed Log.i(TAG, "GLObject has changed from " + mGL + " to " + gl); } mRenderLock.lock(); try { mGL = gl; mCanvas = mEGLContextClientVersion == 2 ? new GLES20Canvas() : new GLES11Canvas(gl); BasicTexture.invalidateAllTextures(); } finally { mRenderLock.unlock(); } if (DEBUG_FPS) { setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY); } else { setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY); } } /** * Called when the OpenGL surface is recreated without destroying the * context. */ @Override public void onSurfaceChanged(GL10 gl1, int width, int height) { Log.i(TAG, "onSurfaceChanged: " + width + "x" + height + ", gl10: " + gl1.toString()); Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY); GalleryUtils.setRenderThread(); GL11 gl = (GL11) gl1; AssertUtils.assertTrue(mGL == gl); mCanvas.setSize(width, height); } @Override public void onDrawFrame(GL10 gl) { AnimationTime.update(); long t0 = System.nanoTime(); mRenderLock.lock(); while (mFreeze) { mFreezeCondition.awaitUninterruptibly(); } try { onDrawFrameLocked(); } finally { mRenderLock.unlock(); } long t = System.nanoTime(); long duration = (t - t0) / 1000000; if (duration > 100) { Log.v(TAG, "--- " + duration + " ---"); } } } private class IdleRunner implements Runnable { // true if the idle runner is in the queue private boolean mActive = false; @Override public void run() { OnGLIdleListener listener; synchronized (mIdleListeners) { mActive = false; if (mIdleListeners.isEmpty()) return; listener = mIdleListeners.removeFirst(); } mRenderLock.lock(); boolean keepInQueue; try { keepInQueue = listener.onGLIdle(mCanvas, mRenderRequested); } finally { mRenderLock.unlock(); } synchronized (mIdleListeners) { if (keepInQueue) mIdleListeners.addLast(listener); if (!mRenderRequested && !mIdleListeners.isEmpty()) enable(); } } public void enable() { // Who gets the flag can add it to the queue if (mActive) return; mActive = true; queueEvent(this); } } // Always chose a config private class ConfigChooser implements EGLConfigChooser { private final int[] mValue = new int[1]; @Override public EGLConfig chooseConfig(EGL10 egl, EGLDisplay display) { int[] num_config = new int[1]; int[] configSpec = new int[]{EGL10.EGL_NONE}; if (!egl.eglChooseConfig(display, configSpec, null, 0, num_config)) { throw new IllegalArgumentException("eglChooseConfig failed"); } int numConfigs = num_config[0]; if (numConfigs <= 0) { throw new IllegalArgumentException( "No configs match configSpec"); } EGLConfig[] configs = new EGLConfig[numConfigs]; if (!egl.eglChooseConfig(display, configSpec, configs, numConfigs, num_config)) { throw new IllegalArgumentException("eglChooseConfig#2 failed"); } EGLConfig config = chooseConfig(egl, display, configs); if (config == null) { throw new IllegalArgumentException("No config chosen"); } int renderableType = findConfigAttrib(egl, display, config, EGL10.EGL_RENDERABLE_TYPE); if ((renderableType & 0x0004) == 0x0004 /* EGL_OPENGL_ES2_BIT */) { mEGLContextClientVersion = 2; } else { mEGLContextClientVersion = 1; } return config; } private EGLConfig chooseConfig(EGL10 egl, EGLDisplay display, EGLConfig[] configs) { // Use score to avoid "No config chosen" int configIndex = 0; int maxScore = 0; for (int i = 0, n = configs.length; i < n; i++) { final EGLConfig config = configs[i]; final int redSize = findConfigAttrib(egl, display, config, EGL10.EGL_RED_SIZE); final int greenSize = findConfigAttrib(egl, display, config, EGL10.EGL_GREEN_SIZE); final int blueSize = findConfigAttrib(egl, display, config, EGL10.EGL_BLUE_SIZE); final int alphaSize = findConfigAttrib(egl, display, config, EGL10.EGL_ALPHA_SIZE); final int sampleBuffers = findConfigAttrib(egl, display, config, EGL10.EGL_SAMPLE_BUFFERS); final int samples = findConfigAttrib(egl, display, config, EGL10.EGL_SAMPLES); final int depth = findConfigAttrib(egl, display, config, EGL10.EGL_DEPTH_SIZE); final int stencil = findConfigAttrib(egl, display, config, EGL10.EGL_STENCIL_SIZE); final int score = redSize + greenSize + blueSize + alphaSize + sampleBuffers + samples - depth - stencil; if (score > maxScore) { maxScore = score; configIndex = i; } } return configs[configIndex]; } private int findConfigAttrib(EGL10 egl, EGLDisplay display, EGLConfig config, int attribute) { if (egl.eglGetConfigAttrib(display, config, attribute, mValue)) { return mValue[0]; } return 0; } } private class ContextFactory implements EGLContextFactory { private static final int EGL_CONTEXT_CLIENT_VERSION = 0x3098; @Override public EGLContext createContext(EGL10 egl, EGLDisplay display, EGLConfig config) { int[] attrib_list = {EGL_CONTEXT_CLIENT_VERSION, mEGLContextClientVersion, EGL10.EGL_NONE}; return egl.eglCreateContext(display, config, EGL10.EGL_NO_CONTEXT, mEGLContextClientVersion != 0 ? attrib_list : null); } @Override public void destroyContext(EGL10 egl, EGLDisplay display, EGLContext context) { if (!egl.eglDestroyContext(display, context)) { Log.e("DefaultContextFactory", "display:" + display + " context: " + context); throw new RuntimeException("eglDestroyContex failed: " + GLUtils.getEGLErrorString(egl.eglGetError())); } } } } ================================================ FILE: app/src/main/java/com/hippo/glview/view/GLView.java ================================================ /* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.view; import android.graphics.Color; import android.graphics.Rect; import android.os.Parcelable; import android.os.SystemClock; import android.util.Log; import android.util.SparseArray; import android.view.MotionEvent; import androidx.annotation.IntDef; import com.hippo.glview.anim.CanvasAnimation; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.glrenderer.GLPaint; import com.hippo.yorozuya.AssertUtils; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; // GLView is a UI component. It can render to a GLCanvas and accept touch // events. A GLView may have zero or more child GLView and they form a tree // structure. The rendering and event handling will pass through the tree // structure. // // A GLView tree should be attached to a GLRoot before event dispatching and // rendering happens. GLView asks GLRoot to re-render or re-layout the // GLView hierarchy using requestRender() and requestLayoutContentPane(). // // The render() method is called in a separate thread. Before calling // dispatchTouchEvent() and layout(), GLRoot acquires a lock to avoid the // rendering thread running at the same time. If there are other entry points // from main thread (like a Handler) in your GLView, you need to call // lockRendering() if the rendering thread should not run at the same time. // public class GLView implements TouchOwner { public static final int VISIBLE = 0b00000000; public static final int INVISIBLE = 0b00000001; public static final int GONE = 0b00000010; public static final int VISIBILITY_INVALID = 0b00000011; /** * Used to mark a GlView that has no ID. */ public static final int NO_ID = -1; protected final static GLPaint mDrawBoundsPaint; private static final String TAG = "GLView"; private static final boolean DEBUG_DRAW_BOUNDS = false; private static final int FLAG_INVISIBLE = 0b00000011; private static final int FLAG_SET_MEASURED_SIZE = 0b00000100; private static final int FLAG_LAYOUT_REQUESTED = 0b00001000; static { mDrawBoundsPaint = new GLPaint(); mDrawBoundsPaint.setColor(Color.RED); mDrawBoundsPaint.setLineWidth(2); } /** * Position in parent, just like left, top, right, bottom in {@link android.view.View} */ protected final Rect mBounds = new Rect(); protected final Rect mPaddings = new Rect(); /** * Position in root */ private final int[] mPositionInRoot = new int[2]; private final Rect mTempRect = new Rect(); protected GLView mParent; protected int mMeasuredWidth = 0; protected int mMeasuredHeight = 0; protected int mScrollY = 0; protected int mScrollX = 0; private GLRoot mRoot; private ArrayList mComponents; private GLView mMotionTarget; private CanvasAnimation mAnimation; private int mViewFlags = 0; private int mLastWidthSpec = -1; private int mLastHeightSpec = -1; /** * The GlView's identifier. * * @see #setId(int) * @see #getId() */ private int mID = NO_ID; /** * The minimum height of the view. We'll try our best to have the height * of this view to at least this amount. */ private int mMinHeight; /** * The minimum width of the view. We'll try our best to have the width * of this view to at least this amount. */ private int mMinWidth; private LayoutParams mLayoutParams; private int mBackgroundColor = Color.TRANSPARENT; private TouchHelper mTouchHelper; private float mHotspotX; private float mHotspotY; private boolean mPressed; private OnClickListener mOnClickListener; private OnLongClickListener mOnLongClickListener; /** * Utility to return a default begin. Uses the supplied number as left or top. * * @param size the parent size * @param specSize the child size * @param paddingBeign the padding begin * @param paddingFinish the padding finish * @param position the {@link Gravity#POSITION_BEGIN} or {@link Gravity#POSITION_CENTER} or * {@link Gravity#POSITION_FINISH} * @return the begin size */ public static int getDefaultBegin(int size, int specSize, int paddingBeign, int paddingFinish, @Gravity.PositionMode int position) { if (position == Gravity.POSITION_FINISH) { return size - paddingFinish - specSize; } else if (position == Gravity.POSITION_CENTER) { return ((size - paddingBeign - paddingFinish) / 2) - (specSize / 2) + paddingBeign; } else { return paddingBeign; } } /** * Utility to return a default size. Uses the supplied size if the * MeasureSpec imposed no constraints. Will get larger if allowed * by the MeasureSpec. *

* It works differently from {@link android.view.View#getDefaultSize(int, int)}. * For {@link MeasureSpec#AT_MOST}, size is suggestion size * if it is small then spec size or it is not zero, * otherwise spec size. * * @param size Default size for this view * @param measureSpec Constraints imposed by the parent * @return The size this view should be. */ public static int getDefaultSize(int size, int measureSpec) { int result = size; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); switch (specMode) { case MeasureSpec.UNSPECIFIED: break; case MeasureSpec.EXACTLY: result = specSize; break; case MeasureSpec.AT_MOST: return size == 0 ? specSize : Math.min(size, specSize); } return result; } /** * Utility to return a max size. Uses the supplied size if the * MeasureSpec imposed no constraints. Will get larger if allowed * by the MeasureSpec. *

* It works the same as {@link android.view.View#getDefaultSize(int, int)}. * * @param size Default size for this view * @param measureSpec Constraints imposed by the parent * @return The size this view should be. */ public static int getMaxSize(int size, int measureSpec) { int result = size; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); result = switch (specMode) { case MeasureSpec.UNSPECIFIED -> size; case MeasureSpec.EXACTLY, MeasureSpec.AT_MOST -> specSize; default -> result; }; return result; } public static int getComponentSpec(int spec, int childSize) { int specMode = MeasureSpec.getMode(spec); int specSize = MeasureSpec.getSize(spec); int size = Math.max(0, specSize); int resultSize = 0; int resultMode = 0; switch (specMode) { // Parent has imposed an exact size on us case MeasureSpec.EXACTLY: if (childSize >= 0) { resultSize = childSize; resultMode = MeasureSpec.EXACTLY; } else if (childSize == LayoutParams.MATCH_PARENT) { // Child wants to be our size. So be it. resultSize = size; resultMode = MeasureSpec.EXACTLY; } else if (childSize == LayoutParams.WRAP_CONTENT) { // Child wants to determine its own size. It can't be // bigger than us. resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // Parent has imposed a maximum size on us case MeasureSpec.AT_MOST: if (childSize >= 0) { // Child wants a specific size... so be it resultSize = childSize; resultMode = MeasureSpec.EXACTLY; } else if (childSize == LayoutParams.MATCH_PARENT) { // Child wants to be our size, but our size is not fixed. // Constrain child to not be bigger than us. resultSize = size; resultMode = MeasureSpec.AT_MOST; } else if (childSize == LayoutParams.WRAP_CONTENT) { // Child wants to determine its own size. It can't be // bigger than us. resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // Parent asked to see how big we want to be case MeasureSpec.UNSPECIFIED: if (childSize >= 0) { // Child wants a specific size... let him have it resultSize = childSize; resultMode = MeasureSpec.EXACTLY; } break; } return MeasureSpec.makeMeasureSpec(resultSize, resultMode); } public static void measureComponent(GLView component, int widthSpec, int heightSpec) { final LayoutParams lp = component.getLayoutParams(); final int componentWidthSpec = getComponentSpec(widthSpec, lp.width); final int componentHeightSpec = getComponentSpec(heightSpec, lp.height); component.measure(componentWidthSpec, componentHeightSpec); } public static void measureAllComponents(GLView parent, int widthSpec, int heightSpec) { for (int i = 0, n = parent.getComponentCount(); i < n; i++) { GLView component = parent.getComponent(i); if (component.getVisibility() == GONE) { continue; } measureComponent(component, widthSpec, heightSpec); } } public void startAnimation(CanvasAnimation animation, boolean atOnce) { GLRoot root = getGLRoot(); if (root == null) throw new IllegalStateException(); mAnimation = animation; if (mAnimation != null) { mAnimation.start(); if (atOnce) { mAnimation.setStartTime(AnimationTime.get()); } else { root.registerLaunchedAnimation(mAnimation); } } invalidate(); } /** * Returns the visibility status for this view. * * @return One of {@link #VISIBLE}, {@link #INVISIBLE}, or {@link #GONE}. */ @Visibility @SuppressWarnings("WrongConstant") public int getVisibility() { int visibility = mViewFlags & FLAG_INVISIBLE; if (visibility == VISIBILITY_INVALID) { visibility = VISIBLE; mViewFlags &= ~FLAG_INVISIBLE; mViewFlags |= visibility; } return visibility; } /** * Set the enabled state of this view. * * @param visibility One of {@link #VISIBLE}, {@link #INVISIBLE}, or {@link #GONE}. */ public void setVisibility(@Visibility int visibility) { @Visibility int oldVisibility = getVisibility(); if (visibility == oldVisibility) { return; } mViewFlags &= ~FLAG_INVISIBLE; mViewFlags |= visibility; onVisibilityChanged(this, visibility); if (oldVisibility == GLView.GONE || visibility == GLView.GONE) { requestLayout(); } else { invalidate(); } } // This should only be called on the content pane (the topmost GLView). void attachToRoot(GLRoot root) { AssertUtils.assertTrue(mParent == null && mRoot == null); onAttachToRoot(root); } // This should only be called on the content pane (the topmost GLView). void detachFromRoot() { AssertUtils.assertTrue(mParent == null && mRoot != null); onDetachFromRoot(); } // This should only be called on the content pane (the topmost GLView). void pause() { onPause(); } // This should only be called on the content pane (the topmost GLView). void resume() { onResume(); } public boolean isAttachedToRoot() { return mRoot != null; } // Returns the number of children of the GLView. public int getComponentCount() { return mComponents == null ? 0 : mComponents.size(); } // Returns the children for the given index. public GLView getComponent(int index) { if (mComponents == null) { throw new ArrayIndexOutOfBoundsException(index); } return mComponents.get(index); } // Adds a child to this GLView. public void addComponent(GLView component) { addComponent(component, -1, null); } public void addComponent(GLView component, int index) { addComponent(component, index, null); } public void addComponent(GLView component, LayoutParams params) { addComponent(component, -1, params); } public void addComponent(GLView component, int index, LayoutParams params) { // Make component is not null if (component == null) { throw new IllegalArgumentException("Cannot add a null child view to a ViewGroup"); } // Make sure the component doesn't have a parent currently. if (component.mParent != null) { throw new IllegalStateException( "Component " + this + " being added, but it already has a parent"); } // Ensure index is valid if (index < 0) { index = getComponentCount(); } // Ensure params is valid if (params == null) { params = generateDefaultLayoutParams(); } else if (!checkLayoutParams(params)) { params = generateLayoutParams(params); } // Build parent-child links if (mComponents == null) { mComponents = new ArrayList<>(); } mComponents.add(index, component); component.mParent = this; component.setLayoutParams(params); // If this is added after we have a root, tell the component. if (mRoot != null) { component.onAttachToRoot(mRoot); } } // Removes a child from this GLView. public void removeComponent(GLView component) { if (mComponents == null) return; if (mComponents.remove(component)) { removeOneComponent(component); } } public boolean removeComponentAt(int index) { if (mComponents == null) { return false; } if (index >= 0 && index < getComponentCount()) { GLView component = mComponents.remove(index); removeOneComponent(component); return true; } else { return false; } } // Removes all children of this GLView. public void removeAllComponents() { for (int i = 0, n = mComponents.size(); i < n; ++i) { removeOneComponent(mComponents.get(i)); } mComponents.clear(); } private void removeOneComponent(GLView component) { if (mMotionTarget == component) { long now = SystemClock.uptimeMillis(); MotionEvent cancelEvent = MotionEvent.obtain( now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0); dispatchTouchEvent(cancelEvent); cancelEvent.recycle(); } component.onDetachFromRoot(); component.mParent = null; } /** * Returns this GlView's identifier. * * @return a positive integer used to identify the view or {@link #NO_ID} * if the view has no ID */ public int getId() { return mID; } /** * Sets the identifier for this GlView. The identifier does not have to be * unique in this GlView's hierarchy. The identifier should be a positive * number. * * @param id a number used to identify the view */ public void setId(int id) { mID = id; } public Rect bounds() { return mBounds; } @Override public void setHotspot(float x, float y) { mHotspotX = x; mHotspotY = y; } public float getHotspotX() { return mHotspotX; } public float getHotspotY() { return mHotspotY; } @Override public boolean isEnabled() { return true; // TODO } @Override public boolean isPressed() { return mPressed; } @Override public void setPressed(boolean pressed) { mPressed = pressed; } @Override public boolean isClickable() { return mOnClickListener != null; } @Override public boolean isLongClickable() { return mOnLongClickListener != null; } @Override public void performClick() { if (mOnClickListener != null) { mOnClickListener.onClick(this); } else { } } @Override public boolean performLongClick() { if (mOnLongClickListener != null) { return mOnLongClickListener.onLongClick(this); } else { return false; } } public void setOnClickListener(OnClickListener listener) { mOnClickListener = listener; } public void setOnLongClickListener(OnLongClickListener listener) { mOnLongClickListener = listener; } @Override public int getWidth() { return mBounds.right - mBounds.left; } @Override public int getHeight() { return mBounds.bottom - mBounds.top; } /** * Offset this GLView's vertical location by the specified number of pixels. * * @param offset the number of pixels to offset the view by */ public void offsetTopAndBottom(int offset) { if (offset != 0) { mBounds.offset(0, offset); dispatchNotifyPositionInRoot(); invalidate(); } } /** * Offset this GLView's horizontal location by the specified amount of pixels. * * @param offset the number of pixels to offset the view by */ public void offsetLeftAndRight(int offset) { if (offset != 0) { mBounds.offset(offset, 0); dispatchNotifyPositionInRoot(); invalidate(); } } public GLRoot getGLRoot() { return mRoot; } // Request re-rendering of the view hierarchy. // This is used for animation or when the contents changed. public void invalidate() { GLRoot root = getGLRoot(); if (root != null) root.requestRender(); } // Request re-layout of the view hierarchy. public void requestLayout() { mViewFlags |= FLAG_LAYOUT_REQUESTED; mLastWidthSpec = -1; mLastHeightSpec = -1; if (mParent != null) { mParent.requestLayout(); } else { // Is this a content pane ? GLRoot root = getGLRoot(); if (root != null) root.requestLayoutContentPane(); } } public void render(GLCanvas canvas) { // render background color if (Color.alpha(mBackgroundColor) != 0) { canvas.fillRect(0, 0, getWidth(), getHeight(), mBackgroundColor); } // render content onRender(canvas); // render child canvas.save(); for (int i = 0, n = getComponentCount(); i < n; ++i) { renderChild(canvas, getComponent(i)); } canvas.restore(); // render bounds if (DEBUG_DRAW_BOUNDS) { canvas.drawRect(0, 0, getWidth(), getHeight(), mDrawBoundsPaint); } } public void onRender(GLCanvas canvas) { } public void setBackgroundColor(int color) { mBackgroundColor = color; } protected void renderChild(GLCanvas canvas, GLView component) { if (component.getVisibility() != GLView.VISIBLE) { return; } getValidRect(mTempRect); if (mTempRect.isEmpty()) { return; } int xOffset = component.mBounds.left - mScrollX; int yOffset = component.mBounds.top - mScrollY; canvas.translate(xOffset, yOffset); CanvasAnimation anim = component.mAnimation; if (anim != null) { canvas.save(anim.getCanvasSaveFlags()); if (anim.calculate(AnimationTime.get())) { invalidate(); } else { component.mAnimation = null; } anim.apply(canvas); } component.render(canvas); if (anim != null) canvas.restore(); canvas.translate(-xOffset, -yOffset); } protected boolean onTouch(MotionEvent event) { if (mTouchHelper == null) { mTouchHelper = new TouchHelper(this); } return mTouchHelper.onTouch(event); } protected boolean dispatchTouchEvent(MotionEvent event, int x, int y, GLView component, boolean checkBounds) { Rect rect = component.mBounds; int left = rect.left; int top = rect.top; if (!checkBounds || rect.contains(x, y)) { event.offsetLocation(-left, -top); if (component.dispatchTouchEvent(event)) { event.offsetLocation(left, top); return true; } event.offsetLocation(left, top); } return false; } protected boolean dispatchTouchEvent(MotionEvent event) { int x = (int) event.getX(); int y = (int) event.getY(); int action = event.getAction(); if (mMotionTarget != null) { if (action == MotionEvent.ACTION_DOWN) { MotionEvent cancel = MotionEvent.obtain(event); cancel.setAction(MotionEvent.ACTION_CANCEL); dispatchTouchEvent(cancel, x, y, mMotionTarget, false); mMotionTarget = null; } else { dispatchTouchEvent(event, x, y, mMotionTarget, false); if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { mMotionTarget = null; } return true; } } if (action == MotionEvent.ACTION_DOWN) { // in the reverse rendering order for (int i = getComponentCount() - 1; i >= 0; --i) { GLView component = getComponent(i); if (component.getVisibility() != GLView.VISIBLE) continue; if (dispatchTouchEvent(event, x, y, component, true)) { mMotionTarget = component; return true; } } } return onTouch(event); } /** * Called by {@link GLRootView#dispatchSaveInstanceState(SparseArray)} to * store this GlView hierarchy's frozen state into the given container. * * @param container The SparseArray in which to save the view's state. */ public void saveHierarchyState(SparseArray container) { dispatchSaveInstanceState(container); } protected void dispatchSaveInstanceState(SparseArray container) { if (mID != NO_ID) { Parcelable state = onSaveInstanceState(); if (state != null) { container.put(mID, state); } } for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).dispatchSaveInstanceState(container); } } protected Parcelable onSaveInstanceState() { return null; } /** * Called by {@link GLRootView#dispatchRestoreInstanceState(SparseArray)} to * restore this view hierarchy's frozen state from the given container. * * @param container The SparseArray which holds previously frozen states. */ public void restoreHierarchyState(SparseArray container) { dispatchRestoreInstanceState(container); } protected void dispatchRestoreInstanceState(SparseArray container) { if (mID != NO_ID) { Parcelable state = container.get(mID); if (state != null) { onRestoreInstanceState(state); } } for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).dispatchRestoreInstanceState(container); } } protected void onRestoreInstanceState(Parcelable state) { if (state != null) { throw new IllegalArgumentException("Please override onRestoreInstanceState"); } } public void setPaddings(int paddingLeft, int paddingTop, int paddingRight, int paddingBottom) { mPaddings.set(paddingLeft, paddingTop, paddingRight, paddingBottom); } public void setPaddingLeft(int paddingLeft) { mPaddings.left = paddingLeft; } public void setPaddingTop(int paddingTop) { mPaddings.top = paddingTop; } public void setPaddingRight(int paddingRight) { mPaddings.right = paddingRight; } public void setPaddingBottom(int paddingBottom) { mPaddings.bottom = paddingBottom; } public Rect getPaddings() { return mPaddings; } // There is a little different between GLView.layout() and View.layout(). // onMeasure() is not called in GLView.layout(). // For the content view of GLRootView, GLView.measure() is called before GLView.layout(). // For component, GLView.measure() in called in parent's GLView.onMeasure(). // So it is OK. public void layout(int left, int top, int right, int bottom) { boolean sizeChanged = setBounds(left, top, right, bottom); final boolean forceLayout = (mViewFlags & FLAG_LAYOUT_REQUESTED) == FLAG_LAYOUT_REQUESTED; if (sizeChanged || forceLayout) { notifyPositionInRoot(); onLayout(sizeChanged, left, top, right, bottom); } else { dispatchNotifyPositionInRoot(); } mViewFlags &= ~FLAG_LAYOUT_REQUESTED; } public void dispatchNotifyPositionInRoot() { if (notifyPositionInRoot()) { for (int i = 0, size = getComponentCount(); i < size; i++) { getComponent(i).dispatchNotifyPositionInRoot(); } } } public boolean notifyPositionInRoot() { // Update position in root int[] position = mPositionInRoot; int oldX = position[0]; int oldY = position[1]; if (mParent == null) { position[0] = 0; position[1] = 0; } else { mParent.getPositionInRoot(position); // Apply offset in parent position[0] += mBounds.left; position[1] += mBounds.top; } int x = position[0]; int y = position[1]; if (x != oldX || y != oldY) { onPositionInRootChanged(x, y, oldX, oldY); return true; } else { return false; } } private boolean setBounds(int left, int top, int right, int bottom) { Rect bounds = mBounds; int oldW = bounds.right - bounds.left; int oldH = bounds.bottom - bounds.top; int newW = right - left; int newH = bottom - top; boolean sizeChanged = oldW != newW || oldH != newH; bounds.set(left, top, right, bottom); if (sizeChanged) { onSizeChanged(newW, newH, oldW, oldH); } return sizeChanged; } protected void onSizeChanged(int newW, int newH, int oldW, int oldH) { } protected void onPositionInRootChanged(int x, int y, int oldX, int oldY) { } public void getPositionInRoot(int[] position) { AssertUtils.assertEquals("position should be 2 length int array", position.length, 2); position[0] = mPositionInRoot[0]; position[1] = mPositionInRoot[1]; } /** * Get the rect of the view clipped by root rect * * @param rect the result */ public void getValidRect(Rect rect) { GLRoot root = mRoot; if (root == null) { rect.setEmpty(); return; } int x = mPositionInRoot[0]; int y = mPositionInRoot[1]; rect.set(x, y, x + getWidth(), y + getHeight()); if (!rect.intersect(0, 0, root.getWidth(), root.getHeight())) { rect.setEmpty(); return; } rect.offset(-x, -y); } public void measure(int widthSpec, int heightSpec) { final boolean forceLayout = (mViewFlags & FLAG_LAYOUT_REQUESTED) == FLAG_LAYOUT_REQUESTED; final boolean isExactly = MeasureSpec.getMode(widthSpec) == MeasureSpec.EXACTLY && MeasureSpec.getMode(heightSpec) == MeasureSpec.EXACTLY; final boolean matchingSize = isExactly && getMeasuredWidth() == MeasureSpec.getSize(widthSpec) && getMeasuredHeight() == MeasureSpec.getSize(heightSpec); if (forceLayout || !matchingSize && (widthSpec != mLastWidthSpec || heightSpec != mLastHeightSpec)) { mLastWidthSpec = widthSpec; mLastHeightSpec = heightSpec; mViewFlags &= ~FLAG_SET_MEASURED_SIZE; onMeasure(widthSpec, heightSpec); if ((mViewFlags & FLAG_SET_MEASURED_SIZE) == 0) { throw new IllegalStateException(getClass().getName() + " should call setMeasuredSize() in onMeasure()"); } } } protected void onMeasure(int widthSpec, int heightSpec) { setMeasuredSize(getDefaultSize(getSuggestedMinimumWidth(), widthSpec), getDefaultSize(getSuggestedMinimumHeight(), heightSpec)); } protected int getSuggestedMinimumHeight() { return getMinimumHeight() + mPaddings.top + mPaddings.bottom; } protected int getSuggestedMinimumWidth() { return getMinimumWidth() + mPaddings.left + mPaddings.right; } /** * Returns the minimum height of the view. * * @return the minimum height the view will try to be. * @see #setMinimumHeight(int) */ public int getMinimumHeight() { return mMinHeight; } /** * Sets the minimum height of the view. It is not guaranteed the view will * be able to achieve this minimum height (for example, if its parent layout * constrains it with less available height). * * @param minHeight The minimum height the view will try to be. * @see #getMinimumHeight() */ public void setMinimumHeight(int minHeight) { mMinHeight = minHeight; requestLayout(); } /** * Returns the minimum width of the view. * * @return the minimum width the view will try to be. * @see #setMinimumWidth(int) */ public int getMinimumWidth() { return mMinWidth; } /** * Sets the minimum width of the view. It is not guaranteed the view will * be able to achieve this minimum width (for example, if its parent layout * constrains it with less available width). * * @param minWidth The minimum width the view will try to be. * @see #getMinimumWidth() */ public void setMinimumWidth(int minWidth) { mMinWidth = minWidth; requestLayout(); } protected void setMeasuredSize(int width, int height) { mViewFlags |= FLAG_SET_MEASURED_SIZE; mMeasuredWidth = width; mMeasuredHeight = height; } public int getMeasuredWidth() { return mMeasuredWidth; } public int getMeasuredHeight() { return mMeasuredHeight; } protected void onLayout( boolean changeSize, int left, int top, int right, int bottom) { } public LayoutParams getLayoutParams() { return mLayoutParams; } public void setLayoutParams(LayoutParams params) { if (params == null) { if (mParent == null) { throw new NullPointerException("Layout parameters cannot be null"); } else { params = mParent.generateDefaultLayoutParams(); } } mLayoutParams = params; requestLayout(); } protected boolean checkLayoutParams(LayoutParams p) { return p != null; } protected LayoutParams generateDefaultLayoutParams() { return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); } protected LayoutParams generateLayoutParams(LayoutParams p) { return new LayoutParams(p); } /** * Gets the bounds of the given descendant that relative to this view. */ public boolean getBoundsOf(GLView descendant, Rect out) { int xOffset = 0; int yOffset = 0; GLView view = descendant; while (view != this) { if (view == null) return false; Rect bounds = view.mBounds; xOffset += bounds.left; yOffset += bounds.top; view = view.mParent; } out.set(xOffset, yOffset, xOffset + descendant.getWidth(), yOffset + descendant.getHeight()); return true; } protected void onVisibilityChanged(GLView changedView, @Visibility int visibility) { for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).onVisibilityChanged(changedView, visibility); } } public void onAttachToRoot(GLRoot root) { mRoot = root; for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).onAttachToRoot(root); } } public void onDetachFromRoot() { for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).onDetachFromRoot(); } mRoot = null; } public void onPause() { for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).onPause(); } } public void onResume() { for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).onResume(); } } public void lockRendering() { if (mRoot != null) { mRoot.lockRenderThread(); } } public void unlockRendering() { if (mRoot != null) { mRoot.unlockRenderThread(); } } // This is for debugging only. // Dump the view hierarchy into log. void dumpTree(String prefix) { Log.d(TAG, prefix + getClass().getSimpleName()); for (int i = 0, n = getComponentCount(); i < n; ++i) { getComponent(i).dumpTree(prefix + "...."); } } @IntDef({VISIBLE, INVISIBLE, GONE}) @Retention(RetentionPolicy.SOURCE) public @interface Visibility { } public interface OnClickListener { boolean onClick(GLView v); } public interface OnLongClickListener { boolean onLongClick(GLView v); } /** * A MeasureSpec encapsulates the layout requirements passed from parent to child. * Each MeasureSpec represents a requirement for either the width or the height. * A MeasureSpec is comprised of a size and a mode. There are three possible * modes: *

*
UNSPECIFIED
*
* The parent has not imposed any constraint on the child. It can be whatever size * it wants. *
* *
EXACTLY
*
* The parent has determined an exact size for the child. The child is going to be * given those bounds regardless of how big it wants to be. *
* *
AT_MOST
*
* The child can be as large as it wants up to the specified size. *
*
*

* MeasureSpecs are implemented as ints to reduce object allocation. This class * is provided to pack and unpack the <size, mode> tuple into the int. */ public static class MeasureSpec { private static final int MODE_SHIFT = 30; /** * Measure specification mode: The parent has not imposed any constraint * on the child. It can be whatever size it wants. */ public static final int UNSPECIFIED = 0; /** * Measure specification mode: The parent has determined an exact size * for the child. The child is going to be given those bounds regardless * of how big it wants to be. */ public static final int EXACTLY = 1 << MODE_SHIFT; /** * Measure specification mode: The child can be as large as it wants up * to the specified size. */ public static final int AT_MOST = 2 << MODE_SHIFT; private static final int MODE_MASK = 0x3 << MODE_SHIFT; /** * Creates a measure specification based on the supplied size and mode. *

* The mode must always be one of the following: *

    *
  • {@link #UNSPECIFIED}
  • *
  • {@link #EXACTLY}
  • *
  • {@link #AT_MOST}
  • *
* *

Note: On API level 17 and lower, makeMeasureSpec's * implementation was such that the order of arguments did not matter * and overflow in either value could impact the resulting MeasureSpec. * {@link android.widget.RelativeLayout} was affected by this bug. * Apps targeting API levels greater than 17 will get the fixed, more strict * behavior.

* * @param size the size of the measure specification * @param mode the mode of the measure specification * @return the measure specification based on size and mode */ public static int makeMeasureSpec(int size, int mode) { return (size & ~MODE_MASK) | (mode & MODE_MASK); } /** * Extracts the mode from the supplied measure specification. * * @param measureSpec the measure specification to extract the mode from * @return {@link #UNSPECIFIED}, * {@link #AT_MOST} or * {@link #EXACTLY} */ public static int getMode(int measureSpec) { return (measureSpec & MODE_MASK); } /** * Extracts the size from the supplied measure specification. * * @param measureSpec the measure specification to extract the size from * @return the size in pixels defined in the supplied measure specification */ public static int getSize(int measureSpec) { return (measureSpec & ~MODE_MASK); } /** * Returns a String representation of the specified measure * specification. * * @param measureSpec the measure specification to convert to a String * @return a String with the following format: "MeasureSpec: MODE SIZE" */ public static String toString(int measureSpec) { int mode = getMode(measureSpec); int size = getSize(measureSpec); StringBuilder sb = new StringBuilder("MeasureSpec: "); if (mode == UNSPECIFIED) sb.append("UNSPECIFIED "); else if (mode == EXACTLY) sb.append("EXACTLY "); else if (mode == AT_MOST) sb.append("AT_MOST "); else sb.append(mode).append(" "); sb.append(size); return sb.toString(); } } /** * The LayoutParams look like {@link android.view.ViewGroup.LayoutParams}, * and work like it. */ public static class LayoutParams { /** * Special value for the height or width requested by a View. * MATCH_PARENT means that the view wants to be as big as its parent, * minus the parent's padding, if any. Introduced in API Level 8. */ public static final int MATCH_PARENT = -1; /** * Special value for the height or width requested by a View. * WRAP_CONTENT means that the view wants to be just large enough to fit * its own internal content, taking its own padding into account. */ public static final int WRAP_CONTENT = -2; /** * Information about how wide the view wants to be. Can be one of the * constants {@link #MATCH_PARENT} or {@link #WRAP_CONTENT} or an exact size. */ public int width; /** * Information about how tall the view wants to be. Can be one of the * constants {@link #MATCH_PARENT} or {@link #WRAP_CONTENT} or an exact size. */ public int height; /** * Creates a new set of layout parameters with the specified width * and height. * * @param width the width, either {@link #WRAP_CONTENT}, * {@link #MATCH_PARENT}, or a fixed size in pixels * @param height the height, either {@link #WRAP_CONTENT}, * {@link #MATCH_PARENT}, or a fixed size in pixels */ public LayoutParams(int width, int height) { this.width = width; this.height = height; } /** * Copy constructor. Clones the width and height values of the source. * * @param source The layout params to copy from. */ public LayoutParams(LayoutParams source) { this.width = source.width; this.height = source.height; } } /** * LayoutParams with gravity inside */ public static class GravityLayoutParams extends LayoutParams { public int gravity = Gravity.NO_GRAVITY; public GravityLayoutParams(GLView.LayoutParams source) { super(source); if (source instanceof GravityLayoutParams) { gravity = ((GravityLayoutParams) source).gravity; } } public GravityLayoutParams(int width, int height) { super(width, height); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/view/Gravity.java ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.glview.view; import androidx.annotation.IntDef; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; public class Gravity { public static final int NO_GRAVITY = 0x0000; public static final int LEFT = 0x0001; public static final int TOP = 0x0002; public static final int RIGHT = 0x0004; public static final int BOTTOM = 0x0008; public static final int CENTER_HORIZONTAL = LEFT | RIGHT; public static final int HORIZONTAL_MASK = CENTER_HORIZONTAL; public static final int CENTER_VERTICAL = TOP | BOTTOM; public static final int CENTER = CENTER_VERTICAL | CENTER_HORIZONTAL; public static final int VERTICAL_MASK = CENTER_VERTICAL; public static final int POSITION_BEGIN = 0; public static final int POSITION_CENTER = 1; public static final int POSITION_FINISH = 2; public static final int HORIZONTAL = 0; public static final int VERTICAL = 1; public static boolean centerHorizontal(int gravity) { return (gravity & HORIZONTAL_MASK) == CENTER_HORIZONTAL; } public static boolean right(int gravity) { return (gravity & HORIZONTAL_MASK) == RIGHT; } public static boolean left(int gravity) { return (gravity & HORIZONTAL_MASK) == LEFT; } public static boolean centerVertical(int gravity) { return (gravity & VERTICAL_MASK) == CENTER_VERTICAL; } public static boolean top(int gravity) { return (gravity & VERTICAL_MASK) == TOP; } public static boolean bottom(int gravity) { return (gravity & VERTICAL_MASK) == BOTTOM; } public static @PositionMode int getPosition(int gravity, @DirectionMode int direction) { if (direction == HORIZONTAL) { if (right(gravity)) { return POSITION_FINISH; } else if (centerHorizontal(gravity)) { return POSITION_CENTER; } else { return POSITION_BEGIN; } } else { if (bottom(gravity)) { return POSITION_FINISH; } else if (centerVertical(gravity)) { return POSITION_CENTER; } else { return POSITION_BEGIN; } } } @IntDef({POSITION_BEGIN, POSITION_CENTER, POSITION_FINISH}) @Retention(RetentionPolicy.SOURCE) public @interface PositionMode { } @IntDef({HORIZONTAL, VERTICAL}) @Retention(RetentionPolicy.SOURCE) public @interface DirectionMode { } } ================================================ FILE: app/src/main/java/com/hippo/glview/view/OrientationSource.java ================================================ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.view; public interface OrientationSource { int getDisplayRotation(); int getCompensation(); } ================================================ FILE: app/src/main/java/com/hippo/glview/view/TouchHelper.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.view; import android.content.Context; import android.view.MotionEvent; import android.view.ViewConfiguration; import androidx.annotation.NonNull; import com.hippo.yorozuya.LayoutUtils; import com.hippo.yorozuya.SimpleHandler; public class TouchHelper { /** * Cache the touch slop from the context that created the view. */ private static int sTouchSlop = 8; private final TouchOwner mOwner; private boolean mPrePressed = false; private boolean mHasPerformedLongPress = false; private CheckForLongPress mPendingCheckForLongPress = null; private CheckForTap mPendingCheckForTap = null; private PerformClick mPerformClick = null; private UnsetPressedState mUnsetPressedState = null; public TouchHelper(@NonNull TouchOwner owner) { mOwner = owner; } public static void initialize(Context context) { sTouchSlop = LayoutUtils.dp2pix(context, 8); // 8dp } public boolean onTouch(MotionEvent event) { TouchOwner owner = mOwner; final float x = event.getX(); final float y = event.getY(); if (event.getAction() == MotionEvent.ACTION_DOWN || event.getAction() == MotionEvent.ACTION_MOVE) owner.setHotspot(x, y); if (!owner.isEnabled()) { if (event.getAction() == MotionEvent.ACTION_UP && owner.isPressed()) { owner.setPressed(false); } // A disabled view that is clickable still consumes the touch // events, it just doesn't respond to them. return (owner.isClickable() || owner.isLongClickable()); } if (owner.isClickable() || owner.isLongClickable()) { switch (event.getAction()) { case MotionEvent.ACTION_UP: if (owner.isPressed() || mPrePressed) { if (mPrePressed) { // The button is being released before we actually // showed it as pressed. Make it show the pressed // state now (before scheduling the click) to ensure // the user sees it. setPressed(true, x, y); } if (!mHasPerformedLongPress) { // This is a tap, so remove the longpress check removeLongPressCallback(); // Use a Runnable and post this rather than calling // performClick directly. This lets other visual state // of the view update before click actions start. if (mPerformClick == null) { mPerformClick = new PerformClick(); } if (!SimpleHandler.getInstance().post(mPerformClick)) { owner.performClick(); } } if (mUnsetPressedState == null) { mUnsetPressedState = new UnsetPressedState(); } if (mPrePressed) { SimpleHandler.getInstance().postDelayed(mUnsetPressedState, ViewConfiguration.getPressedStateDuration()); } else if (!SimpleHandler.getInstance().post(mUnsetPressedState)) { // If the post failed, unpress right now mUnsetPressedState.run(); } removeTapCallback(); } break; case MotionEvent.ACTION_DOWN: mHasPerformedLongPress = false; mPrePressed = true; if (mPendingCheckForTap == null) { mPendingCheckForTap = new CheckForTap(); } mPendingCheckForTap.x = event.getX(); mPendingCheckForTap.y = event.getY(); SimpleHandler.getInstance().postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); break; case MotionEvent.ACTION_CANCEL: owner.setPressed(false); removeTapCallback(); removeLongPressCallback(); break; case MotionEvent.ACTION_MOVE: owner.setHotspot(x, y); // Be lenient about moving outside of buttons if (!pointInView(x, y, sTouchSlop)) { // Outside button removeTapCallback(); if (owner.isPressed()) { // Remove any future long press/tap checks removeLongPressCallback(); owner.setPressed(false); } } break; } return true; } return false; } private void setPressed(boolean pressed, float x, float y) { mOwner.setPressed(pressed); if (pressed) { mOwner.setHotspot(x, y); } } /** * Utility method to determine whether the given point, in local coordinates, * is inside the view, where the area of the view is expanded by the slop factor. * This method is called while processing touch-move events to determine if the event * is still within the view. */ public boolean pointInView(float localX, float localY, float slop) { return localX >= -slop && localY >= -slop && localX < (mOwner.getWidth() + slop) && localY < (mOwner.getHeight() + slop); } /** * Remove the longpress detection timer. */ private void removeLongPressCallback() { if (mPendingCheckForLongPress != null) { SimpleHandler.getInstance().removeCallbacks(mPendingCheckForLongPress); } } /** * Remove the tap detection timer. */ private void removeTapCallback() { if (mPendingCheckForTap != null) { mPrePressed = false; SimpleHandler.getInstance().removeCallbacks(mPendingCheckForTap); } } private void checkForLongClick(int delayOffset) { if (mOwner.isLongClickable()) { mHasPerformedLongPress = false; if (mPendingCheckForLongPress == null) { mPendingCheckForLongPress = new CheckForLongPress(); } SimpleHandler.getInstance().postDelayed(mPendingCheckForLongPress, ViewConfiguration.getLongPressTimeout() - delayOffset); } } private final class CheckForLongPress implements Runnable { @Override public void run() { if (mOwner.isPressed()) { if (mOwner.performLongClick()) { mHasPerformedLongPress = true; } } } } private final class CheckForTap implements Runnable { public float x; public float y; @Override public void run() { mPrePressed = false; setPressed(true, x, y); checkForLongClick(ViewConfiguration.getTapTimeout()); } } private final class PerformClick implements Runnable { @Override public void run() { mOwner.performClick(); } } private final class UnsetPressedState implements Runnable { @Override public void run() { mOwner.setPressed(false); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/view/TouchOwner.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.view; public interface TouchOwner { void setHotspot(float x, float y); boolean isEnabled(); boolean isPressed(); void setPressed(boolean pressed); boolean isClickable(); boolean isLongClickable(); void performClick(); boolean performLongClick(); int getWidth(); int getHeight(); } ================================================ FILE: app/src/main/java/com/hippo/glview/widget/GLFrameLayout.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.widget; import com.hippo.glview.view.GLView; import com.hippo.glview.view.Gravity; public class GLFrameLayout extends GLView { @Override protected void onMeasure(int widthSpec, int heightSpec) { int maxWidth = 0; int maxHeight = 0; for (int i = 0, n = getComponentCount(); i < n; i++) { GLView component = getComponent(i); if (component.getVisibility() == GONE) { continue; } measureComponent(component, widthSpec, heightSpec); maxWidth = Math.max(maxWidth, component.getMeasuredWidth()); maxHeight = Math.max(maxHeight, component.getMeasuredHeight()); } // Consider min maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth()); maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight()); // The final maxWidth = getDefaultSize(maxWidth, widthSpec); maxHeight = getDefaultSize(maxHeight, heightSpec); setMeasuredSize(maxWidth, maxHeight); if (MeasureSpec.getSize(widthSpec) != MeasureSpec.EXACTLY || MeasureSpec.getSize(heightSpec) != MeasureSpec.EXACTLY) { // Measure again measureAllComponents(this, MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.EXACTLY)); } } @Override protected void onLayout(boolean changeSize, int left, int top, int right, int bottom) { int width = getWidth(); int height = getHeight(); for (int i = 0, n = getComponentCount(); i < n; i++) { GLView component = getComponent(i); if (component.getVisibility() == GONE) { continue; } int measureWidth = component.getMeasuredWidth(); int measureHeight = component.getMeasuredHeight(); int componentLeft; int componentTop; GravityLayoutParams lp = (GravityLayoutParams) component.getLayoutParams(); int gravity = lp.gravity; if (Gravity.centerHorizontal(gravity)) { componentLeft = (width / 2) - (measureWidth / 2); } else if (Gravity.right(gravity)) { componentLeft = width - measureWidth; } else { componentLeft = 0; } if (Gravity.centerVertical(gravity)) { componentTop = (height / 2) - (measureHeight / 2); } else if (Gravity.bottom(gravity)) { componentTop = height - measureHeight; } else { componentTop = 0; } component.layout(componentLeft, componentTop, componentLeft + measureWidth, componentTop + measureHeight); } } @Override protected boolean checkLayoutParams(GLView.LayoutParams p) { return p instanceof GravityLayoutParams; } @Override protected LayoutParams generateDefaultLayoutParams() { return new GravityLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); } @Override protected LayoutParams generateLayoutParams(GLView.LayoutParams p) { return p == null ? generateDefaultLayoutParams() : new GravityLayoutParams(p); } } ================================================ FILE: app/src/main/java/com/hippo/glview/widget/GLLinearLayout.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.widget; import android.graphics.Rect; import androidx.annotation.IntDef; import com.hippo.glview.view.GLView; import com.hippo.glview.view.Gravity; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; public class GLLinearLayout extends GLView { public static final int HORIZONTAL = 0; public static final int VERTICAL = 1; private final List mTempList = new ArrayList<>(); private int mInterval = 0; private int mOrientation = VERTICAL; public void setInterval(int interval) { if (mInterval != interval) { mInterval = interval; requestLayout(); } } /** * Should the layout be a column or a row. * * @param orientation Pass {@link #HORIZONTAL} or {@link #VERTICAL}. Default * value is {@link #HORIZONTAL}. */ public void setOrientation(@OrientationMode int orientation) { if (mOrientation != orientation) { mOrientation = orientation; requestLayout(); } } @Override protected void onMeasure(int widthSpec, int heightSpec) { int layoutWidthSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthSpec) - mPaddings.left - mPaddings.right, MeasureSpec.getMode(widthSpec)); int layoutHeightSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightSpec) - mPaddings.top - mPaddings.bottom, MeasureSpec.getMode(heightSpec)); if (mOrientation == HORIZONTAL && MeasureSpec.getMode(widthSpec) == MeasureSpec.EXACTLY) { int width = MeasureSpec.getSize(layoutWidthSpec); float sumWeight = 0.0f; for (int i = 0, n = getComponentCount(); i < n; i++) { final GLView component = getComponent(i); if (component.getVisibility() == GONE) { continue; } final LayoutParams lp = (LayoutParams) component.getLayoutParams(); if (lp.weight > 0.0f) { sumWeight += lp.weight; mTempList.add(component); } else { measureComponent(component, layoutWidthSpec, layoutHeightSpec); width -= component.getMeasuredWidth(); } } for (GLView component : mTempList) { final LayoutParams lp = (LayoutParams) component.getLayoutParams(); final int componentWidthSpec = MeasureSpec.makeMeasureSpec( (int) (width * lp.weight / sumWeight), MeasureSpec.EXACTLY); final int componentHeightSpec = getComponentSpec(layoutHeightSpec, lp.height); component.measure(componentWidthSpec, componentHeightSpec); } mTempList.clear(); } else if (mOrientation == VERTICAL && MeasureSpec.getMode(heightSpec) == MeasureSpec.EXACTLY) { int height = MeasureSpec.getSize(layoutHeightSpec); float sumWeight = 0.0f; for (int i = 0, n = getComponentCount(); i < n; i++) { final GLView component = getComponent(i); if (component.getVisibility() == GONE) { continue; } final LayoutParams lp = (LayoutParams) component.getLayoutParams(); if (lp.weight > 0.0f) { sumWeight += lp.weight; mTempList.add(component); } else { measureComponent(component, layoutWidthSpec, layoutHeightSpec); height -= component.getMeasuredHeight(); } } for (GLView component : mTempList) { final LayoutParams lp = (LayoutParams) component.getLayoutParams(); final int componentWidthSpec = getComponentSpec(layoutWidthSpec, lp.width); final int componentHeightSpec = MeasureSpec.makeMeasureSpec( (int) (height * lp.weight / sumWeight), MeasureSpec.EXACTLY); component.measure(componentWidthSpec, componentHeightSpec); } mTempList.clear(); } else { measureAllComponents(this, layoutWidthSpec, layoutHeightSpec); } int maxWidth = 0; int maxHeight = 0; for (int i = 0, n = getComponentCount(); i < n; i++) { GLView component = getComponent(i); if (component.getVisibility() == GONE) { continue; } if (mOrientation == VERTICAL) { maxWidth = Math.max(maxWidth, component.getMeasuredWidth()); if (i != 0) { maxHeight += mInterval; } maxHeight += component.getMeasuredHeight(); } else if (mOrientation == HORIZONTAL) { maxHeight = Math.max(maxHeight, component.getMeasuredHeight()); if (i != 0) { maxWidth += mInterval; } maxWidth += component.getMeasuredWidth(); } } Rect paddings = getPaddings(); maxWidth = maxWidth + paddings.left + paddings.right; maxHeight = maxHeight + paddings.top + paddings.bottom; setMeasuredSize(getDefaultSize(maxWidth, widthSpec), getDefaultSize(maxHeight, heightSpec)); } @Override protected void onLayout(boolean changeSize, int left, int top, int right, int bottom) { int width = getWidth(); int height = getHeight(); Rect paddings = getPaddings(); int componentLeft; int componentTop; if (mOrientation == VERTICAL) { componentTop = paddings.top; for (int i = 0, n = getComponentCount(); i < n; i++) { GLView component = getComponent(i); if (component.getVisibility() == GONE) { continue; } int measureWidth = component.getMeasuredWidth(); int measureHeight = component.getMeasuredHeight(); LayoutParams lp = (LayoutParams) component.getLayoutParams(); componentLeft = getDefaultBegin(width, measureWidth, paddings.left, paddings.right, Gravity.getPosition(lp.gravity, Gravity.HORIZONTAL)); component.layout(componentLeft, componentTop, componentLeft + measureWidth, componentTop + measureHeight); componentTop += measureHeight + mInterval; } } else if (mOrientation == HORIZONTAL) { componentLeft = paddings.left; for (int i = 0, n = getComponentCount(); i < n; i++) { GLView component = getComponent(i); if (component.getVisibility() == GONE) { continue; } int measureWidth = component.getMeasuredWidth(); int measureHeight = component.getMeasuredHeight(); LayoutParams lp = (LayoutParams) component.getLayoutParams(); componentTop = getDefaultBegin(height, measureHeight, paddings.top, paddings.bottom, Gravity.getPosition(lp.gravity, Gravity.VERTICAL)); component.layout(componentLeft, componentTop, componentLeft + measureWidth, componentTop + measureHeight); componentLeft += measureWidth + mInterval; } } } @Override protected boolean checkLayoutParams(GLView.LayoutParams p) { return p instanceof LayoutParams; } @Override protected GLView.LayoutParams generateDefaultLayoutParams() { return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); } @Override protected GLView.LayoutParams generateLayoutParams(GLView.LayoutParams p) { return p == null ? generateDefaultLayoutParams() : new LayoutParams(p); } @IntDef({HORIZONTAL, VERTICAL}) @Retention(RetentionPolicy.SOURCE) public @interface OrientationMode { } public static class LayoutParams extends GravityLayoutParams { public float weight = 0.0f; public LayoutParams(GLView.LayoutParams source) { super(source); if (source instanceof LayoutParams) { weight = ((LayoutParams) source).weight; } } public LayoutParams(int width, int height) { super(width, height); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/widget/GLProgressView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.widget; import android.graphics.Color; import android.graphics.Path; import android.view.animation.Interpolator; import android.view.animation.LinearInterpolator; import androidx.core.view.animation.PathInterpolatorCompat; import com.hippo.glview.anim.Animation; import com.hippo.glview.anim.FloatAnimation; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.glrenderer.GLPaint; import com.hippo.glview.view.AnimationTime; import com.hippo.glview.view.GLView; import java.util.ArrayList; import java.util.List; public class GLProgressView extends GLView { private static final Interpolator TRIM_START_INTERPOLATOR; private static final Interpolator TRIM_END_INTERPOLATOR; private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator(); static { Path trimStartPath = new Path(); trimStartPath.moveTo(0.0f, 0.0f); trimStartPath.lineTo(0.5f, 0.0f); trimStartPath.cubicTo(0.7f, 0.0f, 0.6f, 1f, 1f, 1f); TRIM_START_INTERPOLATOR = PathInterpolatorCompat.create(trimStartPath); Path trimEndPath = new Path(); trimEndPath.moveTo(0.0f, 0.0f); trimEndPath.cubicTo(0.2f, 0.0f, 0.1f, 1f, 0.5f, 1f); trimEndPath.lineTo(1f, 1f); TRIM_END_INTERPOLATOR = PathInterpolatorCompat.create(trimEndPath); } private final GLPaint mGLPaint; private final List mAnimations; private float mCx; private float mCy; private float mRadiusX; private float mRadiusY; private float mTrimStart = 0.0f; private float mTrimEnd = 0.0f; private float mTrimOffset = 0.0f; private float mTrimRotation = 0.0f; private boolean mIndeterminate = false; public GLProgressView() { mGLPaint = new GLPaint(); mGLPaint.setColor(Color.WHITE); mGLPaint.setBackgroundColor(Color.BLACK); mAnimations = new ArrayList<>(); setupAnimations(); } public void setupAnimations() { FloatAnimation trimStart = new FloatAnimation() { @Override protected void onCalculate(float progress) { super.onCalculate(progress); mTrimStart = get(); } }; trimStart.setRange(0.0f, 0.75f); trimStart.setDuration(1333L); trimStart.setInterpolator(TRIM_START_INTERPOLATOR); trimStart.setRepeatCount(Animation.INFINITE); FloatAnimation trimEnd = new FloatAnimation() { @Override protected void onCalculate(float progress) { super.onCalculate(progress); mTrimEnd = get(); } }; trimEnd.setRange(0.0f, 0.75f); trimEnd.setDuration(1333L); trimEnd.setInterpolator(TRIM_END_INTERPOLATOR); trimEnd.setRepeatCount(Animation.INFINITE); FloatAnimation trimOffset = new FloatAnimation() { @Override protected void onCalculate(float progress) { super.onCalculate(progress); mTrimOffset = get(); } }; trimOffset.setRange(0.0f, 0.25f); trimOffset.setDuration(1333L); trimOffset.setInterpolator(LINEAR_INTERPOLATOR); trimOffset.setRepeatCount(Animation.INFINITE); FloatAnimation trimRotation = new FloatAnimation() { @Override protected void onCalculate(float progress) { super.onCalculate(progress); mTrimRotation = get(); } }; trimRotation.setRange(0.0f, 720.0f); trimRotation.setDuration(6665L); trimRotation.setInterpolator(LINEAR_INTERPOLATOR); trimRotation.setRepeatCount(Animation.INFINITE); mAnimations.add(trimStart); mAnimations.add(trimEnd); mAnimations.add(trimOffset); mAnimations.add(trimRotation); } private void startAnimations() { List animations = mAnimations; for (int i = 0, n = animations.size(); i < n; i++) { animations.get(i).reset(); } } private void stopAnimations() { List animations = mAnimations; for (int i = 0, n = animations.size(); i < n; i++) { animations.get(i).cancel(); } } @Override protected void onLayout(boolean changeSize, int left, int top, int right, int bottom) { super.onLayout(changeSize, left, top, right, bottom); int width = right - left; int height = bottom - top; mGLPaint.setLineWidth(Math.min(width, height) / 12.0f); mCx = (float) width / 2; mCy = (float) height / 2; mRadiusX = width / 48.0f * 19.0f; mRadiusY = height / 48.0f * 19.0f; } public void setColor(int color) { mGLPaint.setColor(color); invalidate(); } public void setBgColor(int color) { mGLPaint.setBackgroundColor(color); invalidate(); } public boolean isIndeterminate() { return mIndeterminate; } public void setIndeterminate(boolean indeterminate) { if (mIndeterminate != indeterminate) { mIndeterminate = indeterminate; if (indeterminate) { startAnimations(); } else { stopAnimations(); } invalidate(); } } public void setProgress(float progress) { if (!mIndeterminate) { mTrimStart = 0.0f; mTrimEnd = progress; mTrimOffset = 0.0f; mTrimRotation = 0.0f; invalidate(); } } @Override public void onRender(GLCanvas canvas) { update(); int width = getWidth(); int height = getHeight(); int cx = width / 2; int cy = height / 2; float startAngle = (mTrimStart + mTrimOffset) * 360.0f - 90; float sweepAngle = Math.max(12.0f, (mTrimEnd - mTrimStart) * 360.0f); float rotation = mTrimRotation + startAngle; canvas.save(); canvas.translate(cx, cy); canvas.rotate(rotation, 0, 0, 1); if (rotation % 180 != 0) { canvas.translate(-cy, -cx); } else { canvas.translate(-cx, -cy); } canvas.drawArc(mCx, mCy, mRadiusX, mRadiusY, sweepAngle, mGLPaint); canvas.restore(); } private void update() { boolean invalidate = false; if (mIndeterminate) { long currentTime = AnimationTime.get(); List animations = mAnimations; for (int i = 0, n = animations.size(); i < n; i++) { invalidate |= animations.get(i).calculate(currentTime); } } if (invalidate) { invalidate(); } } } ================================================ FILE: app/src/main/java/com/hippo/glview/widget/GLTextureView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.glview.widget; import android.graphics.RectF; import com.hippo.glview.glrenderer.GLCanvas; import com.hippo.glview.glrenderer.Texture; import com.hippo.glview.view.GLView; public class GLTextureView extends GLView { private final RectF mSrc = new RectF(); private final RectF mDst = new RectF(); private Texture mTexture; public Texture getTexture() { return mTexture; } public void setTexture(Texture texture) { mTexture = texture; if (texture != null) { mSrc.set(0.0f, 0.0f, texture.getWidth(), texture.getHeight()); } else { mSrc.setEmpty(); } invalidate(); } @Override protected int getSuggestedMinimumWidth() { return mTexture == null ? super.getSuggestedMinimumWidth() : mTexture.getWidth() + mPaddings.width(); } @Override protected int getSuggestedMinimumHeight() { return mTexture == null ? super.getSuggestedMinimumWidth() : mTexture.getHeight() + mPaddings.height(); } @Override public void onRender(GLCanvas canvas) { if (mTexture == null || mSrc.isEmpty()) { return; } mDst.set(mPaddings.left, mPaddings.top, getWidth() - mPaddings.right, getHeight() - mPaddings.bottom); if (mDst.isEmpty()) { return; } mTexture.draw(canvas, mSrc, mDst); } } ================================================ FILE: app/src/main/java/com/hippo/image/Image.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.image import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.Animatable import androidx.core.graphics.createBitmap import coil3.BitmapImage import coil3.DrawableImage import coil3.Image as CoilImage import coil3.asImage import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.size.Dimension import coil3.size.Precision import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.jni.isGif import com.hippo.ehviewer.jni.mmap import com.hippo.ehviewer.jni.munmap import com.hippo.ehviewer.jni.nativeTexImage import com.hippo.ehviewer.jni.rewriteGifSource import com.hippo.unifile.UniFile import com.hippo.util.isAtLeastU import java.nio.ByteBuffer class Image private constructor( private val image: CoilImage, private val src: ImageSource? = null, ) { private var mBitmap: Bitmap? = null private var mCanvas: Canvas? = null val animated get() = image is DrawableImage && image.drawable is Animatable val delay get() = if (animated) 40 else 0 val isOpaque get() = false val width get() = image.width val height get() = image.height var frameUpdateAllowed = true var isRecycled = false private set var started = false private set @Synchronized fun recycle() { if (isRecycled) return when (image) { is DrawableImage -> { (image.drawable as? Animatable)?.stop() image.drawable.callback = null src?.close() mCanvas = null mBitmap?.recycle() mBitmap = null } is BitmapImage -> image.bitmap.recycle() } isRecycled = true } private fun prepareBitmap() { if (mBitmap != null) return mBitmap = createBitmap(width, height) mCanvas = Canvas(mBitmap!!) } private fun updateBitmap() { if (frameUpdateAllowed) { frameUpdateAllowed = false prepareBitmap() mCanvas!!.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) image.draw(mCanvas!!) } } fun texImage(init: Boolean, offsetX: Int, offsetY: Int, width: Int, height: Int) { val bitmap = if (image is BitmapImage) { image.bitmap } else { updateBitmap() mBitmap!! } nativeTexImage( bitmap, init, offsetX, offsetY, width, height, ) } fun start() { if (!started) { started = true if (image is DrawableImage) (image.drawable as? Animatable)?.start() } } companion object { private val appCtx = EhApplication.application private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2 private suspend fun decodeCoil(data: Any): CoilImage { val req = ImageRequest.Builder(appCtx).apply { data(data) size(Dimension(targetWidth), Dimension.Undefined) precision(Precision.INEXACT) allowHardware(false) memoryCachePolicy(CachePolicy.DISABLED) }.build() return when (val result = appCtx.imageLoader.execute(req)) { is SuccessResult -> result.image is ErrorResult -> throw result.throwable } } suspend fun decode(src: ImageSource): Image? { return runCatching { val image = when (src) { is UniFileSource -> { if (!isAtLeastU) { src.source.openFileDescriptor("rw").use { val fd = it.fd if (isGif(fd)) { val buffer = mmap(fd)!! val source = object : ByteBufferSource { override val source = buffer override fun close() { munmap(buffer) src.close() } } return decode(source) } } } decodeCoil(src.source.uri) } is ByteBufferSource -> { if (!isAtLeastU) { rewriteGifSource(src.source) } decodeCoil(src.source) } } when (image) { is DrawableImage -> image.drawable.apply { setBounds(0, 0, intrinsicWidth, intrinsicHeight) } is BitmapImage -> src.close() } Image(image, src) }.onFailure { src.close() it.printStackTrace() }.getOrNull() } @JvmStatic fun create(bitmap: Bitmap): Image = Image(bitmap.asImage(), null) } } sealed interface ImageSource : AutoCloseable interface UniFileSource : ImageSource { val source: UniFile } interface ByteBufferSource : ImageSource { val source: ByteBuffer } ================================================ FILE: app/src/main/java/com/hippo/network/CookieDatabase.kt ================================================ /* * Copyright 2017 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.network import android.content.Context import androidx.room.AutoMigration import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Database import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.Update import okhttp3.Cookie as OkHttpCookie @Entity(tableName = "OK_HTTP_3_COOKIE") class Cookie( @ColumnInfo(name = "NAME") var name: String, @ColumnInfo(name = "VALUE") var value: String, @ColumnInfo(name = "EXPIRES_AT") var expiresAt: Long, @ColumnInfo(name = "DOMAIN") var domain: String, @ColumnInfo(name = "PATH") var path: String, @ColumnInfo(name = "SECURE") var secure: Boolean, @ColumnInfo(name = "HTTP_ONLY") var httpOnly: Boolean, @ColumnInfo(name = "PERSISTENT") var persistent: Boolean, @ColumnInfo(name = "HOST_ONLY") var hostOnly: Boolean, @PrimaryKey @ColumnInfo(name = "_id") var id: Long?, ) @Dao interface CookiesDao { @Query("SELECT * FROM OK_HTTP_3_COOKIE") fun list(): List @Delete fun delete(cookie: Cookie) @Insert fun insert(cookie: Cookie): Long @Update fun update(cookie: Cookie) } /* 1 -> 2 some nullability changes */ @Database( entities = [Cookie::class], version = 2, autoMigrations = [ AutoMigration( from = 1, to = 2, ), ], ) abstract class CookiesDatabase : RoomDatabase() { abstract fun cookiesDao(): CookiesDao } internal class CookieDatabase(context: Context, name: String) { private val cookiesList by lazy { val now = System.currentTimeMillis() db.cookiesDao().list().mapNotNull { it.takeUnless { !it.persistent || it.expiresAt <= now } ?: run { db.cookiesDao().delete(it) null } }.toMutableList() } private val db = Room.databaseBuilder(context, CookiesDatabase::class.java, name).build() val allCookies by lazy { hashMapOf().also { map -> cookiesList.map { it.toOkHttp3Cookie() }.forEach { val set = map[it.domain] ?: CookieSet().apply { map[it.domain] = this } set.add(it) } } } private fun findCookieWithOkHttpCookies(cookie: OkHttpCookie): Cookie? = cookiesList.find { it.name == cookie.name && it.domain == cookie.domain && it.value == cookie.value } fun add(cookie: OkHttpCookie) { val c = cookie.toCookie() c.id = db.cookiesDao().insert(c) cookiesList.add(c) } fun update(from: OkHttpCookie, to: OkHttpCookie) { val origin = findCookieWithOkHttpCookies(from) ?: return val new = to.toCookie(origin.id) cookiesList.remove(origin) cookiesList.add(new) db.cookiesDao().update(new) } fun remove(cookie: OkHttpCookie) { val origin = findCookieWithOkHttpCookies(cookie) ?: return db.cookiesDao().delete(origin) cookiesList.remove(origin) } fun clear() { db.clearAllTables() cookiesList.clear() } } fun Cookie.toOkHttp3Cookie(): OkHttpCookie = OkHttpCookie.Builder().apply { name(name) value(value) expiresAt(expiresAt) if (hostOnly) hostOnlyDomain(domain) else domain(domain) path(path) if (secure) secure() if (httpOnly) httpOnly() }.build() private fun OkHttpCookie.toCookie(id: Long? = null): Cookie = Cookie( name, value, expiresAt, domain, path, secure, httpOnly, persistent, hostOnly, id, ) ================================================ FILE: app/src/main/java/com/hippo/network/CookieSet.kt ================================================ /* * Copyright 2017 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.network import okhttp3.Cookie import okhttp3.HttpUrl internal class CookieSet { private val map: MutableMap = HashMap() /** * Adds a cookie to this `CookieSet`. * Returns a previous cookie with * the same name, domain and path or `null`. */ fun add(cookie: Cookie): Cookie? = map.put(Key(cookie), cookie) /** * Removes a cookie with the same name, * domain and path as the cookie. * Returns the removed cookie or `null`. */ fun remove(cookie: Cookie): Cookie? = map.remove(Key(cookie)) /** * Get cookies for the url. Fill `accepted` and `expired`. */ operator fun get(url: HttpUrl, accepted: MutableList, expired: MutableList) { val now = System.currentTimeMillis() val iterator = map.entries.iterator() while (iterator.hasNext()) { val cookie = iterator.next().value if (cookie.expiresAt <= now) { iterator.remove() expired.add(cookie) } else if (cookie.matches(url)) { accepted.add(cookie) } } } fun get(name: String, domain: String, path: String) = map[Key(name, domain, path)] internal data class Key( val name: String, val domain: String, val path: String, ) { constructor(cookie: Cookie) : this(cookie.name, cookie.domain, cookie.path) } } ================================================ FILE: app/src/main/java/com/hippo/network/InetValidator.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.network import java.util.regex.Pattern object InetValidator { private const val IPV4_REGEX = "^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$" private val IPV4_PATTERN = Pattern.compile(IPV4_REGEX) fun isValidInet4Address(inet4Address: String?): Boolean { if (null == inet4Address) { return false } val matcher = IPV4_PATTERN.matcher(inet4Address) if (!matcher.find()) { return false } // verify that address subgroups are legal for (i in 1..4) { val ipSegment = matcher.group(i) if (ipSegment == null || ipSegment.isEmpty()) { return false } val iIpSegment: Int = try { ipSegment.toInt() } catch (_: NumberFormatException) { return false } if (iIpSegment > 255) { return false } if (ipSegment.length > 1 && ipSegment.startsWith("0")) { return false } } return true } fun isValidInetPort(inetPort: Int): Boolean = inetPort in 0..65535 } ================================================ FILE: app/src/main/java/com/hippo/network/StatusCodeException.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.network import android.util.SparseArray import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.R class StatusCodeException(val responseCode: Int) : Exception() { override val message: String = ERROR_MESSAGE_ARRAY[responseCode, DEFAULT_ERROR_MESSAGE] val isIdentifiedResponseCode: Boolean get() = DEFAULT_ERROR_MESSAGE != message override fun getLocalizedMessage(): String = message companion object { private val ERROR_MESSAGE_ARRAY = SparseArray(24) private const val DEFAULT_ERROR_MESSAGE = "Error response code" init { val resources = EhApplication.application.resources ERROR_MESSAGE_ARRAY.append(400, resources.getString(R.string.error_status_code_400)) ERROR_MESSAGE_ARRAY.append(401, resources.getString(R.string.error_status_code_401)) ERROR_MESSAGE_ARRAY.append(402, resources.getString(R.string.error_status_code_402)) ERROR_MESSAGE_ARRAY.append(403, resources.getString(R.string.error_status_code_403)) ERROR_MESSAGE_ARRAY.append(404, resources.getString(R.string.error_status_code_404)) ERROR_MESSAGE_ARRAY.append(405, resources.getString(R.string.error_status_code_405)) ERROR_MESSAGE_ARRAY.append(406, resources.getString(R.string.error_status_code_406)) ERROR_MESSAGE_ARRAY.append(407, resources.getString(R.string.error_status_code_407)) ERROR_MESSAGE_ARRAY.append(408, resources.getString(R.string.error_status_code_408)) ERROR_MESSAGE_ARRAY.append(409, resources.getString(R.string.error_status_code_409)) ERROR_MESSAGE_ARRAY.append(410, resources.getString(R.string.error_status_code_410)) ERROR_MESSAGE_ARRAY.append(411, resources.getString(R.string.error_status_code_411)) ERROR_MESSAGE_ARRAY.append(412, resources.getString(R.string.error_status_code_412)) ERROR_MESSAGE_ARRAY.append(413, resources.getString(R.string.error_status_code_413)) ERROR_MESSAGE_ARRAY.append(414, resources.getString(R.string.error_status_code_414)) ERROR_MESSAGE_ARRAY.append(415, resources.getString(R.string.error_status_code_415)) ERROR_MESSAGE_ARRAY.append(416, resources.getString(R.string.error_status_code_416)) ERROR_MESSAGE_ARRAY.append(417, resources.getString(R.string.error_status_code_417)) ERROR_MESSAGE_ARRAY.append(500, resources.getString(R.string.error_status_code_500)) ERROR_MESSAGE_ARRAY.append(501, resources.getString(R.string.error_status_code_501)) ERROR_MESSAGE_ARRAY.append(502, resources.getString(R.string.error_status_code_502)) ERROR_MESSAGE_ARRAY.append(503, resources.getString(R.string.error_status_code_503)) ERROR_MESSAGE_ARRAY.append(504, resources.getString(R.string.error_status_code_504)) ERROR_MESSAGE_ARRAY.append(505, resources.getString(R.string.error_status_code_505)) } } } ================================================ FILE: app/src/main/java/com/hippo/network/UrlBuilder.kt ================================================ /* * Copyright (C) 2014-2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.network class UrlBuilder(private var mRootUrl: String) { private var mQueryMap: MutableMap = HashMap() fun addQuery(key: String, value: Any) { mQueryMap[key] = value } fun build(): String = if (mQueryMap.isEmpty()) { mRootUrl } else { val sb = StringBuilder(mRootUrl) sb.append("?") val iter: Iterator = mQueryMap.keys.iterator() if (iter.hasNext()) { val key = iter.next() val value = mQueryMap[key] sb.append(key).append("=").append(value) } while (iter.hasNext()) { val key = iter.next() val value = mQueryMap[key] sb.append("&").append(key).append("=").append(value) } sb.toString() } } ================================================ FILE: app/src/main/java/com/hippo/okhttp/ChromeRequestBuilder.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.okhttp import com.hippo.ehviewer.Settings import com.hippo.ehviewer.util.WebViewVersion import okhttp3.Request val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$WebViewVersion.0.0.0 Mobile Safari/537.36" private const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.9" open class ChromeRequestBuilder(url: String) : Request.Builder() { init { this.url(url) this.addHeader("User-Agent", Settings.userAgent!!) this.addHeader("Accept", CHROME_ACCEPT) this.addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE) } } ================================================ FILE: app/src/main/java/com/hippo/preference/DialogPreference.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.preference import android.app.Dialog import android.content.Context import android.content.DialogInterface import android.content.SharedPreferences import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.preference.Preference import com.hippo.ehviewer.R /** * A base class for [Preference] objects that are * dialog-based. These preferences will, when clicked, open a dialog showing the * actual preference controls. */ abstract class DialogPreference( context: Context, attrs: AttributeSet? = null, ) : Preference( context, attrs, ), DialogInterface.OnClickListener, DialogInterface.OnDismissListener { private var mBuilder: AlertDialog.Builder? = null private var mDialogTitle: CharSequence? = null private var mDialogIcon: Drawable? = null private var mPositiveButtonText: CharSequence? = null private var mNegativeButtonText: CharSequence? = null private var mDialogLayoutResId: Int = 0 /** * The dialog, if it is showing. */ private var mDialog: AlertDialog? = null /** * Which button was clicked. */ private var mWhichButtonClicked = 0 init { context.withStyledAttributes(attrs, R.styleable.DialogPreference, 0, 0) { mDialogTitle = getString(R.styleable.DialogPreference_dialogTitle) if (mDialogTitle == null) { // Fallback on the regular title of the preference // (the one that is seen in the list) mDialogTitle = title } mDialogIcon = getDrawable(R.styleable.DialogPreference_dialogIcon) mPositiveButtonText = getString(R.styleable.DialogPreference_positiveButtonText) mNegativeButtonText = getString(R.styleable.DialogPreference_negativeButtonText) mDialogLayoutResId = getResourceId(R.styleable.DialogPreference_dialogLayout, mDialogLayoutResId) } } /** * Returns the title to be shown on subsequent dialogs. * @return The title. * * Sets the title of the dialog. This will be shown on subsequent dialogs. * @param dialogTitle The title. */ var dialogTitle: CharSequence? get() = mDialogTitle set(dialogTitle) { mDialogTitle = dialogTitle } /** * Returns the icon to be shown on subsequent dialogs. * @return The icon, as a [Drawable]. * * Sets the icon of the dialog. This will be shown on subsequent dialogs. * @param dialogIcon The icon, as a [Drawable]. */ var dialogIcon: Drawable? get() = mDialogIcon set(dialogIcon) { mDialogIcon = dialogIcon } /** * Returns the text of the negative button to be shown on subsequent * dialogs. * @return The text of the positive button. * * Sets the text of the negative button of the dialog. This will be shown on * subsequent dialogs. * @param positiveButtonText The text of the negative button. */ var positiveButtonText: CharSequence? get() = mPositiveButtonText set(positiveButtonText) { mPositiveButtonText = positiveButtonText } /** * Returns the text of the negative button to be shown on subsequent * dialogs. * @return The text of the negative button. * * Sets the text of the negative button of the dialog. This will be shown on * subsequent dialogs. * @param negativeButtonText The text of the negative button. */ var negativeButtonText: CharSequence? get() = mNegativeButtonText set(negativeButtonText) { mNegativeButtonText = negativeButtonText } /** * Returns the layout resource that is used as the content View for * subsequent dialogs. * @return The layout resource. * * Sets the layout resource that is inflated as the [View] to be shown * as the content View of subsequent dialogs. * @param dialogLayoutResource The layout resource ID to be inflated. */ var dialogLayoutResource: Int get() = mDialogLayoutResId set(dialogLayoutResource) { mDialogLayoutResId = dialogLayoutResource } /** * @param dialogTitleResId The dialog title as a resource. * @see .setDialogTitle */ fun setDialogTitle(dialogTitleResId: Int) { mDialogTitle = context.getString(dialogTitleResId) } /** * Sets the icon (resource ID) of the dialog. This will be shown on * subsequent dialogs. * * @param dialogIconRes The icon, as a resource ID. */ fun setDialogIcon(@DrawableRes dialogIconRes: Int) { mDialogIcon = ContextCompat.getDrawable(context, dialogIconRes) } /** * @param positiveButtonTextResId The positive button text as a resource. * @see .setPositiveButtonText */ fun setPositiveButtonText(@StringRes positiveButtonTextResId: Int) { mPositiveButtonText = context.getString(positiveButtonTextResId) } /** * @param negativeButtonTextResId The negative button text as a resource. * @see .setNegativeButtonText */ fun setNegativeButtonText(@StringRes negativeButtonTextResId: Int) { mNegativeButtonText = context.getString(negativeButtonTextResId) } /** * Prepares the dialog builder to be shown when the preference is clicked. * Use this to set custom properties on the dialog. * * Do not [AlertDialog.Builder.create] or * [AlertDialog.Builder.show]. */ protected open fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {} override fun onClick() { if (mDialog != null && mDialog!!.isShowing) return showDialog(null) } /** * Shows the dialog associated with this Preference. This is normally initiated * automatically on clicking on the preference. Call this method if you need to * show the dialog on some other event. * * @param state Optional instance state to restore on the dialog */ protected open fun showDialog(state: Bundle?) { val context = context mWhichButtonClicked = DialogInterface.BUTTON_NEGATIVE mBuilder = AlertDialog.Builder(context) .setTitle(mDialogTitle) .setIcon(mDialogIcon) .setPositiveButton(mPositiveButtonText, this) .setNegativeButton(mNegativeButtonText, this) onCreateDialogView()?.let { view -> view.parent?.let { (it as ViewGroup).removeView(view) } onBindDialogView(view) mBuilder!!.setView(view) } onPrepareDialogBuilder(mBuilder!!) // PreferenceUtils.registerOnActivityDestroyListener(this, this); // Create the dialog mDialog = mBuilder!!.create() val dialog = mDialog!! state?.let { dialog.onRestoreInstanceState(it) } if (needInputMethod) { requestInputMethod(dialog) } dialog.setOnDismissListener(this) dialog.show() onDialogCreated(dialog) } /** * Returns whether the preference needs to display a soft input method when the dialog * is displayed. Default is false. Subclasses should override this method if they need * the soft input method brought up automatically. */ open val needInputMethod: Boolean = false /** * Sets the required flags on the dialog window to enable input method window to show up. */ private fun requestInputMethod(dialog: Dialog) { dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) } /** * Creates the content view for the dialog (if a custom content view is * required). By default, it inflates the dialog layout resource if it is * set. * * @return The content View for the dialog. * @see .setLayoutResource */ protected open fun onCreateDialogView(): View? { if (mDialogLayoutResId == 0) return null val inflater = LayoutInflater.from(mBuilder!!.context) return inflater.inflate(mDialogLayoutResId, null) } /** * Binds views in the content View of the dialog to data. * * @param view The content View of the dialog, if it is custom. */ protected open fun onBindDialogView(view: View) {} protected open fun onDialogCreated(dialog: AlertDialog) {} override fun onClick(dialog: DialogInterface, which: Int) { mWhichButtonClicked = which } override fun onDismiss(dialog: DialogInterface) { mDialog = null onDialogClosed(mWhichButtonClicked == DialogInterface.BUTTON_POSITIVE) } /** * Called when the dialog is dismissed and should be used to save data to * the [SharedPreferences]. * * @param positiveResult Whether the positive button was clicked (true), or * the negative button was clicked or the dialog was canceled (false). */ protected open fun onDialogClosed(positiveResult: Boolean) {} /** * Gets the dialog that is shown by this preference. * * @return The dialog, or null if a dialog is not being shown. */ val dialog: Dialog? get() = mDialog override fun onSaveInstanceState(): Parcelable? { val superState = super.onSaveInstanceState() if (mDialog == null || !mDialog!!.isShowing) { return superState } val myState = SavedState(superState) myState.isDialogShowing = true myState.dialogBundle = mDialog!!.onSaveInstanceState() return myState } override fun onRestoreInstanceState(state: Parcelable?) { if (state == null || state.javaClass != SavedState::class.java) { // Didn't save state for us in onSaveInstanceState super.onRestoreInstanceState(state) return } val myState = state as SavedState super.onRestoreInstanceState(myState.superState) if (myState.isDialogShowing) { showDialog(myState.dialogBundle) } } private class SavedState : BaseSavedState { var isDialogShowing: Boolean = false var dialogBundle: Bundle? = null constructor(source: Parcel) : super(source) { isDialogShowing = source.readInt() == 1 dialogBundle = source.readBundle(DialogPreference::class.java.classLoader) } constructor(superState: Parcelable?) : super(superState) override fun writeToParcel(dest: Parcel, flags: Int) { super.writeToParcel(dest, flags) dest.writeInt(if (isDialogShowing) 1 else 0) dest.writeBundle(dialogBundle) } companion object CREATOR : Parcelable.Creator { override fun createFromParcel(`in`: Parcel): SavedState = SavedState(`in`) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } ================================================ FILE: app/src/main/java/com/hippo/preference/UrlPreference.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.preference import android.content.Context import android.util.AttributeSet import androidx.core.content.withStyledAttributes import androidx.preference.Preference import com.hippo.ehviewer.R import com.hippo.ehviewer.UrlOpener class UrlPreference( context: Context, attrs: AttributeSet? = null, ) : Preference(context, attrs) { private var mUrl: String? = null init { context.withStyledAttributes(attrs, R.styleable.UrlPreference, 0, 0) { mUrl = getString(R.styleable.UrlPreference_url) } } override fun getSummary(): CharSequence? = mUrl ?: super.getSummary() override fun onClick() { UrlOpener.openUrl(context, mUrl, true) } } ================================================ FILE: app/src/main/java/com/hippo/scene/Announcer.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.scene import android.os.Bundle class Announcer(var clazz: Class<*>) { var args: Bundle? = null var tranHelper: TransitionHelper? = null var requestFrom: SceneFragment? = null var requestCode = 0 fun setArgs(args: Bundle?): Announcer { this.args = args return this } fun setTranHelper(tranHelper: TransitionHelper?): Announcer { this.tranHelper = tranHelper return this } fun setRequestCode(requestFrom: SceneFragment?, requestCode: Int): Announcer { this.requestFrom = requestFrom this.requestCode = requestCode return this } } ================================================ FILE: app/src/main/java/com/hippo/scene/SceneApplication.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.scene import android.app.Application import android.util.SparseArray import com.hippo.yorozuya.IntIdGenerator abstract class SceneApplication : Application() { private val mIdGenerator = IntIdGenerator() private val mStageMap = SparseArray() fun registerStageActivity(stage: StageActivity) { val id = mIdGenerator.nextId() mStageMap.put(id, stage) stage.onRegister(id) } fun registerStageActivity(stage: StageActivity, id: Int) { check(mStageMap.indexOfKey(id) < 0) { "The id exists: $id" } mStageMap.put(id, stage) stage.onRegister(id) } fun unregisterStageActivity(id: Int) { val index = mStageMap.indexOfKey(id) if (index >= 0) { val stage = mStageMap.valueAt(index) mStageMap.remove(id) stage.onUnregister() } } fun findStageActivityById(id: Int): StageActivity = mStageMap[id] } ================================================ FILE: app/src/main/java/com/hippo/scene/SceneFragment.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.scene import android.app.assist.AssistContent import android.os.Bundle import android.view.View import androidx.annotation.IntDef import androidx.fragment.app.Fragment import com.hippo.ehviewer.R import com.hippo.yorozuya.collect.IntList import kotlin.math.min import rikka.core.res.resolveDrawable open class SceneFragment : Fragment() { var result: Bundle? = null private var resultCode = RESULT_CANCELED private var mRequestSceneTagList: MutableList = ArrayList(0) private var mRequestCodeList = IntList() open fun onNewArguments(args: Bundle) {} fun startScene(announcer: Announcer, horizontal: Boolean) { val activity = activity if (activity is StageActivity) { activity.startScene(announcer, horizontal) } } fun startScene(announcer: Announcer) { val activity = activity if (activity is StageActivity) { activity.startScene(announcer) } } fun finish(transitionHelper: TransitionHelper? = null) { val activity = activity if (activity is StageActivity) { activity.finishScene(this, transitionHelper) } } fun finishStage() { val activity = activity activity?.finish() } /** * @return negative for error */ val stackIndex: Int get() { val activity = activity return if (activity is StageActivity) { activity.getSceneIndex(this) } else { -1 } } open fun onBackPressed() { finish() } open fun onProvideAssistContent(outContent: AssistContent) {} override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) view.setTag(R.id.fragment_tag, tag) view.background = requireActivity().getTheme().resolveDrawable(android.R.attr.windowBackground) // Notify val activity = activity if (activity is StageActivity) { activity.onSceneViewCreated(this, savedInstanceState) } } override fun onDestroyView() { super.onDestroyView() // Notify val activity = activity if (activity is StageActivity) { activity.onSceneViewDestroyed(this) } } override fun onDestroy() { super.onDestroy() val activity = activity if (activity is StageActivity) { activity.onSceneDestroyed(this) } } fun addRequest(requestSceneTag: String, requestCode: Int) { mRequestSceneTagList.add(requestSceneTag) mRequestCodeList.add(requestCode) } fun returnResult(stage: StageActivity) { for (i in 0 until min(mRequestSceneTagList.size.toDouble(), mRequestCodeList.size.toDouble()).toInt()) { val tag = mRequestSceneTagList[i] val code = mRequestCodeList[i] val scene = stage.findSceneByTag(tag) scene?.onSceneResult(code, resultCode, result) } mRequestSceneTagList.clear() mRequestCodeList.clear() } protected open fun onSceneResult(requestCode: Int, resultCode: Int, data: Bundle?) {} fun setResult(resultCode: Int, result: Bundle?) { this.resultCode = resultCode this.result = result } @IntDef(LAUNCH_MODE_STANDARD, LAUNCH_MODE_SINGLE_TOP, LAUNCH_MODE_SINGLE_TASK) @Retention( AnnotationRetention.SOURCE, ) annotation class LaunchMode companion object { const val LAUNCH_MODE_STANDARD = 0 const val LAUNCH_MODE_SINGLE_TOP = 1 const val LAUNCH_MODE_SINGLE_TASK = 2 /** * Standard scene result: operation canceled. */ const val RESULT_CANCELED = 0 /** * Standard scene result: operation succeeded. */ const val RESULT_OK = -1 } } ================================================ FILE: app/src/main/java/com/hippo/scene/StageActivity.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.scene import android.annotation.SuppressLint import android.app.assist.AssistContent import android.content.Intent import android.os.Bundle import android.util.Log import android.view.View import androidx.fragment.app.Fragment import com.hippo.ehviewer.R import com.hippo.ehviewer.ui.EhActivity import com.hippo.scene.SceneFragment.LaunchMode import com.hippo.yorozuya.AssertUtils import com.hippo.yorozuya.IntIdGenerator import java.util.concurrent.atomic.AtomicInteger abstract class StageActivity : EhActivity() { private val mSceneTagList = ArrayList() private val mDelaySceneTagList = ArrayList() private val mIdGenerator = AtomicInteger() private val mSceneViewComparator = SceneViewComparator() private var stageId = IntIdGenerator.INVALID_ID abstract val containerViewId: Int /** * @return `true` for start scene */ private fun startSceneFromIntent(intent: Intent): Boolean { val clazzStr = intent.getStringExtra(KEY_SCENE_NAME) ?: return false val clazz: Class<*> = try { Class.forName(clazzStr) } catch (e: ClassNotFoundException) { Log.e(TAG, "Can't find class $clazzStr", e) return false } val args = intent.getBundleExtra(KEY_SCENE_ARGS) val announcer = onStartSceneFromIntent(clazz, args) ?: return false startScene(announcer) return true } /** * Start scene from `Intent`, it might be not safe, * Correct it here. * * @return `null` for do not start scene */ protected open fun onStartSceneFromIntent(clazz: Class<*>, args: Bundle?): Announcer? = Announcer(clazz).setArgs(args) override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (ACTION_START_SCENE != intent.action || !startSceneFromIntent(intent)) { onUnrecognizedIntent(intent) } } protected abstract val launchAnnouncer: Announcer? /** * Can't recognize intent in first time `onCreate` and `onNewIntent`, * null included. */ protected open fun onUnrecognizedIntent(intent: Intent?) {} /** * Call `setContentView` here. Do **NOT** call `startScene` here */ protected abstract fun onCreate2(savedInstanceState: Bundle?) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { stageId = savedInstanceState.getInt(KEY_STAGE_ID, IntIdGenerator.INVALID_ID) val list = savedInstanceState.getStringArrayList(KEY_SCENE_TAG_LIST) if (list != null) { mSceneTagList.addAll(list) mDelaySceneTagList.addAll(list) } mIdGenerator.lazySet(savedInstanceState.getInt(KEY_NEXT_ID)) } if (stageId == IntIdGenerator.INVALID_ID) { (applicationContext as SceneApplication).registerStageActivity(this) } else { (applicationContext as SceneApplication).registerStageActivity(this, stageId) } // Create layout onCreate2(savedInstanceState) val intent = intent if (savedInstanceState == null) { if (intent != null) { val action = intent.action if (Intent.ACTION_MAIN == action) { val announcer = launchAnnouncer if (announcer != null) { startScene(announcer) return } } else if (ACTION_START_SCENE == action) { if (startSceneFromIntent(intent)) { return } } } // Can't recognize intent onUnrecognizedIntent(intent) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt(KEY_STAGE_ID, stageId) outState.putStringArrayList(KEY_SCENE_TAG_LIST, mSceneTagList) outState.putInt(KEY_NEXT_ID, mIdGenerator.getAndIncrement()) } override fun onDestroy() { super.onDestroy() (applicationContext as SceneApplication).unregisterStageActivity(stageId) } open fun onSceneViewCreated(scene: SceneFragment, savedInstanceState: Bundle?) {} open fun onSceneViewDestroyed(scene: SceneFragment) {} fun onSceneDestroyed(scene: SceneFragment) { mDelaySceneTagList.remove(scene.tag) } internal fun onRegister(id: Int) { stageId = id } internal fun onUnregister() {} protected open fun onTransactScene() {} val sceneCount: Int get() = mSceneTagList.size private fun getSceneLaunchMode(clazz: Class<*>): Int { val integer = sLaunchModeMap[clazz] return integer ?: throw RuntimeException("Not register " + clazz.getName()) } private fun newSceneInstance(clazz: Class<*>): SceneFragment = try { clazz.getDeclaredConstructor().newInstance() as SceneFragment } catch (e: InstantiationException) { throw IllegalStateException("Can't instance " + clazz.getName(), e) } catch (e: IllegalAccessException) { throw IllegalStateException( "The constructor of " + clazz.getName() + " is not visible", e, ) } catch (e: ClassCastException) { throw IllegalStateException(clazz.getName() + " can not cast to scene", e) } fun startScene(announcer: Announcer, horizontal: Boolean = false) { val clazz = announcer.clazz val args = announcer.args val tranHelper = announcer.tranHelper val fragmentManager = supportFragmentManager val launchMode = getSceneLaunchMode(clazz) // Check LAUNCH_MODE_SINGLE_TASK if (launchMode == SceneFragment.LAUNCH_MODE_SINGLE_TASK) { var i = 0 val n = mSceneTagList.size while (i < n) { val tag = mSceneTagList[i] val fragment = fragmentManager.findFragmentByTag(tag) if (fragment == null) { Log.e(TAG, "Can't find fragment with tag: $tag") i++ continue } if (clazz.isInstance(fragment)) { // Get it val transaction = fragmentManager.beginTransaction() // Use default animation if (horizontal) { transaction.setCustomAnimations( R.anim.scene_open_enter_horizontal, R.anim.scene_open_exit, ) } else { transaction.setCustomAnimations( R.anim.scene_open_enter, R.anim.scene_open_exit, ) } // Remove top fragments for (j in i + 1 until n) { val topTag = mSceneTagList[j] val topFragment = fragmentManager.findFragmentByTag(topTag) if (null == topFragment) { Log.e(TAG, "Can't find fragment with tag: $topTag") continue } // Clear shared element topFragment.sharedElementEnterTransition = null topFragment.sharedElementReturnTransition = null topFragment.enterTransition = null topFragment.exitTransition = null // Remove it transaction.remove(topFragment) } // Remove tag from index i+1 mSceneTagList.subList(i + 1, mSceneTagList.size).clear() mDelaySceneTagList.subList(i + 1, mDelaySceneTagList.size).clear() // Attach fragment if (fragment.isDetached) { transaction.attach(fragment) } // Commit transaction.commitAllowingStateLoss() onTransactScene() // New arguments if (args != null && fragment is SceneFragment) { // TODO Call onNewArguments when view created ? fragment.onNewArguments(args) } return } i++ } } // Get current fragment var currentScene: SceneFragment? = null if (mSceneTagList.isNotEmpty()) { // Get last tag val tag = mSceneTagList[mSceneTagList.size - 1] val fragment = fragmentManager.findFragmentByTag(tag) if (fragment != null) { AssertUtils.assertTrue(fragment is SceneFragment) currentScene = fragment as SceneFragment? } } // Check LAUNCH_MODE_SINGLE_TASK if (clazz.isInstance(currentScene) && launchMode == SceneFragment.LAUNCH_MODE_SINGLE_TOP) { if (args != null) { currentScene!!.onNewArguments(args) } return } // Create new scene val newScene = newSceneInstance(clazz) newScene.setArguments(args) // Create new scene tag val newTag = mIdGenerator.getAndIncrement().toString() // Add new tag to list mSceneTagList.add(newTag) mDelaySceneTagList.add(newTag) val transaction = fragmentManager.beginTransaction() // Animation if (currentScene != null) { if (tranHelper == null || !tranHelper.onTransition( this, transaction, currentScene, newScene, ) ) { // Clear shared item currentScene.sharedElementEnterTransition = null currentScene.sharedElementReturnTransition = null currentScene.enterTransition = null currentScene.exitTransition = null newScene.sharedElementEnterTransition = null newScene.sharedElementReturnTransition = null newScene.enterTransition = null newScene.exitTransition = null // Set default animation if (horizontal) { transaction.setCustomAnimations( R.anim.scene_open_enter_horizontal, R.anim.scene_open_exit, ) } else { transaction.setCustomAnimations(R.anim.scene_open_enter, R.anim.scene_open_exit) } } // Detach current scene if (!currentScene.isDetached) { transaction.detach(currentScene) } else { Log.e(TAG, "Current scene is detached") } } // Add new scene transaction.add(containerViewId, newScene, newTag) // Commit transaction.commitAllowingStateLoss() onTransactScene() // Check request if (announcer.requestFrom != null && announcer.requestFrom!!.tag != null) { newScene.addRequest(announcer.requestFrom!!.tag!!, announcer.requestCode) } } fun startSceneFirstly(announcer: Announcer) { val clazz = announcer.clazz val args = announcer.args val fragmentManager = supportFragmentManager val launchMode = getSceneLaunchMode(clazz) val forceNewScene = launchMode == SceneFragment.LAUNCH_MODE_STANDARD var createNewScene = true var findScene = false var scene: SceneFragment? = null val transaction = fragmentManager.beginTransaction() // Set default animation transaction.setCustomAnimations(R.anim.scene_open_enter, R.anim.scene_open_exit) var findSceneTag: String? = null var i = 0 val n = mSceneTagList.size while (i < n) { val tag = mSceneTagList[i] val fragment = fragmentManager.findFragmentByTag(tag) if (fragment == null) { Log.e(TAG, "Can't find fragment with tag: $tag") i++ continue } // Clear shared element fragment.sharedElementEnterTransition = null fragment.sharedElementReturnTransition = null fragment.enterTransition = null fragment.exitTransition = null // Check is target scene if (!forceNewScene && !findScene && clazz.isInstance(fragment) && (launchMode == SceneFragment.LAUNCH_MODE_SINGLE_TASK || !fragment.isDetached) ) { scene = fragment as SceneFragment? findScene = true createNewScene = false findSceneTag = tag if (fragment.isDetached) { transaction.attach(fragment) } } else { // Remove it transaction.remove(fragment) } i++ } // Handle tag list mSceneTagList.clear() if (null != findSceneTag) { mSceneTagList.add(findSceneTag) } if (createNewScene) { scene = newSceneInstance(clazz) scene.setArguments(args) // Create scene tag val tag = mIdGenerator.getAndIncrement().toString() // Add tag to list mSceneTagList.add(tag) mDelaySceneTagList.add(tag) // Add scene transaction.add(containerViewId, scene, tag) } // Commit transaction.commitAllowingStateLoss() onTransactScene() if (!createNewScene && args != null) { // TODO Call onNewArguments when view created ? scene!!.onNewArguments(args) } } fun getSceneIndex(scene: SceneFragment): Int = getTagIndex(scene.tag) private fun getTagIndex(tag: String?): Int = mSceneTagList.indexOf(tag) fun sortSceneViews(views: ArrayList) { views.sortWith(mSceneViewComparator) } fun finishScene(scene: SceneFragment, transitionHelper: TransitionHelper? = null) { finishScene(scene.tag, transitionHelper) } private fun finishScene(tag: String?, transitionHelper: TransitionHelper?) { val fragmentManager = supportFragmentManager // Get scene val scene = fragmentManager.findFragmentByTag(tag) if (scene == null) { Log.e(TAG, "finishScene: Can't find scene by tag: $tag") return } // Get scene index val index = mSceneTagList.indexOf(tag) if (index < 0) { Log.e(TAG, "finishScene: Can't find the tag in tag list: $tag") return } if (mSceneTagList.size == 1) { // It is the last fragment, finish Activity now Log.i(TAG, "finishScene: It is the last scene, finish activity now") finish() return } var next: Fragment? = null if (index == mSceneTagList.size - 1) { // It is first fragment, show the next one next = fragmentManager.findFragmentByTag(mSceneTagList[index - 1]) } val transaction = fragmentManager.beginTransaction() if (next != null) { if (transitionHelper == null || !transitionHelper.onTransition( this, transaction, scene, next, ) ) { // Clear shared item scene.sharedElementEnterTransition = null scene.sharedElementReturnTransition = null scene.enterTransition = null scene.exitTransition = null next.sharedElementEnterTransition = null next.sharedElementReturnTransition = null next.enterTransition = null next.exitTransition = null // Do not show animate if it is not the first fragment transaction.setCustomAnimations(0, R.anim.scene_close_exit) } // Attach fragment transaction.attach(next) } transaction.remove(scene) transaction.commitAllowingStateLoss() onTransactScene() // Remove tag mSceneTagList.removeAt(index) // Return result if (scene is SceneFragment) { scene.returnResult(this) } } fun refreshTopScene() { val index = mSceneTagList.size - 1 if (index < 0) { return } val tag = mSceneTagList[index] val fragmentManager = supportFragmentManager val fragment = fragmentManager.findFragmentByTag(tag) ?: return fragmentManager.beginTransaction().detach(fragment).commitAllowingStateLoss() fragmentManager.beginTransaction().attach(fragment).commitAllowingStateLoss() } @Deprecated("Deprecated in Java") @SuppressLint("MissingSuperCall") override fun onBackPressed() { val size = mSceneTagList.size val tag = mSceneTagList[size - 1] val scene: SceneFragment val fragment = supportFragmentManager.findFragmentByTag(tag) if (fragment == null) { Log.e(TAG, "onBackPressed: Can't find scene by tag: $tag") return } if (fragment !is SceneFragment) { Log.e(TAG, "onBackPressed: The fragment is not SceneFragment") return } scene = fragment scene.onBackPressed() } override fun onProvideAssistContent(outContent: AssistContent) { super.onProvideAssistContent(outContent) val size = mSceneTagList.size val tag = mSceneTagList[size - 1] val fragment = supportFragmentManager.findFragmentByTag(tag) if (fragment == null) { Log.e(TAG, "onProvideAssistContent: Can't find scene by tag: $tag") return } (fragment as? SceneFragment)?.onProvideAssistContent(outContent) ?: Log.e( TAG, "onProvideAssistContent: The fragment is not SceneFragment", ) } fun findSceneByTag(tag: String?): SceneFragment? { val fragmentManager = supportFragmentManager val fragment = fragmentManager.findFragmentByTag(tag) return if (fragment != null) { fragment as SceneFragment? } else { null } } val topSceneClass: Class<*>? get() { val index = mSceneTagList.size - 1 if (index < 0) { return null } val tag = mSceneTagList[index] val fragment = supportFragmentManager.findFragmentByTag(tag) ?: return null return fragment.javaClass } private inner class SceneViewComparator : Comparator { private fun getIndex(view: View): Int { val o = view.getTag(R.id.fragment_tag) return if (o is String) { mDelaySceneTagList.indexOf(o) } else { -1 } } override fun compare(lhs: View, rhs: View): Int = getIndex(lhs) - getIndex(rhs) } companion object { const val ACTION_START_SCENE = "start_scene" const val KEY_SCENE_NAME = "stage_activity_scene_name" const val KEY_SCENE_ARGS = "stage_activity_scene_args" private val TAG = StageActivity::class.java.getSimpleName() private const val KEY_STAGE_ID = "stage_activity_stage_id" private const val KEY_SCENE_TAG_LIST = "stage_activity_scene_tag_list" private const val KEY_NEXT_ID = "stage_activity_next_id" private val sLaunchModeMap: MutableMap, Int> = HashMap() fun registerLaunchMode(clazz: Class<*>, @LaunchMode launchMode: Int) { check(!(launchMode != SceneFragment.LAUNCH_MODE_STANDARD && launchMode != SceneFragment.LAUNCH_MODE_SINGLE_TOP && launchMode != SceneFragment.LAUNCH_MODE_SINGLE_TASK)) { "Invalid launch mode: $launchMode" } sLaunchModeMap[clazz] = launchMode } } } ================================================ FILE: app/src/main/java/com/hippo/scene/StageLayout.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.scene import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.util.AttributeSet import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import com.hippo.ehviewer.R import java.lang.reflect.Field open class StageLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : FrameLayout( context, attrs, defStyleAttr, ) { private var mDisappearingChildrenField: Field? = null private var mSuperDisappearingChildren: ArrayList? = null private var mSortedScenes: ArrayList? = null private var mDumpView: View? = null private var mDoTrick = false @SuppressLint("DiscouragedPrivateApi") private fun init(context: Context) { try { mDisappearingChildrenField = ViewGroup::class.java.getDeclaredField("mDisappearingChildren") mDisappearingChildrenField!!.isAccessible = true } catch (e: NoSuchFieldException) { e.printStackTrace() } if (mDisappearingChildrenField != null) { mDumpView = View(context) addView(mDumpView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) } } private val superDisappearingChildren: Unit get() { if (mDisappearingChildrenField == null || mSuperDisappearingChildren != null) { return } try { @Suppress("UNCHECKED_CAST") mSuperDisappearingChildren = mDisappearingChildrenField!![this] as ArrayList? } catch (e: IllegalAccessException) { e.printStackTrace() } } private fun beforeDispatchDraw(): Boolean { superDisappearingChildren if (mSuperDisappearingChildren == null || mSuperDisappearingChildren!!.isEmpty() || childCount <= 1) { // only dump view return false } // Get stage val stage: StageActivity? val context = context if (context is StageActivity) { stage = context } else { return false } if (null == mSortedScenes) { mSortedScenes = ArrayList() } // Add all scene view to mSortedScenes val disappearingChildren: ArrayList = mSuperDisappearingChildren!! val sortedScenes = mSortedScenes!! run { for (i in 1 until childCount) { // Skip dump view val view = getChildAt(i) if (null != view.getTag(R.id.fragment_tag)) { sortedScenes.add(view) } } } for (i in 0 until disappearingChildren.size) { val view = disappearingChildren[i] if (null != view.getTag(R.id.fragment_tag)) { sortedScenes.add(view) } } stage.sortSceneViews(sortedScenes) return true } private fun afterDispatchDraw() { mSortedScenes?.clear() } override fun dispatchDraw(canvas: Canvas) { mDoTrick = beforeDispatchDraw() super.dispatchDraw(canvas) afterDispatchDraw() mDoTrick = false } override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean { if (mDoTrick) { val sortedScenes = mSortedScenes if (child === mDumpView) { var more = false for (i in 0 until sortedScenes!!.size) { more = more or super.drawChild(canvas, sortedScenes[i], drawingTime) } return more } else if (sortedScenes!!.contains(child)) { // Skip return false } } return super.drawChild(canvas, child, drawingTime) } init { init(context) } } ================================================ FILE: app/src/main/java/com/hippo/scene/TransitionHelper.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.scene import android.content.Context import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction interface TransitionHelper { fun onTransition(context: Context, transaction: FragmentTransaction, exit: Fragment, enter: Fragment): Boolean } ================================================ FILE: app/src/main/java/com/hippo/text/URLImageGetter.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.text import android.graphics.drawable.Drawable import android.text.Html.ImageGetter import com.hippo.drawable.UnikeryDrawable import com.hippo.widget.ObservedTextView class URLImageGetter( private val mTextView: ObservedTextView, ) : ImageGetter { override fun getDrawable(source: String): Drawable = UnikeryDrawable(mTextView).apply { load(source) } } ================================================ FILE: app/src/main/java/com/hippo/unifile/Contracts.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor import java.io.IOException internal object Contracts { fun queryForString( context: Context, self: Uri, column: String, defaultValue: String?, ): String? = runCatching { context.contentResolver.query(self, arrayOf(column), null, null, null).use { if (it != null && it.moveToFirst() && !it.isNull(0)) { it.getString(0) } else { defaultValue } } }.getOrElse { Utils.throwIfFatal(it) defaultValue } fun queryForInt(context: Context, self: Uri, column: String?, defaultValue: Int): Int = queryForLong(context, self, column, defaultValue.toLong()).toInt() fun queryForLong(context: Context, self: Uri, column: String?, defaultValue: Long): Long = runCatching { context.contentResolver.query(self, arrayOf(column), null, null, null).use { if (it != null && it.moveToFirst() && !it.isNull(0)) { it.getLong(0) } else { defaultValue } } }.getOrElse { Utils.throwIfFatal(it) defaultValue } fun openFileDescriptor( context: Context, uri: Uri?, mode: String?, ): ParcelFileDescriptor = context.contentResolver.openFileDescriptor(uri!!, mode!!) ?: throw IOException("Can't open ParcelFileDescriptor") } ================================================ FILE: app/src/main/java/com/hippo/unifile/DocumentsContractApi19.kt ================================================ /* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.DocumentsContract internal object DocumentsContractApi19 { fun isDocumentUri(context: Context, self: Uri): Boolean = DocumentsContract.isDocumentUri(context, self) fun getName(context: Context, self: Uri): String? = Contracts.queryForString( context, self, DocumentsContract.Document.COLUMN_DISPLAY_NAME, null, ) private fun getRawType(context: Context, self: Uri): String? = Contracts.queryForString( context, self, DocumentsContract.Document.COLUMN_MIME_TYPE, null, ) fun getType(context: Context, self: Uri): String? = getRawType(context, self).takeUnless { it == DocumentsContract.Document.MIME_TYPE_DIR } fun isDirectory(context: Context, self: Uri): Boolean = DocumentsContract.Document.MIME_TYPE_DIR == getRawType(context, self) fun isFile(context: Context, self: Uri): Boolean { val type = getRawType(context, self) return !(DocumentsContract.Document.MIME_TYPE_DIR == type || type.isNullOrEmpty()) } fun lastModified(context: Context, self: Uri): Long = Contracts.queryForLong( context, self, DocumentsContract.Document.COLUMN_LAST_MODIFIED, -1L, ) fun length(context: Context, self: Uri): Long = Contracts.queryForLong(context, self, DocumentsContract.Document.COLUMN_SIZE, -1L) fun canRead(context: Context, self: Uri): Boolean { // Ignore if grant doesn't allow read return if ( context.checkCallingOrSelfUriPermission(self, Intent.FLAG_GRANT_READ_URI_PERMISSION) != PackageManager.PERMISSION_GRANTED ) { false } else { // Ignore documents without MIME !getRawType(context, self).isNullOrEmpty() } } fun canWrite(context: Context, self: Uri): Boolean { // Ignore if grant doesn't allow write if (context.checkCallingOrSelfUriPermission(self, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != PackageManager.PERMISSION_GRANTED ) { return false } val type = getRawType(context, self) val flags = Contracts.queryForInt(context, self, DocumentsContract.Document.COLUMN_FLAGS, 0) // Ignore documents without MIME if (type.isNullOrEmpty()) { return false } // Deletable documents considered writable if (flags and DocumentsContract.Document.FLAG_SUPPORTS_DELETE != 0) { return true } // Writable normal files considered writable return if (DocumentsContract.Document.MIME_TYPE_DIR == type && flags and DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE != 0) { // Directories that allow create considered writable true } else { flags and DocumentsContract.Document.FLAG_SUPPORTS_WRITE != 0 } } fun delete(context: Context, self: Uri): Boolean = try { DocumentsContract.deleteDocument(context.contentResolver, self) } catch (e: Throwable) { Utils.throwIfFatal(e) false } fun exists(context: Context, self: Uri): Boolean = runCatching { context.contentResolver.query( self, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null, ).use { null != it && it.count > 0 } }.getOrElse { Utils.throwIfFatal(it) false } } ================================================ FILE: app/src/main/java/com/hippo/unifile/DocumentsContractApi21.kt ================================================ /* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.net.Uri import android.provider.DocumentsContract internal object DocumentsContractApi21 { private const val PATH_DOCUMENT = "document" private const val PATH_TREE = "tree" fun createFile(context: Context, self: Uri, mimeType: String, displayName: String): Uri? = try { DocumentsContract.createDocument( context.contentResolver, self, mimeType, displayName, ) } catch (e: Throwable) { Utils.throwIfFatal(e) null } fun createDirectory(context: Context, self: Uri, displayName: String): Uri? = createFile(context, self, DocumentsContract.Document.MIME_TYPE_DIR, displayName) fun prepareTreeUri(treeUri: Uri?): Uri = DocumentsContract.buildDocumentUriUsingTree( treeUri, DocumentsContract.getTreeDocumentId(treeUri), ) fun getTreeDocumentPath(documentUri: Uri): String { val paths = documentUri.pathSegments if (paths.size >= 4 && PATH_TREE == paths[0] && PATH_DOCUMENT == paths[2]) { return paths[3] } throw IllegalArgumentException("Invalid URI: $documentUri") } fun buildChildUri(uri: Uri, displayName: String): Uri = DocumentsContract.buildDocumentUriUsingTree( uri, getTreeDocumentPath(uri) + "/" + displayName, ) fun listFiles(context: Context, self: Uri): Array { val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( self, DocumentsContract.getDocumentId(self), ) val results = ArrayList() runCatching { context.contentResolver.query( childrenUri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null, ).use { if (null != it) { while (it.moveToNext()) { val documentId = it.getString(0) val documentUri = DocumentsContract.buildDocumentUriUsingTree( self, documentId, ) results.add(documentUri) } } } }.onFailure { Utils.throwIfFatal(it) } return results.toTypedArray() } fun renameTo(context: Context, self: Uri, displayName: String): Uri? = try { DocumentsContract.renameDocument(context.contentResolver, self, displayName) } catch (e: Throwable) { Utils.throwIfFatal(e) null } } ================================================ FILE: app/src/main/java/com/hippo/unifile/FilenameFilter.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile /** * An interface for filtering [UniFile] objects based on their names * or the directory they reside in. * * @see UniFile * * @see UniFile.listFiles */ interface FilenameFilter { /** * Indicates if a specific filename matches this filter. * * @param dir the directory in which the `filename` was found. * @param filename the name of the file in `dir` to test. * @return `true` if the filename matches the filter * and can be included in the list, `false` * otherwise. */ fun accept(dir: UniFile?, filename: String?): Boolean } ================================================ FILE: app/src/main/java/com/hippo/unifile/MediaContract.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.net.Uri import android.provider.MediaStore internal object MediaContract { fun getName(context: Context, self: Uri): String? = Contracts.queryForString(context, self, MediaStore.MediaColumns.DISPLAY_NAME, null) fun getType(context: Context, self: Uri): String? = Contracts.queryForString(context, self, MediaStore.MediaColumns.MIME_TYPE, null) fun lastModified(context: Context, self: Uri): Long = Contracts.queryForLong(context, self, MediaStore.MediaColumns.DATE_MODIFIED, 0) fun length(context: Context, self: Uri): Long = Contracts.queryForLong(context, self, MediaStore.MediaColumns.SIZE, 0) } ================================================ FILE: app/src/main/java/com/hippo/unifile/MediaFile.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor import java.io.IOException internal class MediaFile(context: Context, override val uri: Uri) : UniFile(null) { private val mContext = context.applicationContext override fun createFile(displayName: String): UniFile? = null override fun createDirectory(displayName: String): UniFile? = null override val name: String? get() = MediaContract.getName(mContext, uri) override val type: String? get() = MediaContract.getType(mContext, uri) override val isDirectory: Boolean get() = false override val isFile: Boolean get() = DocumentsContractApi19.isFile(mContext, uri) override fun lastModified(): Long = MediaContract.lastModified(mContext, uri) override fun length(): Long = MediaContract.length(mContext, uri) override fun canRead(): Boolean = isFile override fun canWrite(): Boolean { try { val fd = openFileDescriptor("w") fd.close() } catch (_: IOException) { return false } return true } override fun ensureDir(): Boolean = false override fun ensureFile(): Boolean = isFile override fun subFile(displayName: String): UniFile? = null override fun delete(): Boolean = false override fun exists(): Boolean = isFile override fun listFiles(): Array? = null override fun listFiles(filter: FilenameFilter?): Array? = null override fun findFile(displayName: String): UniFile? = null override fun renameTo(displayName: String): Boolean = false override fun openFileDescriptor(mode: String): ParcelFileDescriptor = Contracts.openFileDescriptor(mContext, uri, mode) companion object { fun isMediaUri(context: Context, uri: Uri): Boolean = null != MediaContract.getName(context, uri) } } ================================================ FILE: app/src/main/java/com/hippo/unifile/RawFile.kt ================================================ /* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Log import android.webkit.MimeTypeMap import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.Locale internal class RawFile(parent: UniFile?, var mFile: File) : UniFile(parent) { override fun createFile(displayName: String): UniFile? { val target = File(mFile, displayName) return if (target.exists()) { if (target.isFile) { RawFile(this, target) } else { null } } else { runCatching { FileOutputStream(target).use {} RawFile(this, target) }.getOrElse { Log.w(TAG, "Failed to createFile $displayName: $it") null } } } override fun createDirectory(displayName: String): UniFile? { val target = File(mFile, displayName) return if (target.isDirectory || target.mkdirs()) { RawFile(this, target) } else { null } } override val uri: Uri get() = Uri.fromFile(mFile) override val name: String get() = mFile.name override val type: String? get() = if (mFile.isDirectory) { null } else { getTypeForName(mFile.name) } override val isDirectory: Boolean get() = mFile.isDirectory override val isFile: Boolean get() = mFile.isFile override fun lastModified(): Long = mFile.lastModified() override fun length(): Long = mFile.length() override fun canRead(): Boolean = mFile.canRead() override fun canWrite(): Boolean = mFile.canWrite() override fun ensureDir(): Boolean = mFile.isDirectory || mFile.mkdirs() override fun ensureFile(): Boolean = if (mFile.exists()) { mFile.isFile } else { runCatching { FileOutputStream(mFile).use {} true }.getOrDefault(false) } override fun subFile(displayName: String): UniFile = RawFile(this, File(mFile, displayName)) override fun delete(): Boolean { deleteContents(mFile) return mFile.delete() } override fun exists(): Boolean = mFile.exists() override fun listFiles(): Array? { val files = mFile.listFiles() ?: return null return files.map { RawFile(this, it) }.toTypedArray() } override fun listFiles(filter: FilenameFilter?): Array? { if (filter == null) { return listFiles() } val files = mFile.listFiles() ?: return null val results = ArrayList() for (file in files) { if (filter.accept(this, file.name)) { results.add(RawFile(this, file)) } } return results.toTypedArray() } override fun findFile(displayName: String): UniFile? { val child = File(mFile, displayName) return if (child.exists()) RawFile(this, child) else null } override fun renameTo(displayName: String): Boolean { val target = File(mFile.parentFile, displayName) return if (mFile.renameTo(target)) { mFile = target true } else { false } } override fun openFileDescriptor(mode: String): ParcelFileDescriptor { val md = ParcelFileDescriptor.parseMode(mode) return ParcelFileDescriptor.open(mFile, md) ?: throw IOException("Can't open ParcelFileDescriptor") } companion object { private val TAG = RawFile::class.java.simpleName private fun getTypeForName(name: String): String { val lastDot = name.lastIndexOf('.') if (lastDot >= 0) { val extension = name.substring(lastDot + 1).lowercase(Locale.getDefault()) val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) if (mime != null) { return mime } } return "application/octet-stream" } private fun deleteContents(dir: File): Boolean { val files = dir.listFiles() var success = true if (files != null) { for (file in files) { if (file.isDirectory) { success = success and deleteContents(file) } if (!file.delete()) { Log.w(TAG, "Failed to delete $file") success = false } } } return success } } } ================================================ FILE: app/src/main/java/com/hippo/unifile/SingleDocumentFile.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor internal class SingleDocumentFile(parent: UniFile?, context: Context, override val uri: Uri) : UniFile(parent) { private val mContext = context.applicationContext override fun createFile(displayName: String): UniFile? = null override fun createDirectory(displayName: String): UniFile? = null override val name: String? get() = DocumentsContractApi19.getName(mContext, uri) override val type: String? get() = DocumentsContractApi19.getType(mContext, uri) override val isDirectory: Boolean get() = DocumentsContractApi19.isDirectory(mContext, uri) override val isFile: Boolean get() = DocumentsContractApi19.isFile(mContext, uri) override fun lastModified(): Long = DocumentsContractApi19.lastModified(mContext, uri) override fun length(): Long = DocumentsContractApi19.length(mContext, uri) override fun canRead(): Boolean = DocumentsContractApi19.canRead(mContext, uri) override fun canWrite(): Boolean = DocumentsContractApi19.canWrite(mContext, uri) override fun ensureDir(): Boolean = isDirectory override fun ensureFile(): Boolean = isFile override fun subFile(displayName: String): UniFile? = null override fun delete(): Boolean = DocumentsContractApi19.delete(mContext, uri) override fun exists(): Boolean = DocumentsContractApi19.exists(mContext, uri) override fun listFiles(): Array? = null override fun listFiles(filter: FilenameFilter?): Array? = null override fun findFile(displayName: String): UniFile? = null override fun renameTo(displayName: String): Boolean = false override fun openFileDescriptor(mode: String): ParcelFileDescriptor = Contracts.openFileDescriptor(mContext, uri, mode) } ================================================ FILE: app/src/main/java/com/hippo/unifile/TreeDocumentFile.kt ================================================ /* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Log import android.webkit.MimeTypeMap internal class TreeDocumentFile : UniFile { private val mContext: Context override var uri: Uri private var mFilename: String? = null constructor(parent: UniFile?, context: Context, uri: Uri) : super(parent) { mContext = context.applicationContext this.uri = uri } private constructor(parent: UniFile, context: Context, uri: Uri, filename: String?) : super( parent, ) { mContext = context.applicationContext this.uri = uri mFilename = filename } override fun createFile(displayName: String): UniFile? { val child = findFile(displayName) return if (child != null) { if (child.isFile) { child } else { Log.w( TAG, "Try to create file $displayName, but it is not file", ) null } } else { val index = displayName.lastIndexOf('.') if (index > 0) { val name = displayName.substring(0, index) val extension = displayName.substring(index + 1) val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) if (!mimeType.isNullOrEmpty()) { val result = DocumentsContractApi21.createFile(mContext, uri, mimeType, name) return if (result != null) { TreeDocumentFile( this, mContext, result, displayName, ) } else { null } } } // Not dot in displayName or dot is the first char or can't get MimeType val result = DocumentsContractApi21.createFile( mContext, uri, "application/octet-stream", displayName, ) if (result != null) { TreeDocumentFile( this, mContext, result, displayName, ) } else { null } } } override fun createDirectory(displayName: String): UniFile? { val child = findFile(displayName) return if (child != null) { if (child.isDirectory) { child } else { null } } else { val result = DocumentsContractApi21.createDirectory(mContext, uri, displayName) if (result != null) { TreeDocumentFile( this, mContext, result, displayName, ) } else { null } } } override val name: String? get() = DocumentsContractApi19.getName(mContext, uri) override val type: String? get() = DocumentsContractApi19.getType(mContext, uri) override val isDirectory: Boolean get() = DocumentsContractApi19.isDirectory(mContext, uri) override val isFile: Boolean get() = DocumentsContractApi19.isFile(mContext, uri) override fun lastModified(): Long = DocumentsContractApi19.lastModified(mContext, uri) override fun length(): Long = DocumentsContractApi19.length(mContext, uri) override fun canRead(): Boolean = DocumentsContractApi19.canRead(mContext, uri) override fun canWrite(): Boolean = DocumentsContractApi19.canWrite(mContext, uri) override fun ensureDir(): Boolean { if (isDirectory) { return true } else if (isFile) { return false } val parent = parentFile return if (parent != null && parent.ensureDir() && mFilename != null) { parent.createDirectory(mFilename!!) != null } else { false } } override fun ensureFile(): Boolean { if (isFile) { return true } else if (isDirectory) { return false } val parent = parentFile return if (parent != null && parent.ensureDir() && mFilename != null) { parent.createFile(mFilename!!) != null } else { false } } override fun subFile(displayName: String): UniFile { val childUri = DocumentsContractApi21.buildChildUri(uri, displayName) return TreeDocumentFile(this, mContext, childUri, displayName) } override fun delete(): Boolean = DocumentsContractApi19.delete(mContext, uri) override fun exists(): Boolean = DocumentsContractApi19.exists(mContext, uri) private fun getFilenameForUri(uri: Uri): String? { val path = uri.path if (path != null) { val index = path.lastIndexOf('/') if (index >= 0) { return path.substring(index + 1) } } return null } override fun listFiles(): Array { val result = DocumentsContractApi21.listFiles(mContext, uri) return result.map { TreeDocumentFile(this, mContext, it, getFilenameForUri(it)) }.toTypedArray() } override fun listFiles(filter: FilenameFilter?): Array { if (filter == null) { return listFiles() } val result = DocumentsContractApi21.listFiles(mContext, uri) val results = ArrayList() for (uri in result) { val name = getFilenameForUri(uri) if (filter.accept(this, name)) { results.add(TreeDocumentFile(this, mContext, uri, name)) } } return results.toTypedArray() } override fun findFile(displayName: String): UniFile? { val childUri = DocumentsContractApi21.buildChildUri(uri, displayName) return if (DocumentsContractApi19.exists(mContext, childUri)) { TreeDocumentFile( this, mContext, childUri, displayName, ) } else { null } } override fun renameTo(displayName: String): Boolean { val result = DocumentsContractApi21.renameTo(mContext, uri, displayName) return if (result != null) { uri = result true } else { false } } override fun openFileDescriptor(mode: String): ParcelFileDescriptor = Contracts.openFileDescriptor(mContext, uri, mode) companion object { private val TAG = TreeDocumentFile::class.java.simpleName } } ================================================ FILE: app/src/main/java/com/hippo/unifile/UniFile.kt ================================================ /* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.ContentResolver import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.os.ParcelFileDescriptor import java.io.File /** * In Android files can be accessed via [java.io.File] and [android.net.Uri]. * The UniFile is designed to emulate File interface for both File and Uri. */ abstract class UniFile internal constructor(private val parent: UniFile?) { /** * Create a new file as a direct child of this directory. * * @param displayName name of new file * @return file representing newly created document, or null if failed * @see android.provider.DocumentsContract.createDocument */ abstract fun createFile(displayName: String): UniFile? /** * Create a new directory as a direct child of this directory. * * @param displayName name of new directory * @return file representing newly created directory, or null if failed * @see android.provider.DocumentsContract.createDocument */ abstract fun createDirectory(displayName: String): UniFile? /** * Return a Uri for the underlying document represented by this file. This * can be used with other platform APIs to manipulate or share the * underlying content. You can use [.isTreeUri] to * test if the returned Uri is backed by a * [android.provider.DocumentsProvider]. * * @return uri of the file * @see Intent.setData * @see Intent.setClipData * @see ContentResolver.openInputStream * @see ContentResolver.openOutputStream * @see ContentResolver.openFileDescriptor */ abstract val uri: Uri /** * Return the display name of this file. * * @return name of the file, or null if failed * @see android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME */ abstract val name: String? /** * Return the MIME type of this file. * * @return MIME type of the file, or null if failed * @see android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE */ abstract val type: String? /** * Return the parent file of this file. Only defined inside of the * user-selected tree; you can never escape above the top of the tree. * * * The underlying [android.provider.DocumentsProvider] only defines a * forward mapping from parent to child, so the reverse mapping of child to * parent offered here is purely a convenience method, and it may be * incorrect if the underlying tree structure changes. * * @return parent of the file, or null if it is the top of the file tree */ val parentFile: UniFile? get() = parent /** * Indicates if this file represents a *directory*. * * @return `true` if this file is a directory, `false` * otherwise. * @see android.provider.DocumentsContract.Document.MIME_TYPE_DIR */ abstract val isDirectory: Boolean /** * Indicates if this file represents a *file*. * * @return `true` if this file is a file, `false` otherwise. * @see android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE */ abstract val isFile: Boolean /** * Returns the time when this file was last modified, measured in * milliseconds since January 1st, 1970, midnight. Returns -1 if the file * does not exist, or if the modified time is unknown. * * @return the time when this file was last modified, `-1L` if can't get it * @see android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED */ abstract fun lastModified(): Long /** * Returns the length of this file in bytes. Returns -1 if the file does not * exist, or if the length is unknown. The result for a directory is not * defined. * * @return the number of bytes in this file, `-1L` if can't get it * @see android.provider.DocumentsContract.Document.COLUMN_SIZE */ abstract fun length(): Long /** * Indicates whether the current context is allowed to read from this file. * * @return `true` if this file can be read, `false` otherwise. */ abstract fun canRead(): Boolean /** * Indicates whether the current context is allowed to write to this file. * * @return `true` if this file can be written, `false` * otherwise. * @see android.provider.DocumentsContract.Document.COLUMN_FLAGS * * @see android.provider.DocumentsContract.Document.FLAG_SUPPORTS_DELETE * * @see android.provider.DocumentsContract.Document.FLAG_SUPPORTS_WRITE * * @see android.provider.DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE */ abstract fun canWrite(): Boolean /** * It works like mkdirs, but it will return true if the UniFile is directory * * @return `true` if the directory was created * or if the directory already existed. */ abstract fun ensureDir(): Boolean /** * Make sure the UniFile is file * * @return `true` if the file can be created * or if the file already existed. */ abstract fun ensureFile(): Boolean /** * Get child file of this directory, the child might not exist. * * @return the child file, `null` if not supported */ abstract fun subFile(displayName: String): UniFile? /** * Deletes this file. * * * Note that this method does *not* throw `IOException` on * failure. Callers must check the return value. * * @return `true` if this file was deleted, `false` otherwise. * @see android.provider.DocumentsContract.deleteDocument */ abstract fun delete(): Boolean /** * Returns a boolean indicating whether this file can be found. * * @return `true` if this file exists, `false` otherwise. */ abstract fun exists(): Boolean /** * Returns an array of files contained in the directory represented by this * file. * * @return an array of files or `null`. * @see android.provider.DocumentsContract.buildChildDocumentsUriUsingTree */ abstract fun listFiles(): Array? /** * Gets a list of the files in the directory represented by this file. This * list is then filtered through a FilenameFilter and the names of files * with matching names are returned as an array of strings. * * @param filter the filter to match names against, may be `null`. * @return an array of files or `null`. */ abstract fun listFiles(filter: FilenameFilter?): Array? /** * Test there is a file with the display name in the directory. * * @return the file if found it, or `null`. */ abstract fun findFile(displayName: String): UniFile? /** * Renames this file to `displayName`. * * * Note that this method does *not* throw `IOException` on * failure. Callers must check the return value. * * * Some providers may need to create a new file to reflect the rename, * potentially with a different MIME type, so [.getUri] and * [.getType] may change to reflect the rename. * * * When renaming a directory, children previously enumerated through * [.listFiles] may no longer be valid. * * @param displayName the new display name. * @return true on success. * @see android.provider.DocumentsContract.renameDocument */ abstract fun renameTo(displayName: String): Boolean abstract fun openFileDescriptor(mode: String): ParcelFileDescriptor companion object { private var sUriHandlerArray: MutableList? = null /** * Add a UriHandler to get UniFile from uri */ fun addUriHandler(handler: UriHandler) { if (sUriHandlerArray == null) { sUriHandlerArray = ArrayList() } sUriHandlerArray!!.add(handler) } /** * Remove the UriHandler added before */ fun removeUriHandler(handler: UriHandler) { if (sUriHandlerArray != null) { sUriHandlerArray!!.remove(handler) } } /** * Create a [UniFile] representing the given [File]. * * @param file the file to wrap * @return the [UniFile] representing the given [File]. */ fun fromFile(file: File?): UniFile? = if (file != null) RawFile(null, file) else null /** * Create a [UniFile] representing the single document at the * given [Uri]. This is only useful on devices running * [android.os.Build.VERSION_CODES.KITKAT] or later, and will return * `null` when called on earlier platform versions. * * @param singleUri the [Intent.getData] from a successful * [Intent.ACTION_OPEN_DOCUMENT] or * [Intent.ACTION_CREATE_DOCUMENT] request. * @return the [UniFile] representing the given [Uri]. */ fun fromSingleUri(context: Context, singleUri: Uri): UniFile? { val version = Build.VERSION.SDK_INT return if (version >= 19) { SingleDocumentFile(null, context, singleUri) } else { null } } /** * Create a [UniFile] representing the document tree rooted at * the given [Uri]. This is only useful on devices running * [Build.VERSION_CODES.LOLLIPOP] or later, and will return * `null` when called on earlier platform versions. * * @param treeUri the [Intent.getData] from a successful * [Intent.ACTION_OPEN_DOCUMENT_TREE] request. * @return the [UniFile] representing the given [Uri]. */ fun fromTreeUri(context: Context, treeUri: Uri): UniFile? { val version = Build.VERSION.SDK_INT return if (version >= 21) { TreeDocumentFile( null, context, DocumentsContractApi21.prepareTreeUri(treeUri), ) } else { null } } /** * Create a [UniFile] representing the media file rooted at * the given [Uri]. * * @param mediaUri the media uri to wrap * @return the [UniFile] representing the given [Uri]. */ fun fromMediaUri(context: Context, mediaUri: Uri): UniFile = MediaFile(context, mediaUri) /** * Create a [UniFile] representing the given [Uri]. */ fun fromUri(context: Context, uri: Uri): UniFile? { // Custom handler if (sUriHandlerArray != null) { var i = 0 val size = sUriHandlerArray!!.size while (i < size) { val file = sUriHandlerArray!![i].fromUri(context, uri) if (file != null) { return file } i++ } } return if (isFileUri(uri)) { fromFile(File(uri.path!!)) } else if (isDocumentUri(context, uri)) { if (isTreeUri(uri)) { fromTreeUri(context, uri) } else { fromSingleUri(context, uri) } } else if (MediaFile.isMediaUri(context, uri)) { MediaFile(context, uri) } else { null } } /** * Test if given Uri is FileUri */ fun isFileUri(uri: Uri): Boolean = ContentResolver.SCHEME_FILE == uri.scheme /** * Test if given Uri is backed by a * [android.provider.DocumentsProvider]. */ fun isDocumentUri(context: Context, uri: Uri): Boolean { val version = Build.VERSION.SDK_INT return if (version >= 19) { DocumentsContractApi19.isDocumentUri(context, uri) } else { false } } /** * Test if given Uri is TreeUri */ fun isTreeUri(uri: Uri): Boolean { val paths = uri.pathSegments return ContentResolver.SCHEME_CONTENT == uri.scheme && paths.size >= 2 && "tree" == paths[0] } } } ================================================ FILE: app/src/main/java/com/hippo/unifile/UniFileExtensions.kt ================================================ /* * Copyright 2023 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.unifile import android.os.ParcelFileDescriptor.AutoCloseInputStream import android.os.ParcelFileDescriptor.AutoCloseOutputStream import java.io.FileInputStream import java.io.FileOutputStream /** * Use Native IO/NIO directly if possible, unless you need process file content on JVM! */ fun UniFile.openInputStream(): FileInputStream = AutoCloseInputStream(openFileDescriptor("r")) /** * Use Native IO/NIO directly if possible, unless you need process file content on JVM! */ fun UniFile.openOutputStream(): FileOutputStream = AutoCloseOutputStream(openFileDescriptor("wt")) fun UniFile.sha1() = openFileDescriptor("r").use { com.hippo.ehviewer.jni.sha1(it.fd) } ================================================ FILE: app/src/main/java/com/hippo/unifile/UriHandler.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.unifile import android.content.Context import android.net.Uri /* * Created by Hippo on 8/16/2016. */ /** * A UriHandler is to get UniFile from custom uri for extensions */ interface UriHandler { /** * Create a [UniFile] representing the uri */ fun fromUri(context: Context?, uri: Uri?): UniFile? } ================================================ FILE: app/src/main/java/com/hippo/unifile/Utils.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.unifile internal object Utils { fun throwIfFatal(t: Throwable) { // values here derived from https://github.com/ReactiveX/RxJava/issues/748#issuecomment-32471495 when (t) { is VirtualMachineError, is ThreadDeath, is LinkageError -> throw t } } } ================================================ FILE: app/src/main/java/com/hippo/util/AppHelper.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.util import android.app.Activity import android.content.Intent import android.widget.Toast import com.hippo.ehviewer.R object AppHelper { fun share(from: Activity, text: String?): Boolean { val sendIntent = Intent() sendIntent.action = Intent.ACTION_SEND sendIntent.putExtra(Intent.EXTRA_TEXT, text) sendIntent.type = "text/plain" val chooser = Intent.createChooser(sendIntent, from.getString(R.string.share)) return runCatching { from.startActivity(chooser) true }.getOrElse { ExceptionUtils.throwIfFatal(it) Toast.makeText(from, R.string.error_cant_find_activity, Toast.LENGTH_SHORT).show() false } } } ================================================ FILE: app/src/main/java/com/hippo/util/BBCode.kt ================================================ /* * Copyright 2023 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import android.graphics.Typeface import android.text.Spanned import android.text.style.CharacterStyle import android.text.style.ImageSpan import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.text.style.URLSpan import android.text.style.UnderlineSpan fun Spanned.toBBCode(): String { val text = this tailrec fun StringBuilder.processOneSpanTransition(cur: Int = 0) { val next = nextSpanTransition(cur, text.length, CharacterStyle::class.java) getSpans(cur, next, CharacterStyle::class.java).forEach { when (it) { is StyleSpan -> { val s = it.style if (s and Typeface.BOLD != 0) { append("[b]") } if (s and Typeface.ITALIC != 0) { append("[i]") } } is UnderlineSpan -> append("[u]") is StrikethroughSpan -> append("[s]") is URLSpan -> { append("[url=") append(it.url) append("]") } is ImageSpan -> { append("[img]") append(it.source) append("[/img]") } } } append(text.subSequence(cur, next)) getSpans(cur, next, CharacterStyle::class.java).reversed().forEach { when (it) { is StyleSpan -> { val s = it.style if (s and Typeface.BOLD != 0) { append("[/b]") } if (s and Typeface.ITALIC != 0) { append("[/i]") } } is UnderlineSpan -> append("[/u]") is StrikethroughSpan -> append("[/s]") is URLSpan -> append("[/url]") } } if (next < text.length) processOneSpanTransition(next) } return StringBuilder().apply { processOneSpanTransition() }.toString() } ================================================ FILE: app/src/main/java/com/hippo/util/ClipboardUtil.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import android.content.ClipData import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.os.PersistableBundle import android.text.TextUtils import android.view.textclassifier.TextClassifier import com.hippo.ehviewer.R import com.hippo.ehviewer.ui.MainActivity import com.hippo.ehviewer.ui.SettingsActivity import com.hippo.ehviewer.ui.scene.BaseScene fun Context.getClipboardManager(): ClipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager fun Context.addTextToClipboard(text: CharSequence?, isSensitive: Boolean) { getClipboardManager().apply { setPrimaryClip( ClipData.newPlainText(null, text).apply { if (isAtLeastT && isSensitive) { description.extras = PersistableBundle().apply { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) } } }, ) } if (this is MainActivity) { showTip(R.string.copied_to_clipboard, BaseScene.LENGTH_SHORT) } else if (this is SettingsActivity) { showTip(R.string.copied_to_clipboard, BaseScene.LENGTH_SHORT) } } fun ClipboardManager.getTextFromClipboard(context: Context): String? { val item = primaryClip?.getItemAt(0) val string = item?.coerceToText(context).toString() return if (!TextUtils.isEmpty(string)) string else null } fun ClipboardManager.getUrlFromClipboard(context: Context): String? { if (isAtLeastS && primaryClipDescription?.classificationStatus == ClipDescription.CLASSIFICATION_COMPLETE) { if (( primaryClipDescription?.getConfidenceScore(TextClassifier.TYPE_URL) ?.let { it <= 0 } ) == true ) { return null } } val item = primaryClip?.getItemAt(0) val string = item?.coerceToText(context).toString() return if (!TextUtils.isEmpty(string)) string else null } ================================================ FILE: app/src/main/java/com/hippo/util/CoroutinesExtensions.kt ================================================ /* * Copyright 2023 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import java.io.InterruptedIOException import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext /** * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. * * **Possible replacements** * - suspend function * - custom scope like view or presenter scope */ @DelicateCoroutinesApi fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) /** * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. * * **Possible replacements** * - suspend function * - custom scope like view or presenter scope */ @DelicateCoroutinesApi fun launchIO(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block) /** * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. * * **Possible replacements** * - suspend function * - custom scope like view or presenter scope */ @DelicateCoroutinesApi fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = launch(Dispatchers.Main, block = block) fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = launch(Dispatchers.IO, block = block) fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job = launchIO { withContext(NonCancellable, block) } suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block) suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) = withContext(NonCancellable, block) // moe.tarsin.coroutines inline fun Result<*>.except(): Result<*> = onFailure { if (it is T) throw it } inline fun runSuspendCatching(block: () -> R): Result = runCatching(block).apply { except() } inline fun T.runSuspendCatching(block: T.() -> R): Result = runCatching(block).apply { except() } // See https://github.com/Kotlin/kotlinx.coroutines/issues/3551 suspend inline fun runInterruptibleOkio( context: CoroutineContext = EmptyCoroutineContext, crossinline block: () -> T, ): T = runInterruptible(context) { try { block() } catch (e: InterruptedIOException) { if (Thread.currentThread().isInterrupted) { // Coroutine cancelled throw InterruptedException().initCause(e) } else { // AsyncTimeout reached throw e } } } ================================================ FILE: app/src/main/java/com/hippo/util/DateTimeUtil.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import kotlin.time.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime fun LocalDate.toEpochMillis(timeZone: TimeZone = TimeZone.UTC): Long = atStartOfDayIn(timeZone).toEpochMilliseconds() fun LocalDateTime.toEpochMillis(timeZone: TimeZone = TimeZone.UTC): Long = toInstant(timeZone).toEpochMilliseconds() fun Long.toLocalDateTime(timeZone: TimeZone = TimeZone.UTC): LocalDateTime = Instant.fromEpochMilliseconds(this).toLocalDateTime(timeZone) ================================================ FILE: app/src/main/java/com/hippo/util/ExceptionUtils.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.util import com.hippo.ehviewer.GetText.getString import com.hippo.ehviewer.R import com.hippo.ehviewer.client.exception.CloudflareBypassException import com.hippo.ehviewer.client.exception.EhException import com.hippo.network.StatusCodeException import java.net.MalformedURLException import java.net.ProtocolException import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException import javax.net.ssl.SSLException object ExceptionUtils { fun getReadableString(e: Throwable?): String { e?.printStackTrace() if (e?.cause is CloudflareBypassException) { return e.cause!!.message!! } return when (e) { is MalformedURLException -> { getString(R.string.error_invalid_url) } is SocketTimeoutException -> { getString(R.string.error_timeout) } is UnknownHostException -> { getString(R.string.error_unknown_host) } is StatusCodeException -> { val sb = StringBuilder() sb.append(getString(R.string.error_bad_status_code, e.responseCode)) if (e.isIdentifiedResponseCode) { sb.append(", ").append(e.message) } sb.toString() } is ProtocolException if e.message!!.startsWith("Too many follow-up requests:") -> { getString(R.string.error_redirection) } is ProtocolException, is SocketException, is SSLException -> { getString(R.string.error_socket) } is EhException -> { e.message!! } else -> { getString(R.string.error_unknown) } } } fun throwIfFatal(t: Throwable) { // values here derived from https://github.com/ReactiveX/RxJava/issues/748#issuecomment-32471495 when (t) { is VirtualMachineError -> { throw t } is ThreadDeath -> { throw t } is LinkageError -> { throw t } } } } ================================================ FILE: app/src/main/java/com/hippo/util/FDUtils.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import android.os.ParcelFileDescriptor import android.system.Int64Ref import android.system.Os import android.util.Log import com.hippo.unifile.UniFile import java.io.FileDescriptor import java.io.FileInputStream import java.io.FileOutputStream private fun sendFileTotally(from: FileDescriptor, to: FileDescriptor): Long { if (isAtLeastP) { // sendFile may fail on some devices try { return Os.sendfile(to, from, Int64Ref(0), Long.MAX_VALUE) } catch (e: Exception) { Log.e("sendFile", "failed", e) } } return FileInputStream(from).use { src -> FileOutputStream(to).use { dst -> src.channel.transferTo(0, Long.MAX_VALUE, dst.channel) } } } infix fun ParcelFileDescriptor.sendTo(fd: ParcelFileDescriptor) { sendFileTotally(fileDescriptor, fd.fileDescriptor) } infix fun UniFile.sendTo(file: UniFile) = openFileDescriptor("r").use { src -> file.openFileDescriptor("wt").use { dst -> src sendTo dst } } ================================================ FILE: app/src/main/java/com/hippo/util/HtmlCompat.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import android.text.Html import android.text.Spanned import com.hippo.text.URLImageGetter import com.hippo.widget.ObservedTextView @Suppress("DEPRECATION") fun loadHtml(source: String): Spanned = if (isAtLeastN) { Html.fromHtml(source, Html.FROM_HTML_MODE_LEGACY) } else { Html.fromHtml(source) } @Suppress("DEPRECATION") fun loadHtml(source: String?, textView: ObservedTextView): Spanned = if (isAtLeastN) { Html.fromHtml(source, Html.FROM_HTML_MODE_LEGACY, URLImageGetter(textView), null) } else { Html.fromHtml(source, URLImageGetter(textView), null) } ================================================ FILE: app/src/main/java/com/hippo/util/JsoupUtils.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.util; import androidx.annotation.Nullable; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; public final class JsoupUtils { @Nullable public static Element getElementByClass(Document doc, String className) { Elements elements = doc.getElementsByClass(className); if (!elements.isEmpty()) { //noinspection SequencedCollectionMethodCanBeUsed return elements.get(0); } else { return null; } } @Nullable public static Element getElementByClass(Element element, String className) { Elements elements = element.getElementsByClass(className); if (!elements.isEmpty()) { //noinspection SequencedCollectionMethodCanBeUsed return elements.get(0); } else { return null; } } @Nullable public static Element getElementByTag(Element element, String tagName) { Elements elements = element.getElementsByTag(tagName); if (!elements.isEmpty()) { //noinspection SequencedCollectionMethodCanBeUsed return elements.get(0); } else { return null; } } } ================================================ FILE: app/src/main/java/com/hippo/util/LogCat.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.util; import android.util.Log; import com.hippo.yorozuya.IOUtils; import java.io.IOException; import java.io.OutputStream; public final class LogCat { private LogCat() { } public static boolean save(OutputStream outputStream) { try { Process p = Runtime.getRuntime().exec("logcat -d"); IOUtils.copy(p.getInputStream(), outputStream); return true; } catch (IOException e) { Log.e("LogCat", "Error saving logcat output", e); return false; } } } ================================================ FILE: app/src/main/java/com/hippo/util/ParcelableCompat.kt ================================================ /* * Copyright 2023 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import android.content.Intent import android.os.Bundle import android.os.Parcel import android.os.Parcelable import android.util.SparseArray import androidx.core.content.IntentCompat import androidx.core.os.BundleCompat import androidx.core.os.ParcelCompat inline fun Bundle.getParcelableCompat(key: String?): T? = BundleCompat.getParcelable(this, key, T::class.java) inline fun Bundle.getSparseParcelableArrayCompat(key: String?): SparseArray? = BundleCompat.getSparseParcelableArray(this, key, T::class.java) inline fun Intent.getParcelableExtraCompat(key: String?): T? = IntentCompat.getParcelableExtra(this, key, T::class.java) inline fun Parcel.readParcelableCompat(key: ClassLoader?): T? = ParcelCompat.readParcelable(this, key, T::class.java) ================================================ FILE: app/src/main/java/com/hippo/util/ReadableTime.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.util import android.content.Context import android.content.res.Resources import com.hippo.ehviewer.R import java.util.Locale import kotlin.time.Clock import kotlin.time.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.format.MonthNames import kotlinx.datetime.format.Padding import kotlinx.datetime.format.char import kotlinx.datetime.toLocalDateTime object ReadableTime { const val MAX_VALUE_MILLIS = 253402300799999L private const val SECOND_MILLIS = 1000L private const val MINUTE_MILLIS = 60 * SECOND_MILLIS private const val HOUR_MILLIS = 60 * MINUTE_MILLIS private const val DAY_MILLIS = 24 * HOUR_MILLIS private const val WEEK_MILLIS = 7 * DAY_MILLIS private const val YEAR_MILLIS = 365 * DAY_MILLIS private val MULTIPLES = longArrayOf( YEAR_MILLIS, DAY_MILLIS, HOUR_MILLIS, MINUTE_MILLIS, SECOND_MILLIS, ) private const val SIZE = 5 private val UNITS = intArrayOf( R.plurals.year, R.plurals.day, R.plurals.hour, R.plurals.minute, R.plurals.second, ) private val DATE_FORMAT_WITHOUT_YEAR = LocalDate.Format { monthName(MonthNames.ENGLISH_ABBREVIATED) char(' ') day(Padding.NONE) } private val DATE_FORMAT_WITH_YEAR = LocalDate.Format { monthName(MonthNames.ENGLISH_ABBREVIATED) char(' ') day(Padding.NONE) chars(", ") year() } private val DATE_FORMAT_WITHOUT_YEAR_ZH = LocalDate.Format { monthNumber(Padding.NONE) char('月') day(Padding.NONE) char('日') } private val DATE_FORMAT_WITH_YEAR_ZH = LocalDate.Format { year() char('年') monthNumber(Padding.NONE) char('月') day(Padding.NONE) char('日') } // yyyy-MM-dd-HH-mm-ss-SSS private val FILENAMABLE_DATE_FORMAT = LocalDateTime.Format { year() char('-') monthNumber() char('-') day() char('-') hour() char('-') minute() char('-') second() char('-') secondFraction(3) } // yyyy-MM-dd HH:mm private val DATE_FORMAT_SHORT = LocalDateTime.Format { year() char('-') monthNumber() char('-') day() char(' ') hour() char(':') minute() } private var sResources: Resources? = null fun initialize(context: Context) { sResources = context.applicationContext.resources } fun getTimeAgo(time: Long): String { val resources = sResources!! val nowInstant = Clock.System.now() val now = nowInstant.toEpochMilliseconds() val diff = now - time return when { (diff < 0 || time <= 0) -> resources.getString(R.string.from_the_future) diff < MINUTE_MILLIS -> resources.getString(R.string.just_now) diff < 2 * MINUTE_MILLIS -> resources.getQuantityString(R.plurals.some_minutes_ago, 1, 1) diff < 50 * MINUTE_MILLIS -> { val minutes = (diff / MINUTE_MILLIS).toInt() resources.getQuantityString(R.plurals.some_minutes_ago, minutes, minutes) } diff < 90 * MINUTE_MILLIS -> resources.getQuantityString(R.plurals.some_hours_ago, 1, 1) diff < 24 * HOUR_MILLIS -> { val hours = (diff / HOUR_MILLIS).toInt() resources.getQuantityString(R.plurals.some_hours_ago, hours, hours) } diff < 48 * HOUR_MILLIS -> { resources.getString(R.string.yesterday) } diff < WEEK_MILLIS -> resources.getString(R.string.some_days_ago, (diff / DAY_MILLIS).toInt()) else -> { val timeZone = TimeZone.currentSystemDefault() val nowDate = nowInstant.toLocalDateTime(timeZone).date val timeDate = time.toLocalDateTime(timeZone).date val nowYear = nowDate.year val timeYear = timeDate.year val isZh = Locale.getDefault().language == "zh" if (nowYear == timeYear) { if (isZh) DATE_FORMAT_WITHOUT_YEAR_ZH else DATE_FORMAT_WITHOUT_YEAR } else { if (isZh) DATE_FORMAT_WITH_YEAR_ZH else DATE_FORMAT_WITH_YEAR }.format(timeDate) } } } fun getShortTimeInterval(time: Long): String = buildString { val resources: Resources = sResources!! for (i in 0 until SIZE) { val multiple = MULTIPLES[i] val quotient = time / multiple if (time > multiple * 1.5 || i == SIZE - 1) { append(quotient) .append(" ") .append(resources.getQuantityString(UNITS[i], quotient.toInt())) break } } } @JvmOverloads fun getFilenamableTime(time: Instant = Clock.System.now()): String = FILENAMABLE_DATE_FORMAT.format(time.toLocalDateTime(TimeZone.currentSystemDefault())) fun getShortTime(time: Long): String = DATE_FORMAT_SHORT.format(time.toLocalDateTime(TimeZone.currentSystemDefault())) } ================================================ FILE: app/src/main/java/com/hippo/util/SDKUtils.kt ================================================ /* * Copyright 2024 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import android.os.Build import android.os.ext.SdkExtensions val isAtLeastN = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N val isAtLeastO = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O val isAtLeastOMR1 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 val isAtLeastP = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P val isAtLeastQ = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q val isAtLeastR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R val isAtLeastS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val isAtLeastT = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU val isAtLeastU = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE val isAtLeastSExtension7 = isAtLeastR && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7 ================================================ FILE: app/src/main/java/com/hippo/util/SqlUtils.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.util; import android.database.Cursor; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import java.util.ArrayList; import java.util.List; public class SqlUtils { public static void exeSQLSafely(SQLiteDatabase db, String sql) { try { db.execSQL(sql); } catch (SQLException e) { // Ignore } } public static void dropTable(SQLiteDatabase db, String tableName) { exeSQLSafely(db, "DROP TABLE IF EXISTS " + tableName); } public static void dropAllTable(SQLiteDatabase db) { List tables = new ArrayList<>(); Cursor cursor = db.rawQuery("SELECT * FROM sqlite_master WHERE type='table';", null); cursor.moveToFirst(); while (!cursor.isAfterLast()) { String tableName = cursor.getString(1); if (!tableName.equals("android_metadata") && !tableName.equals("sqlite_sequence")) tables.add(tableName); cursor.moveToNext(); } cursor.close(); for (String tableName : tables) { dropTable(db, tableName); } } public static String sqlEscapeString(String value) { StringBuilder sb = new StringBuilder(); int length = value.length(); for (int i = 0; i < length; i++) { char c = value.charAt(i); if (c == '\'') { sb.append('\''); } sb.append(c); } return sb.toString(); } public static boolean getBoolean(Cursor cursor, String column, boolean defValue) { try { int index = cursor.getColumnIndex(column); if (index != -1) { return cursor.getInt(index) != 0; } } catch (Throwable e) { /* Ignore */ } return defValue; } public static int getInt(Cursor cursor, String column, int defValue) { try { int index = cursor.getColumnIndex(column); if (index != -1) { return cursor.getInt(index); } } catch (Throwable e) { /* Ignore */ } return defValue; } public static long getLong(Cursor cursor, String column, long defValue) { try { int index = cursor.getColumnIndex(column); if (index != -1) { return cursor.getLong(index); } } catch (Throwable e) { /* Ignore */ } return defValue; } public static float getFloat(Cursor cursor, String column, float defValue) { try { int index = cursor.getColumnIndex(column); if (index != -1) { return cursor.getFloat(index); } } catch (Throwable e) { /* Ignore */ } return defValue; } public static String getString(Cursor cursor, String column, String defValue) { try { int index = cursor.getColumnIndex(column); if (index != -1) { return cursor.getString(index); } } catch (Throwable e) { /* Ignore */ } return defValue; } } ================================================ FILE: app/src/main/java/com/hippo/util/TextUrl.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.util; import android.text.Spannable; import android.text.SpannableString; import android.text.style.URLSpan; import java.util.regex.Matcher; import java.util.regex.Pattern; public final class TextUrl { private static final Pattern URL_PATTERN = Pattern.compile("(http|https)://[a-z0-9A-Z%-]+(\\.[a-z0-9A-Z%-]+)+(:\\d{1,5})?(/[a-zA-Z0-9-_~:#@!&',;=%/*.?+$\\[\\]()]+)?/?"); public static CharSequence handleTextUrl(CharSequence content) { Matcher m = URL_PATTERN.matcher(content); Spannable spannable = null; while (m.find()) { // Ensure spannable if (spannable == null) { if (content instanceof Spannable) { spannable = (Spannable) content; } else { spannable = new SpannableString(content); } } int start = m.start(); int end = m.end(); URLSpan[] links = spannable.getSpans(start, end, URLSpan.class); if (links.length > 0) { // There has been URLSpan already, leave it alone continue; } URLSpan urlSpan = new URLSpan(m.group(0)); spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } return spannable == null ? content : spannable; } } ================================================ FILE: app/src/main/java/com/hippo/util/URLEncoderCompat.kt ================================================ /* * Copyright 2023 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.util import java.net.URLEncoder import java.nio.charset.Charset import java.nio.charset.StandardCharsets fun encode(s: String, charset: Charset): String = if (isAtLeastT) { URLEncoder.encode(s, charset) } else { URLEncoder.encode(s, charset.name()) } fun encodeUTF8(s: String): String = encode(s, StandardCharsets.UTF_8) ================================================ FILE: app/src/main/java/com/hippo/view/BringOutTransition.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.view; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.view.View; import com.hippo.ehviewer.widget.SearchLayout; import com.hippo.widget.ContentLayout; public class BringOutTransition extends ViewTransition { public BringOutTransition(ContentLayout contentLayout, SearchLayout mSearchLayout) { super(contentLayout, mSearchLayout); } @Override protected void startAnimations(final View hiddenView, final View shownView) { mAnimator1 = hiddenView.animate().alpha(0).scaleY(0.7f).scaleX(0.7f); mAnimator1.setDuration(ANIMATE_TIME).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { hiddenView.setVisibility(View.GONE); mAnimator1 = null; } }).start(); shownView.setAlpha(0); shownView.setScaleX(0.7f); shownView.setScaleY(0.7f); shownView.setVisibility(View.VISIBLE); mAnimator2 = shownView.animate().alpha(1f).scaleX(1).scaleY(1); mAnimator2.setDuration(ANIMATE_TIME).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mAnimator2 = null; } }).start(); } } ================================================ FILE: app/src/main/java/com/hippo/view/ViewTransition.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.view; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.view.View; import android.view.ViewPropertyAnimator; public class ViewTransition { protected static final long ANIMATE_TIME = 300L; private final View[] mViews; protected ViewPropertyAnimator mAnimator1; protected ViewPropertyAnimator mAnimator2; private int mShownView = -1; private OnShowViewListener mOnShowViewListener; public ViewTransition(View... views) { if (views.length < 2) { throw new IllegalStateException("You must pass view to ViewTransition"); } for (View v : views) { if (v == null) { throw new IllegalStateException("Any View pass to ViewTransition must not be null"); } } mViews = views; showView(0, false); } public void setOnShowViewListener(OnShowViewListener listener) { mOnShowViewListener = listener; } public int getShownViewIndex() { return mShownView; } public boolean showView(int shownView) { return showView(shownView, true); } public boolean showView(int shownView, boolean animation) { View[] views = mViews; int length = views.length; if (shownView >= length || shownView < 0) { throw new IndexOutOfBoundsException("Only " + length + " view(s) in " + "the ViewTransition, but attempt to show " + shownView); } if (mShownView != shownView) { int oldShownView = mShownView; mShownView = shownView; // Cancel animation if (mAnimator1 != null) { mAnimator1.cancel(); } if (mAnimator2 != null) { mAnimator2.cancel(); } if (animation) { startAnimations(views[oldShownView], views[shownView]); } else { for (int i = 0; i < length; i++) { View v = views[i]; if (i == shownView) { v.setAlpha(1f); v.setVisibility(View.VISIBLE); } else { v.setAlpha(0f); v.setVisibility(View.GONE); } } } if (null != mOnShowViewListener) { mOnShowViewListener.onShowView(views[oldShownView], views[shownView]); } return true; } else { return false; } } protected void startAnimations(final View hiddenView, final View shownView) { mAnimator1 = hiddenView.animate().alpha(0); mAnimator1.setDuration(ANIMATE_TIME).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { hiddenView.setVisibility(View.GONE); mAnimator1 = null; } }).start(); shownView.setAlpha(0); shownView.setVisibility(View.VISIBLE); mAnimator2 = shownView.animate().alpha(1); mAnimator2.setDuration(ANIMATE_TIME).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mAnimator2 = null; } }).start(); } public interface OnShowViewListener { void onShowView(View hiddenView, View shownView); } } ================================================ FILE: app/src/main/java/com/hippo/widget/AutoWrapLayout.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Rect; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import com.hippo.ehviewer.R; import java.util.ArrayList; import java.util.List; /** * A ViewGroup that can layout views in line and auto wrap * * @author Hippo */ public class AutoWrapLayout extends ViewGroup { private static final Alignment[] sBaseLineArray = {Alignment.TOP, Alignment.CENTER, Alignment.BOTTOM}; private final List rectList = new ArrayList<>(); private Alignment mAlignment; public AutoWrapLayout(Context context) { super(context); } public AutoWrapLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public AutoWrapLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); //noinspection resource TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AutoWrapLayout, defStyle, 0); try { int index = a.getInt(R.styleable.AutoWrapLayout_alignment, -1); if (index >= 0) { setAlignment(sBaseLineArray[index]); } } finally { a.recycle(); } } public Alignment getAlignment() { return mAlignment; } public void setAlignment(Alignment baseLine) { if (baseLine == null) { return; } if (mAlignment != baseLine) { mAlignment = baseLine; requestLayout(); invalidate(); } } private void adjustBaseLine(int lineHeight, int startIndex, int endIndex) { if (mAlignment == Alignment.TOP) return; for (int index = startIndex; index < endIndex; index++) { final View child = getChildAt(index); final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); Rect rect = rectList.get(index); int offsetRaw = lineHeight - rect.height() - lp.topMargin - lp.bottomMargin; if (mAlignment == Alignment.CENTER) rect.offset(0, offsetRaw / 2); else if (mAlignment == Alignment.BOTTOM) rect.offset(0, offsetRaw); } } /** * each row or line at least show one child *

* horizontal only show child can show or partly show in parent */ @SuppressLint("DrawAllocation") @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int maxWidth = MeasureSpec.getSize(widthMeasureSpec); int maxHeight = MeasureSpec.getSize(heightMeasureSpec); if (widthMode == MeasureSpec.UNSPECIFIED) maxWidth = Integer.MAX_VALUE; if (heightMode == MeasureSpec.UNSPECIFIED) maxHeight = Integer.MAX_VALUE; int paddingLeft = getPaddingLeft(); int paddingTop = getPaddingTop(); int paddingRight = getPaddingRight(); int paddingBottom = getPaddingBottom(); int maxRightBound = maxWidth - paddingRight; int maxBottomBound = maxHeight - paddingBottom; int left; int top; int right; int bottom; int rightBound = paddingLeft; int maxRightNoPadding = rightBound; int bottomBound; int lastMaxBottom = paddingTop; int maxBottom = lastMaxBottom; int childWidth; int childHeight; int lineStartIndex = 0; int lineEndIndex; // endIndex + 1 rectList.clear(); int childCount = getChildCount(); for (int index = 0; index < childCount; index++) { final View child = getChildAt(index); child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); if (child.getVisibility() == View.GONE) continue; final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); childWidth = child.getMeasuredWidth(); childHeight = child.getMeasuredHeight(); left = rightBound + lp.leftMargin; right = left + childWidth; rightBound = right + lp.rightMargin; if (rightBound > maxRightBound) { // Go to next row lineEndIndex = index; // Adjust child position base on baseline adjustBaseLine(maxBottom - lastMaxBottom, lineStartIndex, lineEndIndex); // If child can't show in parent begin this line if (maxBottom >= maxBottomBound) break; // If it is first item in line, try to show it all if (lineEndIndex == lineStartIndex) { child.measure(MeasureSpec.makeMeasureSpec( maxWidth - paddingLeft - paddingRight - lp.leftMargin - lp.rightMargin, MeasureSpec.AT_MOST), MeasureSpec.UNSPECIFIED); childWidth = child.getMeasuredWidth(); childHeight = child.getMeasuredHeight(); } left = paddingLeft + lp.leftMargin; right = left + childWidth; rightBound = right + lp.rightMargin; lastMaxBottom = maxBottom; top = lastMaxBottom + lp.topMargin; bottom = top + childHeight; bottomBound = bottom + lp.bottomMargin; lineStartIndex = index; } else { top = lastMaxBottom + lp.topMargin; bottom = top + childHeight; bottomBound = bottom + lp.bottomMargin; } // Update max if (rightBound > maxRightNoPadding) maxRightNoPadding = rightBound; if (bottomBound > maxBottom) maxBottom = bottomBound; Rect rect = new Rect(); rect.left = left; rect.top = top; rect.right = right; rect.bottom = bottom; rectList.add(rect); } // Handle last line baseline adjustBaseLine(maxBottom - lastMaxBottom, lineStartIndex, rectList.size()); int measuredWidth; int measuredHeight; if (widthMode == MeasureSpec.EXACTLY) measuredWidth = maxWidth; else measuredWidth = maxRightNoPadding + paddingRight; if (heightMode == MeasureSpec.EXACTLY) measuredHeight = maxHeight; else { measuredHeight = maxBottom + paddingBottom; if (heightMode == MeasureSpec.AT_MOST) measuredHeight = Math.min(measuredHeight, maxHeight); } setMeasuredDimension(measuredWidth, measuredHeight); } // TODO Take vertical mode @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { final int count = rectList.size(); for (int i = 0; i < count; i++) { final View child = this.getChildAt(i); if (child.getVisibility() == View.GONE) continue; Rect rect = rectList.get(i); child.layout(rect.left, rect.top, rect.right, rect.bottom); } } @Override public MarginLayoutParams generateLayoutParams(AttributeSet attrs) { return new MarginLayoutParams(getContext(), attrs); } @Override protected MarginLayoutParams generateDefaultLayoutParams() { return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); } @Override protected MarginLayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { return new MarginLayoutParams(p); } public enum Alignment { TOP(0), CENTER(1), BOTTOM(2); final int nativeInt; Alignment(int ni) { nativeInt = ni; } } } ================================================ FILE: app/src/main/java/com/hippo/widget/BatteryView.kt ================================================ /* * Copyright (C) 2014 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Color import android.os.BatteryManager import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.withStyledAttributes import com.hippo.drawable.BatteryDrawable import com.hippo.ehviewer.R class BatteryView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : AppCompatTextView( context, attrs, defStyleAttr, ) { private var mColor = 0 private var mWarningColor = 0 private var mCurrentColor = 0 private var mLevel = 0 private var mCharging = false private var mDrawable: BatteryDrawable? = null private var mAttached = false private var mIsChargerWorking = false private val mCharger: Runnable = object : Runnable { private var level = 0 override fun run() { level += 2 if (level > 100) { level = 0 } mDrawable!!.setElect(level, false) getHandler().postDelayed(this, 200) } } private val mIntentReceiver: BroadcastReceiver = object : BroadcastReceiver() { @SuppressLint("SetTextI18n") override fun onReceive(context: Context, intent: Intent) { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, 0) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, 0) val charging = ( intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) == BatteryManager.BATTERY_STATUS_CHARGING ) if (mLevel != level || mCharging != charging) { mLevel = level mCharging = charging if (mCharging && mLevel != 100) { startCharger() } else { stopCharger() mDrawable!!.setElect(mLevel) } if (level <= BatteryDrawable.WARN_LIMIT && !charging) { setTextColor(mWarningColor) } else { setTextColor(mColor) } text = "$mLevel%" } } } init { init() context.withStyledAttributes( attrs, R.styleable.BatteryView, defStyleAttr, 0, ) { mColor = getColor(R.styleable.BatteryView_color, Color.WHITE) mWarningColor = getColor(R.styleable.BatteryView_warningColor, Color.RED) } mDrawable!!.setColor(mColor) mDrawable!!.setWarningColor(mWarningColor) } private fun init() { mDrawable = BatteryDrawable() val height = textSize.toInt() mDrawable!!.setBounds(0, 0, (height / 0.618f).toInt(), height) setCompoundDrawables(mDrawable, null, null, null) } override fun setTextColor(color: Int) { if (mCurrentColor == color) { return } mCurrentColor = color super.setTextColor(color) } private fun startCharger() { if (!mIsChargerWorking) { getHandler().post(mCharger) mIsChargerWorking = true } } private fun stopCharger() { if (mIsChargerWorking) { getHandler().removeCallbacks(mCharger) mIsChargerWorking = false } } override fun onAttachedToWindow() { super.onAttachedToWindow() if (!mAttached) { mAttached = true registerReceiver() } } override fun onDetachedFromWindow() { super.onDetachedFromWindow() if (mAttached) { unregisterReceiver() stopCharger() mAttached = false } } private fun registerReceiver() { val filter = IntentFilter() filter.addAction(Intent.ACTION_BATTERY_CHANGED) context.registerReceiver(mIntentReceiver, filter, null, getHandler()) } private fun unregisterReceiver() { context.unregisterReceiver(mIntentReceiver) } } ================================================ FILE: app/src/main/java/com/hippo/widget/CheckTextView.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget import android.content.Context import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.Gravity import android.view.View import androidx.appcompat.widget.AppCompatCheckedTextView import androidx.core.content.withStyledAttributes import com.hippo.ehviewer.R open class CheckTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : AppCompatCheckedTextView(context, attrs, defStyleAttr), View.OnClickListener { private val mSelfBounds = Rect() private val mOverlayBounds = Rect() private var mForegroundInPadding = true private var mForegroundBoundsChanged = false private var mForeground: Drawable? = null private var mForegroundGravity = Gravity.FILL init { context.withStyledAttributes( attrs, R.styleable.CheckTextView, defStyleAttr, 0, ) { mForegroundGravity = getInt( R.styleable.CheckTextView_android_foregroundGravity, mForegroundGravity, ) getDrawable(R.styleable.CheckTextView_android_foreground)?.let { foreground = it } mForegroundInPadding = getBoolean( R.styleable.CheckTextView_foregroundInsidePadding, true, ) } setOnClickListener(this) } /** * Describes how the foreground is positioned. * * @return foreground gravity. * @see .setForegroundGravity */ override fun getForegroundGravity(): Int = mForegroundGravity /** * Describes how the foreground is positioned. Defaults to START and TOP. * * @param foregroundGravity See [android.view.Gravity] * @see .getForegroundGravity */ override fun setForegroundGravity(foregroundGravity: Int) { var sForegroundGravity = foregroundGravity if (mForegroundGravity != sForegroundGravity) { if (sForegroundGravity and Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK == 0) { sForegroundGravity = sForegroundGravity or Gravity.START } if (sForegroundGravity and Gravity.VERTICAL_GRAVITY_MASK == 0) { sForegroundGravity = sForegroundGravity or Gravity.TOP } mForegroundGravity = sForegroundGravity if (mForegroundGravity == Gravity.FILL && mForeground != null) { val padding = Rect() mForeground!!.getPadding(padding) } requestLayout() } } override fun verifyDrawable(who: Drawable): Boolean = super.verifyDrawable(who) || who === mForeground override fun jumpDrawablesToCurrentState() { super.jumpDrawablesToCurrentState() if (mForeground != null) { mForeground!!.jumpToCurrentState() } } override fun drawableStateChanged() { super.drawableStateChanged() if (mForeground != null && mForeground!!.isStateful) { mForeground!!.state = drawableState } } /** * Returns the drawable used as the foreground of this FrameLayout. The * foreground drawable, if non-null, is always drawn on top of the children. * * @return A Drawable or null if no foreground was set. */ override fun getForeground(): Drawable = mForeground!! /** * Supply a Drawable that is to be rendered on top of all of the child * views in the frame layout. Any padding in the Drawable will be taken * into account by ensuring that the children are inset to be placed * inside of the padding area. * * @param drawable The Drawable to be drawn on top of the children. */ override fun setForeground(drawable: Drawable?) { if (mForeground !== drawable) { mForeground?.let { it.callback = null unscheduleDrawable(it) } mForeground = drawable if (drawable != null) { setWillNotDraw(false) drawable.callback = this if (drawable.isStateful) { drawable.state = drawableState } if (mForegroundGravity == Gravity.FILL) { val padding = Rect() drawable.getPadding(padding) } } else { setWillNotDraw(true) } requestLayout() invalidate() } } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) mForegroundBoundsChanged = mForegroundBoundsChanged or changed } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) mForegroundBoundsChanged = true } override fun draw(canvas: Canvas) { super.draw(canvas) if (mForeground != null) { val foreground: Drawable = mForeground!! if (mForegroundBoundsChanged) { mForegroundBoundsChanged = false val selfBounds = mSelfBounds val overlayBounds = mOverlayBounds val w = right - left val h = bottom - top if (mForegroundInPadding) { selfBounds[0, 0, w] = h } else { selfBounds[paddingLeft, paddingTop, w - paddingRight] = h - paddingBottom } Gravity.apply( mForegroundGravity, foreground.intrinsicWidth, foreground.intrinsicHeight, selfBounds, overlayBounds, ) foreground.bounds = overlayBounds } foreground.draw(canvas) } } override fun drawableHotspotChanged(x: Float, y: Float) { super.drawableHotspotChanged(x, y) if (mForeground != null) { mForeground!!.setHotspot(x, y) } } override fun onClick(v: View) { isChecked = !isChecked } } ================================================ FILE: app/src/main/java/com/hippo/widget/ColorView.java ================================================ /* * Copyright (C) 2014 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.Context; import android.graphics.Canvas; import android.util.AttributeSet; import android.view.View; public class ColorView extends View { private int mColor; public ColorView(Context context) { super(context); } public ColorView(Context context, AttributeSet attrs) { super(context, attrs); } public ColorView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public void setColor(int color) { if (mColor != color) { mColor = color; invalidate(); } } @Override protected void onDraw(Canvas canvas) { canvas.drawColor(mColor); } } ================================================ FILE: app/src/main/java/com/hippo/widget/ContentLayout.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget import android.app.Activity import android.content.Context import android.os.Bundle import android.os.Parcelable import android.util.AttributeSet import android.util.Log import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import com.google.android.material.progressindicator.CircularProgressIndicator import com.google.android.material.progressindicator.LinearProgressIndicator import com.hippo.easyrecyclerview.EasyRecyclerView import com.hippo.easyrecyclerview.FastScroller import com.hippo.easyrecyclerview.HandlerDrawable import com.hippo.easyrecyclerview.LayoutManagerUtils import com.hippo.easyrecyclerview.LayoutManagerUtils.OnScrollToPositionListener import com.hippo.ehviewer.EhApplication import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhUrl import com.hippo.ehviewer.client.exception.CloudflareBypassException import com.hippo.ehviewer.ui.WebViewActivity import com.hippo.util.ExceptionUtils import com.hippo.util.getParcelableCompat import com.hippo.view.ViewTransition import com.hippo.view.ViewTransition.OnShowViewListener import com.hippo.yorozuya.IntIdGenerator import com.hippo.yorozuya.LayoutUtils import com.hippo.yorozuya.collect.IntList import rikka.core.res.resolveColor class ContentLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, ) : FrameLayout(context, attrs) { private lateinit var mContentHelper: ContentHelper<*> private val mProgressView: CircularProgressIndicator private val mTipView: TextView private val mContentView: ViewGroup private val mRefreshLayout: SwipeRefreshLayout private val mBottomProgress: LinearProgressIndicator private val mRecyclerView: EasyRecyclerView private val mFastScroller: FastScroller private val mRecyclerViewOriginBottom: Int private val mFastScrollerOriginBottom: Int init { (context as Activity).layoutInflater.inflate(R.layout.widget_content_layout, this) clipChildren = false clipToPadding = false mProgressView = findViewById(R.id.progress) mTipView = findViewById(R.id.tip) mContentView = findViewById(R.id.content_view) mRefreshLayout = mContentView.findViewById(R.id.refresh_layout) mBottomProgress = mContentView.findViewById(R.id.bottom_progress) mFastScroller = mContentView.findViewById(R.id.fast_scroller) mRecyclerView = mRefreshLayout.findViewById(R.id.recycler_view) mFastScroller.attachToRecyclerView(mRecyclerView) val drawable = HandlerDrawable() drawable.setColor(context.theme.resolveColor(R.attr.widgetColorThemeAccent)) mFastScroller.setHandlerDrawable(drawable) mRefreshLayout.setColorSchemeResources( R.color.loading_indicator_red, R.color.loading_indicator_purple, R.color.loading_indicator_blue, R.color.loading_indicator_cyan, R.color.loading_indicator_green, R.color.loading_indicator_yellow, ) mBottomProgress.setIndicatorColor( context.getColor(R.color.loading_indicator_red), context.getColor(R.color.loading_indicator_blue), context.getColor(R.color.loading_indicator_green), context.getColor(R.color.loading_indicator_orange), ) mBottomProgress.indeterminateAnimationType = LinearProgressIndicator.INDETERMINATE_ANIMATION_TYPE_CONTIGUOUS mRecyclerViewOriginBottom = mRecyclerView.paddingBottom mFastScrollerOriginBottom = mFastScroller.paddingBottom } val recyclerView get() = mRecyclerView val fastScroller get() = mFastScroller fun setHelper(helper: ContentHelper<*>) { mContentHelper = helper helper.init(this) } fun hideFastScroll() { mFastScroller.detachedFromRecyclerView() } override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { super.setPadding(left, top, right, 0) setFitPaddingBottom(bottom) } fun setFitPaddingTop(fitPaddingTop: Int) { // RefreshLayout mRefreshLayout.setProgressViewOffset( true, 0, fitPaddingTop + LayoutUtils.dp2pix(context, 32f), ) // TODO } private fun setFitPaddingBottom(fitPaddingBottom: Int) { // RecyclerView mRecyclerView.setPadding( mRecyclerView.paddingLeft, mRecyclerView.paddingTop, mRecyclerView.paddingRight, mRecyclerViewOriginBottom + fitPaddingBottom, ) mTipView.setPadding( mTipView.paddingLeft, mTipView.paddingTop, mTipView.paddingRight, fitPaddingBottom, ) mProgressView.setPadding( mProgressView.paddingLeft, mProgressView.paddingTop, mProgressView.paddingRight, fitPaddingBottom, ) mFastScroller.setPadding( mFastScroller.paddingLeft, mFastScroller.paddingTop, mFastScroller.paddingRight, mFastScrollerOriginBottom + fitPaddingBottom, ) if (fitPaddingBottom > LayoutUtils.dp2pix(context, 16f)) { mBottomProgress.setPadding(0, 0, 0, fitPaddingBottom) } else { mBottomProgress.setPadding(0, 0, 0, 0) } } override fun onSaveInstanceState(): Parcelable = mContentHelper.saveInstanceState(super.onSaveInstanceState()) override fun onRestoreInstanceState(state: Parcelable) { super.onRestoreInstanceState(mContentHelper.restoreInstanceState(state)) } abstract class ContentHelper : OnShowViewListener { /** * Generate task id */ private val mIdGenerator = IntIdGenerator() private val mOnScrollToPositionListener = OnScrollToPositionListener { position: Int -> onScrollToPosition(position) } protected var mPrev: String? = null protected var mNext: String? = null private var mTipView: TextView? = null private var mRefreshLayout: SwipeRefreshLayout? = null private var mBottomProgress: LinearProgressIndicator? = null private var mRecyclerView: EasyRecyclerView? = null private var mViewTransition: ViewTransition? = null /** * Store data */ private var mData = ArrayList() /** * Store the page divider index * * * For example, the data contain page 3, page 4, page 5, * page 3 size is 7, page 4 size is 8, page 5 size is 9, * so `mPageDivider` contain 7, 15, 24. */ private var mPageDivider: IntList? = IntList() /** * The first page in `mData` */ private var mStartPage = 0 /** * The last page + 1 in `mData` */ private var mEndPage = 0 /** * The available page count. */ var pages = 0 private set private var mNextPage = 0 private var mCurrentTaskId = 0 private var mCurrentTaskType = 0 private var mCurrentTaskPage = 0 private val mOnRefreshListener = OnRefreshListener { if (mPrev != null || mStartPage > 0) { mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_PRE_PAGE_KEEP_POS mCurrentTaskPage = mStartPage - 1 getPageData(mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, mPrev, false) } else { doRefresh() } } private var mNextPageScrollSize = 0 private var mEmptyString = "No hint" private val mOnScrollListener: RecyclerView.OnScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (!mRefreshLayout!!.isRefreshing && !recyclerView.canScrollVertically(1)) { if (mNext != null || mEndPage < pages) { mBottomProgress!!.show() // Get next page // Fill pages before NextPage with empty list while (mNextPage > mEndPage && mEndPage < pages) { mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_NEXT_PAGE_KEEP_POS mCurrentTaskPage = mEndPage onGetPageData( mCurrentTaskId, pages, mNextPage, null, null, emptyList(), ) } mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_NEXT_PAGE_KEEP_POS mCurrentTaskPage = mEndPage getPageData( mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, mNext, true, ) } else if (mStartPage > 0 && mEndPage == pages) { mBottomProgress!!.show() // Refresh last page mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_REFRESH_PAGE mCurrentTaskPage = mEndPage - 1 getPageData( mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, null, true, ) } } } } private var mSavedDataId = IntIdGenerator.INVALID_ID fun init(contentLayout: ContentLayout) { mNextPageScrollSize = LayoutUtils.dp2pix(contentLayout.context, 48f) val mProgressView = contentLayout.mProgressView mTipView = contentLayout.mTipView val mContentView = contentLayout.mContentView mRefreshLayout = contentLayout.mRefreshLayout mBottomProgress = contentLayout.mBottomProgress mRecyclerView = contentLayout.mRecyclerView val drawable = ContextCompat.getDrawable(context, R.drawable.big_sad_pandroid)!! drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) mTipView!!.setCompoundDrawables(null, drawable, null, null) mViewTransition = ViewTransition(mContentView, mProgressView, mTipView) mViewTransition!!.setOnShowViewListener(this) mRecyclerView!!.addOnScrollListener(mOnScrollListener) mRefreshLayout!!.setOnRefreshListener(mOnRefreshListener) mTipView!!.setOnClickListener { refresh() } } /** * Call [.onGetPageData] when get data * * @param taskId task id * @param page the page to get * @param index the index to get * @param isNext the index is next or prev */ protected abstract fun getPageData( taskId: Int, type: Int, page: Int, index: String?, isNext: Boolean, ) protected abstract val context: Context protected abstract fun notifyDataSetChanged() protected abstract fun notifyItemRangeInserted(positionStart: Int, itemCount: Int) protected open fun onScrollToPosition(position: Int) {} override fun onShowView(hiddenView: View, shownView: View) {} val shownViewIndex: Int get() = mViewTransition!!.shownViewIndex fun setRefreshLayoutEnable(enable: Boolean) { mRefreshLayout!!.isEnabled = enable } fun setEnable(enable: Boolean) { mRefreshLayout!!.isEnabled = enable } fun setEmptyString(str: String) { mEmptyString = str } val data: List get() = mData fun getDataAtEx(location: Int): E? = if (location >= 0 && location < mData.size) { mData[location] } else { null } val firstVisibleItem: E? get() = getDataAtEx(LayoutManagerUtils.getFirstVisibleItemPosition(mRecyclerView!!.layoutManager!!)) fun size(): Int = mData.size fun isCurrentTask(taskId: Int): Boolean = mCurrentTaskId == taskId protected abstract fun isDuplicate(d1: E, d2: E): Boolean private fun removeDuplicateData(data: List, start: Int, end: Int) { val slicedData = mData.slice(start.coerceAtLeast(0) until end.coerceAtMost(mData.size)) data.dropWhile { d1 -> slicedData.any { d2 -> isDuplicate(d1, d2) } } } protected open fun onAddData(data: List) {} protected open fun onRemoveData(data: List) {} protected open fun onClearData() {} fun onGetPageData( taskId: Int, pages: Int, nextPage: Int, prev: String?, next: String?, data: List, ) { if (mCurrentTaskId == taskId) { val dataSize: Int when (mCurrentTaskType) { TYPE_REFRESH -> { mStartPage = 0 mEndPage = 1 this.pages = pages mNextPage = nextPage mPrev = prev mNext = next mPageDivider!!.clear() mPageDivider!!.add(data.size) if (data.isEmpty()) { mData.clear() onClearData() notifyDataSetChanged() // Not found // Ui change, show empty string mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showEmptyString() } else { mData.clear() onClearData() mData.addAll(data) onAddData(data) notifyDataSetChanged() // Ui change, show content mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showContent() // RecyclerView scroll if (mRecyclerView!!.isAttachedToWindow) { mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, 0, 0, ) onScrollToPosition(0) } } } TYPE_PRE_PAGE, TYPE_PRE_PAGE_KEEP_POS -> { removeDuplicateData(data, 0, CHECK_DUPLICATE_RANGE) dataSize = data.size var i = 0 val n = mPageDivider!!.size while (i < n) { mPageDivider!![i] = mPageDivider!![i] + dataSize i++ } mPageDivider!!.add(0, dataSize) mStartPage-- this.pages = pages.coerceAtLeast(mEndPage) mPrev = prev // assert mStartPage >= 0 if (data.isEmpty()) { // OK, that's all if (mData.isEmpty()) { // Ui change, show empty string mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showEmptyString() } else { // Ui change, show content mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showContent() if (mCurrentTaskType == TYPE_PRE_PAGE && mRecyclerView!!.isAttachedToWindow) { // RecyclerView scroll, to top mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, 0, 0, ) onScrollToPosition(0) } } } else { mData.addAll(0, data) onAddData(data) notifyItemRangeInserted(0, data.size) // Ui change, show content mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showContent() if (mRecyclerView!!.isAttachedToWindow) { // RecyclerView scroll if (mCurrentTaskType == TYPE_PRE_PAGE_KEEP_POS) { mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionProperly( mRecyclerView!!.layoutManager!!, context, dataSize - 1, mOnScrollToPositionListener, ) } else { mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, 0, 0, ) onScrollToPosition(0) } } } } TYPE_NEXT_PAGE, TYPE_NEXT_PAGE_KEEP_POS -> { removeDuplicateData(data, mData.size - CHECK_DUPLICATE_RANGE, mData.size) dataSize = data.size val oldDataSize = mData.size mPageDivider!!.add(oldDataSize + dataSize) mEndPage++ mNextPage = nextPage this.pages = pages.coerceAtLeast(mEndPage) mNext = next if (data.isEmpty()) { // OK, that's all if (mData.isEmpty()) { // Ui change, show empty string mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showEmptyString() } else { // Ui change, show content mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showContent() if (mCurrentTaskType == TYPE_NEXT_PAGE && mRecyclerView!!.isAttachedToWindow) { // RecyclerView scroll mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, oldDataSize, 0, ) onScrollToPosition(oldDataSize) } } } else { mData.addAll(data) onAddData(data) notifyItemRangeInserted(oldDataSize, dataSize) // Ui change, show content mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showContent() if (mRecyclerView!!.isAttachedToWindow) { if (mCurrentTaskType == TYPE_NEXT_PAGE_KEEP_POS) { mRecyclerView!!.stopScroll() mRecyclerView!!.smoothScrollBy(0, mNextPageScrollSize) } else { mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, oldDataSize, 0, ) onScrollToPosition(oldDataSize) } } } } TYPE_SOMEWHERE -> { mStartPage = mCurrentTaskPage mEndPage = mCurrentTaskPage + 1 mNextPage = nextPage this.pages = pages mPrev = prev mNext = next mPageDivider!!.clear() mPageDivider!!.add(data.size) if (data.isEmpty()) { mData.clear() onClearData() notifyDataSetChanged() // Not found // Ui change, show empty string mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showEmptyString() } else { mData.clear() onClearData() mData.addAll(data) onAddData(data) notifyDataSetChanged() // Ui change, show content mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showContent() if (mRecyclerView!!.isAttachedToWindow) { // RecyclerView scroll mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, 0, 0, ) onScrollToPosition(0) } } } TYPE_REFRESH_PAGE -> { if (mCurrentTaskPage < mStartPage || mCurrentTaskPage >= mEndPage) { Log.e( TAG, "TYPE_REFRESH_PAGE, but mCurrentTaskPage = " + mCurrentTaskPage + ", mStartPage = " + mStartPage + ", mEndPage = " + mEndPage, ) return } if (mCurrentTaskPage == mEndPage - 1) { mNextPage = nextPage } this.pages = pages.coerceAtLeast(mEndPage) val oldIndexStart = if (mCurrentTaskPage == mStartPage) 0 else mPageDivider!![mCurrentTaskPage - mStartPage - 1] val oldIndexEnd = mPageDivider!![mCurrentTaskPage - mStartPage] val toRemove = mData.subList(oldIndexStart, oldIndexEnd) onRemoveData(toRemove) toRemove.clear() removeDuplicateData( data, oldIndexStart - CHECK_DUPLICATE_RANGE, oldIndexStart + CHECK_DUPLICATE_RANGE, ) val newIndexEnd = oldIndexStart + data.size mData.addAll(oldIndexStart, data) onAddData(data) notifyDataSetChanged() var i = mCurrentTaskPage - mStartPage val n = mPageDivider!!.size while (i < n) { mPageDivider!![i] = mPageDivider!![i] - oldIndexEnd + newIndexEnd i++ } if (mData.isEmpty()) { // Ui change, show empty string mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showEmptyString() } else { // Ui change, show content mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() showContent() // RecyclerView scroll if (newIndexEnd > oldIndexEnd && newIndexEnd > 0 && mRecyclerView!!.isAttachedToWindow) { mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, newIndexEnd - 1, 0, ) onScrollToPosition(newIndexEnd - 1) } } } } } } fun onGetException(taskId: Int, e: Exception?) { if (mCurrentTaskId == taskId) { mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() val readableError = ExceptionUtils.getReadableString(e) if (mViewTransition!!.shownViewIndex == 0) { Toast.makeText(context, readableError, Toast.LENGTH_SHORT).show() } else { showText(readableError) } if (e?.cause is CloudflareBypassException) { val dialog = AlertDialog.Builder(context) .setTitle(R.string.cloudflare_bypass_failed) .setMessage(R.string.open_in_webview) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> context.startActivity(WebViewActivity.newIntent(context, EhUrl.host)) } dialog.show() } } } private fun showContent() { mViewTransition!!.showView(0) } private val isContentShowing: Boolean get() = mViewTransition!!.shownViewIndex == 0 fun showProgressBar(animation: Boolean = true) { mViewTransition!!.showView(1, animation) } private fun showText(text: CharSequence?) { mTipView!!.text = text mViewTransition!!.showView(2) } private fun showEmptyString() { showText(mEmptyString) } private fun doRefresh() { mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_REFRESH mCurrentTaskPage = 0 getPageData(mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, null, true) } /** * Like [.refresh], but no animation when show progress bar */ fun firstRefresh() { showProgressBar(false) doRefresh() } /** * Show progress bar first, than do refresh */ fun refresh() { showProgressBar() doRefresh() } private fun cancelCurrentTask() { mCurrentTaskId = mIdGenerator.nextId() mRefreshLayout!!.isRefreshing = false mBottomProgress!!.hide() } private fun getPageStart(page: Int): Int = if (mStartPage == page) { 0 } else { mPageDivider!![page - mStartPage - 1] } private fun getPageForPosition(position: Int): Int { if (position < 0) { return -1 } val pageDivider = mPageDivider var i = 0 val n = pageDivider!!.size while (i < n) { if (position < pageDivider[i]) { return i + mStartPage } i++ } return -1 } val pageForTop: Int get() = getPageForPosition(LayoutManagerUtils.getFirstVisibleItemPosition(mRecyclerView!!.layoutManager!!)) val pageForBottom: Int get() = getPageForPosition(LayoutManagerUtils.getLastVisibleItemPosition(mRecyclerView!!.layoutManager!!)) fun canGoTo(): Boolean = isContentShowing /** * Check range first! * * @param page the target page * @throws IndexOutOfBoundsException */ @Throws(IndexOutOfBoundsException::class) fun goTo(page: Int) { if (page < 0 || (page >= pages && pages != 0)) { throw IndexOutOfBoundsException("Page count is $pages, page is $page") } else if (page in mStartPage until mEndPage) { cancelCurrentTask() val position = getPageStart(page) mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset( mRecyclerView!!.layoutManager!!, position, 0, ) onScrollToPosition(position) } else if (page == mStartPage - 1) { mRefreshLayout!!.isRefreshing = true mBottomProgress!!.hide() mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_PRE_PAGE mCurrentTaskPage = page getPageData(mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, null, true) } else if (page == mEndPage) { mRefreshLayout!!.isRefreshing = false mBottomProgress!!.show() mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_NEXT_PAGE mCurrentTaskPage = page getPageData(mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, null, true) } else { mRefreshLayout!!.isRefreshing = true mBottomProgress!!.hide() mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_SOMEWHERE mCurrentTaskPage = page getPageData(mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, null, true) } } fun goTo(index: String?, isNext: Boolean) { mRefreshLayout!!.isRefreshing = true mBottomProgress!!.hide() mCurrentTaskId = mIdGenerator.nextId() mCurrentTaskType = TYPE_SOMEWHERE mCurrentTaskPage = 0 getPageData(mCurrentTaskId, mCurrentTaskType, mCurrentTaskPage, index, isNext) } fun scrollTo(position: Int) { cancelCurrentTask() mRecyclerView!!.stopScroll() LayoutManagerUtils.scrollToPositionWithOffset(mRecyclerView!!.layoutManager!!, position, 0) onScrollToPosition(position) } open fun saveInstanceState(superState: Parcelable?): Parcelable { if (mData.isNotEmpty()) cancelCurrentTask() val bundle = Bundle() bundle.putParcelable(KEY_SUPER, superState) val shownView = mViewTransition!!.shownViewIndex bundle.putInt(KEY_SHOWN_VIEW, shownView) bundle.putString(KEY_TIP, mTipView!!.text.toString()) // TODO It's a bad design val app = context.applicationContext as EhApplication if (mSavedDataId != IntIdGenerator.INVALID_ID) { app.removeGlobalStuff(mSavedDataId) mSavedDataId = IntIdGenerator.INVALID_ID } mSavedDataId = app.putGlobalStuff(mData) bundle.putInt(KEY_DATA, mSavedDataId) bundle.putInt(KEY_NEXT_ID, mIdGenerator.nextId()) bundle.putParcelable(KEY_PAGE_DIVIDER, mPageDivider) bundle.putInt(KEY_START_PAGE, mStartPage) bundle.putInt(KEY_END_PAGE, mEndPage) bundle.putInt(KEY_PAGES, pages) bundle.putString(KEY_PREV, mPrev) bundle.putString(KEY_NEXT, mNext) return bundle } @Suppress("UNCHECKED_CAST") open fun restoreInstanceState(state: Parcelable): Parcelable? = if (state is Bundle) { mViewTransition!!.showView(state.getInt(KEY_SHOWN_VIEW), false) mTipView!!.text = state.getString(KEY_TIP) mSavedDataId = state.getInt(KEY_DATA) var newData: ArrayList? = null val app = context.applicationContext as EhApplication if (mSavedDataId != IntIdGenerator.INVALID_ID) { newData = app.removeGlobalStuff(mSavedDataId) as ArrayList? mSavedDataId = IntIdGenerator.INVALID_ID if (newData != null) { mData = newData } } mIdGenerator.setNextId(state.getInt(KEY_NEXT_ID)) mPageDivider = state.getParcelableCompat(KEY_PAGE_DIVIDER) mStartPage = state.getInt(KEY_START_PAGE) mEndPage = state.getInt(KEY_END_PAGE) pages = state.getInt(KEY_PAGES) mPrev = state.getString(KEY_PREV) mNext = state.getString(KEY_NEXT) notifyDataSetChanged() if (newData == null) { mPageDivider!!.clear() mStartPage = 0 mEndPage = 0 pages = 0 mPrev = null mNext = null firstRefresh() } state.getParcelableCompat(KEY_SUPER) } else { state } companion object { const val TYPE_REFRESH = 0 const val TYPE_PRE_PAGE = 1 const val TYPE_PRE_PAGE_KEEP_POS = 2 const val TYPE_NEXT_PAGE = 3 const val TYPE_NEXT_PAGE_KEEP_POS = 4 const val TYPE_SOMEWHERE = 5 const val TYPE_REFRESH_PAGE = 6 private val TAG = ContentHelper::class.java.simpleName private const val CHECK_DUPLICATE_RANGE = 50 private const val KEY_SUPER = "super" private const val KEY_SHOWN_VIEW = "shown_view" private const val KEY_TIP = "tip" private const val KEY_DATA = "data" private const val KEY_NEXT_ID = "next_id" private const val KEY_PAGE_DIVIDER = "page_divider" private const val KEY_START_PAGE = "start_page" private const val KEY_END_PAGE = "end_page" private const val KEY_PAGES = "pages" private const val KEY_PREV = "prev" private const val KEY_NEXT = "next" } } } ================================================ FILE: app/src/main/java/com/hippo/widget/CuteSpinner.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.util.AttributeSet; import android.widget.ArrayAdapter; import androidx.appcompat.widget.AppCompatSpinner; import com.hippo.ehviewer.R; public class CuteSpinner extends AppCompatSpinner { public CuteSpinner(Context context) { super(context); init(context, null, androidx.appcompat.R.attr.spinnerStyle); } public CuteSpinner(Context context, int mode) { super(context, mode); init(context, null, androidx.appcompat.R.attr.spinnerStyle); } public CuteSpinner(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs, androidx.appcompat.R.attr.spinnerStyle); } public CuteSpinner(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr); } public CuteSpinner(Context context, AttributeSet attrs, int defStyleAttr, int mode) { super(context, attrs, defStyleAttr, mode); init(context, attrs, defStyleAttr); } public CuteSpinner(Context context, AttributeSet attrs, int defStyleAttr, int mode, Resources.Theme popupTheme) { super(context, attrs, defStyleAttr, mode, popupTheme); init(context, attrs, defStyleAttr); } @SuppressLint("CustomViewStyleable") private void init(Context context, AttributeSet attrs, int defStyleAttr) { //noinspection resource TypedArray a = context.obtainStyledAttributes(attrs, androidx.appcompat.R.styleable.Spinner, defStyleAttr, 0); try { final CharSequence[] entries = a.getTextArray(androidx.appcompat.R.styleable.Spinner_android_entries); if (entries != null) { final ArrayAdapter adapter = new ArrayAdapter<>(context, R.layout.item_cute_spinner_item, entries); adapter.setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item); setAdapter(adapter); } } finally { a.recycle(); } } } ================================================ FILE: app/src/main/java/com/hippo/widget/DateUtils.java ================================================ /* * Copyright (C) 2014 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; final class DateUtils { public static final char QUOTE = '\''; public static final char SECONDS = 's'; public static boolean hasSeconds(CharSequence inFormat) { return hasDesignator(inFormat, SECONDS); } public static boolean hasDesignator(CharSequence inFormat, char designator) { if (inFormat == null) return false; final int length = inFormat.length(); int c; int count; for (int i = 0; i < length; i += count) { count = 1; c = inFormat.charAt(i); if (c == QUOTE) { count = skipQuotedText(inFormat, i, length); } else if (c == designator) { return true; } } return false; } private static int skipQuotedText(CharSequence s, int i, int len) { if (i + 1 < len && s.charAt(i + 1) == QUOTE) { return 2; } int count = 1; // skip leading quote i++; while (i < len) { char c = s.charAt(i); if (c == QUOTE) { count++; // QUOTEQUOTE -> QUOTE if (i + 1 < len && s.charAt(i + 1) == QUOTE) { i++; } else { break; } } else { i++; count++; } } return count; } } ================================================ FILE: app/src/main/java/com/hippo/widget/DrawerView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.widget.FrameLayout; import com.hippo.yorozuya.LayoutUtils; public class DrawerView extends FrameLayout { private static final int DEFAULT_MAX_WIDTH = 280; private static final int[] SIZE_ATTRS = new int[]{ android.R.attr.maxWidth }; private int mMaxWidth; public DrawerView(Context context) { super(context); init(context, null); } public DrawerView(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public DrawerView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } private void init(Context context, AttributeSet attrs) { //noinspection resource TypedArray a = context.obtainStyledAttributes(attrs, SIZE_ATTRS); try { mMaxWidth = a.getDimensionPixelOffset(0, LayoutUtils.dp2pix(context, DEFAULT_MAX_WIDTH)); } finally { a.recycle(); } } @Override protected void onMeasure(int widthSpec, int heightSpec) { switch (MeasureSpec.getMode(widthSpec)) { case MeasureSpec.EXACTLY: // Nothing to do break; case MeasureSpec.AT_MOST: widthSpec = MeasureSpec.makeMeasureSpec( Math.min(MeasureSpec.getSize(widthSpec), mMaxWidth), MeasureSpec.EXACTLY); break; case MeasureSpec.UNSPECIFIED: widthSpec = MeasureSpec.makeMeasureSpec(mMaxWidth, MeasureSpec.EXACTLY); break; } // Let super sort out the height super.onMeasure(widthSpec, heightSpec); } } ================================================ FILE: app/src/main/java/com/hippo/widget/FabLayout.kt ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget import android.animation.Animator import android.content.Context import android.os.Bundle import android.os.Parcelable import android.util.AttributeSet import android.view.View import android.view.ViewGroup import android.view.animation.Interpolator import androidx.core.view.isGone import com.google.android.material.floatingactionbutton.FloatingActionButton import com.hippo.ehviewer.R import com.hippo.util.getParcelableCompat import com.hippo.yorozuya.AnimationUtils import com.hippo.yorozuya.SimpleAnimatorListener class FabLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : ViewGroup(context, attrs, defStyleAttr), View.OnClickListener { private var mFabSize = 0 private var mFabMiniSize = 0 private var mIntervalPrimary = 0 private var mIntervalSecondary = 0 private var mExpanded = true private var mAutoCancel = true private var mHidePrimaryFab = false private var mMainFabCenterY = -1f private var mOnExpandListener: OnExpandListener? = null private var mOnClickFabListener: OnClickFabListener? = null init { isSoundEffectsEnabled = false clipToPadding = false mFabSize = context.resources.getDimensionPixelOffset(R.dimen.fab_size) mFabMiniSize = context.resources.getDimensionPixelOffset(R.dimen.fab_min_size) mIntervalPrimary = context.resources.getDimensionPixelOffset(R.dimen.fab_layout_primary_margin) mIntervalSecondary = context.resources.getDimensionPixelOffset(R.dimen.fab_layout_secondary_margin) } override fun addView(child: View, index: Int, params: LayoutParams) { check(child is FloatingActionButton) { "FloatingActionBarLayout should only " + "contain FloatingActionButton, but try to add " + child.javaClass.name } super.addView(child, index, params) } val primaryFab: FloatingActionButton? get() = getChildAt(childCount - 1) as? FloatingActionButton private val secondaryFabCount: Int get() = 0.coerceAtLeast(childCount - 1) fun getSecondaryFabAt(index: Int): FloatingActionButton? = if (index < 0 || index >= secondaryFabCount) { null } else { getChildAt(index) as FloatingActionButton } fun setSecondaryFabVisibilityAt(index: Int, visible: Boolean) { getSecondaryFabAt(index)?.run { if (visible && isGone) { animate().cancel() visibility = if (mExpanded) VISIBLE else INVISIBLE } else if (!visible && visibility != GONE) { animate().cancel() visibility = GONE } } } private fun getChildMeasureSpec(parentMeasureSpec: Int): Int { val parentMode = MeasureSpec.getMode(parentMeasureSpec) val parentSize = MeasureSpec.getSize(parentMeasureSpec) return MeasureSpec.makeMeasureSpec( parentSize, if (parentMode == MeasureSpec.UNSPECIFIED) MeasureSpec.UNSPECIFIED else MeasureSpec.AT_MOST, ) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec) val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec) measureChildren(childWidthMeasureSpec, childHeightMeasureSpec) var maxWidth = 0 var maxHeight = 0 val count = childCount for (i in 0 until count) { val child = getChildAt(i) if (child.isGone) { continue } maxWidth = maxWidth.coerceAtLeast(child.measuredWidth) maxHeight += child.measuredHeight } maxWidth += paddingLeft + paddingRight maxHeight += paddingTop + paddingBottom maxHeight = maxHeight.coerceAtLeast(suggestedMinimumHeight) maxWidth = maxWidth.coerceAtLeast(suggestedMinimumWidth) setMeasuredDimension( resolveSizeAndState(maxWidth, widthMeasureSpec, 0), resolveSizeAndState(maxHeight, heightMeasureSpec, 0), ) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { var centerX = 0 var bottom = measuredHeight - paddingBottom val count = childCount var i = count while (--i >= 0) { val child = getChildAt(i) if (child.isGone) { continue } val childWidth = child.measuredWidth val childHeight = child.measuredHeight var layoutBottom: Int var layoutRight: Int if (i == count - 1) { layoutBottom = bottom + (childHeight - mFabSize) / 2 layoutRight = measuredWidth - paddingRight + (childWidth - mFabSize) / 2 bottom -= mFabSize + mIntervalPrimary centerX = layoutRight - childWidth / 2 mMainFabCenterY = layoutBottom - childHeight / 2f } else { layoutBottom = bottom + (childHeight - mFabMiniSize) / 2 layoutRight = centerX + childWidth / 2 bottom -= mFabMiniSize + mIntervalSecondary } child.layout( layoutRight - childWidth, layoutBottom - childHeight, layoutRight, layoutBottom, ) } } fun setOnExpandListener(listener: OnExpandListener?) { mOnExpandListener = listener } fun setOnClickFabListener(listener: OnClickFabListener?) { mOnClickFabListener = listener if (listener != null) { var i = 0 val n = childCount while (i < n) { getChildAt(i).setOnClickListener(this) i++ } } else { var i = 0 val n = childCount while (i < n) { getChildAt(i).isClickable = false i++ } } } fun setHidePrimaryFab(hidePrimaryFab: Boolean) { if (mHidePrimaryFab != hidePrimaryFab) { mHidePrimaryFab = hidePrimaryFab val expanded = mExpanded val count = childCount if (!expanded && count > 0) { getChildAt(count - 1).visibility = if (hidePrimaryFab) INVISIBLE else VISIBLE } } } fun setAutoCancel(autoCancel: Boolean) { if (mAutoCancel != autoCancel) { mAutoCancel = autoCancel if (mExpanded) { if (autoCancel) { setOnClickListener(this) } else { isClickable = false } } } } fun toggle() { isExpanded = !mExpanded } var isExpanded: Boolean get() = mExpanded set(expanded) { setExpanded(expanded, true) } fun setExpanded(expanded: Boolean, animation: Boolean) { if (mExpanded != expanded) { mExpanded = expanded if (mAutoCancel) { if (expanded) { setOnClickListener(this) } else { isClickable = false } } val count = childCount if (count > 0) { if (mMainFabCenterY == -1f || !animation) { // It is before first onLayout val checkCount = if (mHidePrimaryFab) count else count - 1 for (i in 0 until checkCount) { val child = getChildAt(i) if (child.isGone) { continue } child.visibility = if (expanded) VISIBLE else INVISIBLE if (expanded) { child.alpha = 1f } } } else { if (mHidePrimaryFab) { setPrimaryFabAnimation(getChildAt(count - 1), expanded, !expanded) } for (i in 0 until count - 1) { val child = getChildAt(i) if (child.isGone) { continue } setSecondaryFabAnimation(child, expanded, expanded) } } } mOnExpandListener?.onExpand(expanded) } } private fun setPrimaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) { val startRotation: Float val endRotation: Float val startScale: Float val endScale: Float val interpolator: Interpolator if (expanded) { startRotation = -45.0f endRotation = 0.0f startScale = 0.0f endScale = 1.0f interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR } else { startRotation = 0.0f endRotation = 0.0f startScale = 1.0f endScale = 0.0f interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR } child.scaleX = startScale child.scaleY = startScale child.rotation = startRotation child.animate() .scaleX(endScale) .scaleY(endScale) .rotation(endRotation) .setStartDelay(if (delay) ANIMATE_TIME else 0L) .setDuration(ANIMATE_TIME) .setInterpolator(interpolator) .setListener(object : SimpleAnimatorListener() { override fun onAnimationStart(animation: Animator) { if (expanded) { child.visibility = VISIBLE } } override fun onAnimationEnd(animation: Animator) { if (!expanded) { child.visibility = INVISIBLE } } }).start() } private fun setSecondaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) { val startTranslationY: Float val endTranslationY: Float val startAlpha: Float val endAlpha: Float val interpolator: Interpolator if (expanded) { startTranslationY = mMainFabCenterY - child.top - child.height / 2 endTranslationY = 0f startAlpha = 0f endAlpha = 1f interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR } else { startTranslationY = 0f endTranslationY = mMainFabCenterY - child.top - child.height / 2 startAlpha = 1f endAlpha = 0f interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR } child.alpha = startAlpha child.translationY = startTranslationY child.animate() .alpha(endAlpha) .translationY(endTranslationY) .setStartDelay(if (delay) ANIMATE_TIME else 0L) .setDuration(ANIMATE_TIME) .setInterpolator(interpolator) .setListener(object : SimpleAnimatorListener() { override fun onAnimationStart(animation: Animator) { if (expanded) { child.visibility = VISIBLE } } override fun onAnimationEnd(animation: Animator) { if (!expanded) { child.visibility = INVISIBLE } } }).start() } override fun onClick(v: View) { if (this === v) { isExpanded = false } else { mOnClickFabListener?.let { val position = indexOfChild(v) if (position == childCount - 1) { it.onClickPrimaryFab(this, v as FloatingActionButton) } else if (position >= 0 && mExpanded) { it.onClickSecondaryFab(this, v as FloatingActionButton, position) } } } } override fun dispatchSetPressed(pressed: Boolean) { // Don't dispatch it to children } override fun onSaveInstanceState(): Parcelable { val state = Bundle() state.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState()) state.putBoolean(STATE_KEY_AUTO_CANCEL, mAutoCancel) state.putBoolean(STATE_KEY_EXPANDED, mExpanded) return state } override fun onRestoreInstanceState(state: Parcelable) { if (state is Bundle) { super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER)) setAutoCancel(state.getBoolean(STATE_KEY_AUTO_CANCEL)) setExpanded(state.getBoolean(STATE_KEY_EXPANDED), false) } } interface OnExpandListener { fun onExpand(expanded: Boolean) } interface OnClickFabListener { fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton) fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int) } companion object { private const val ANIMATE_TIME = 300L private const val STATE_KEY_SUPER = "super" private const val STATE_KEY_AUTO_CANCEL = "auto_cancel" private const val STATE_KEY_EXPANDED = "expanded" } } ================================================ FILE: app/src/main/java/com/hippo/widget/FixedAspectImageView.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import androidx.core.content.withStyledAttributes import com.google.android.material.imageview.ShapeableImageView import com.hippo.ehviewer.R import com.hippo.yorozuya.MathUtils import kotlin.math.abs import kotlin.math.max import kotlin.math.min open class FixedAspectImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : ShapeableImageView( context, attrs, defStyle, ) { private var mMinWidth = 0 private var mMinHeight = 0 private var mMaxWidth = Int.MAX_VALUE private var mMaxHeight = Int.MAX_VALUE private var mAdjustViewBounds = false // width / height private var mAspect = -1f @SuppressLint("ResourceType") private fun init(context: Context, attrs: AttributeSet?, defStyle: Int) { // Make sure we get value from xml context.withStyledAttributes(attrs, MIN_ATTRS, defStyle, 0) { setMinimumWidth(getDimensionPixelSize(0, 0)) setMinimumHeight(getDimensionPixelSize(1, 0)) } context.withStyledAttributes(attrs, ATTRS, defStyle, 0) { setAdjustViewBounds(getBoolean(0, false)) setMaxWidth(getDimensionPixelSize(1, Int.MAX_VALUE)) setMaxHeight(getDimensionPixelSize(2, Int.MAX_VALUE)) } context.withStyledAttributes( attrs, R.styleable.FixedAspectImageView, defStyle, 0, ) { aspect = getFloat(R.styleable.FixedAspectImageView_aspect, -1f) } } override fun setMinimumWidth(minWidth: Int) { super.setMinimumWidth(minWidth) mMinWidth = minWidth } override fun setMinimumHeight(minHeight: Int) { super.setMinimumHeight(minHeight) mMinHeight = minHeight } override fun setMaxWidth(maxWidth: Int) { super.setMaxWidth(maxWidth) mMaxWidth = maxWidth } override fun setMaxHeight(maxHeight: Int) { super.setMaxHeight(maxHeight) mMaxHeight = maxHeight } override fun setAdjustViewBounds(adjustViewBounds: Boolean) { super.setAdjustViewBounds(adjustViewBounds) mAdjustViewBounds = adjustViewBounds } /** * Enable aspect will set AdjustViewBounds true. * Any negative float to disable it, * disable Aspect will not disable AdjustViewBounds. * * @param aspect width/height */ var aspect: Float get() = mAspect set(aspect) { mAspect = if (aspect > 0) { aspect } else { -1f } requestLayout() } private fun resolveAdjustedSize( desiredSize: Int, minSize: Int, maxSize: Int, measureSpec: Int, ): Int { var result = desiredSize val specMode = MeasureSpec.getMode(measureSpec) val specSize = MeasureSpec.getSize(measureSpec) when (specMode) { MeasureSpec.UNSPECIFIED -> // Parent says we can be as big as we want. Just don't be smaller // than min size, and don't be larger than max size. result = MathUtils.clamp(desiredSize, minSize, maxSize) MeasureSpec.AT_MOST -> // Parent says we can be as big as we want, up to specSize. // Don't be larger than specSize, and don't be smaller // than min size, and don't be larger than max size. result = min( MathUtils.clamp(desiredSize, minSize, maxSize).toDouble(), specSize.toDouble(), ).toInt() MeasureSpec.EXACTLY -> // No choice. Do what we are told. result = specSize } return result } private fun isSizeAcceptable(size: Int, minSize: Int, maxSize: Int, measureSpec: Int): Boolean { val specMode = MeasureSpec.getMode(measureSpec) val specSize = MeasureSpec.getSize(measureSpec) return when (specMode) { MeasureSpec.UNSPECIFIED -> // Parent says we can be as big as we want. Just don't be smaller // than min size, and don't be larger than max size. size in minSize..maxSize MeasureSpec.AT_MOST -> // Parent says we can be as big as we want, up to specSize. // Don't be larger than specSize, and don't be smaller // than min size, and don't be larger than max size. size in minSize..specSize && size <= maxSize MeasureSpec.EXACTLY -> // No choice. size == specSize else -> // WTF? Return true to make you happy. (´・ω・`) true } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { var w: Int var h: Int // Desired aspect ratio of the view's contents (not including padding) var desiredAspect = 0.0f // We are allowed to change the view's width var resizeWidth = false // We are allowed to change the view's height var resizeHeight = false val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec) val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec) val drawable = getDrawable() if (drawable == null) { // If no drawable, its intrinsic size is 0. h = 0 w = 0 // Aspect is forced set. if (mAspect > 0.0f) { resizeWidth = widthSpecMode != MeasureSpec.EXACTLY resizeHeight = heightSpecMode != MeasureSpec.EXACTLY desiredAspect = mAspect } } else { w = drawable.intrinsicWidth h = drawable.intrinsicHeight if (w <= 0) w = 1 if (h <= 0) h = 1 if (mAdjustViewBounds) { // We are supposed to adjust view bounds to match the aspect // ratio of our drawable. See if that is possible. resizeWidth = widthSpecMode != MeasureSpec.EXACTLY resizeHeight = heightSpecMode != MeasureSpec.EXACTLY desiredAspect = w.toFloat() / h.toFloat() } else if (mAspect > 0.0f) { // Aspect is forced set. resizeWidth = widthSpecMode != MeasureSpec.EXACTLY resizeHeight = heightSpecMode != MeasureSpec.EXACTLY desiredAspect = mAspect } } val pLeft = paddingLeft val pRight = paddingRight val pTop = paddingTop val pBottom = paddingBottom var widthSize: Int var heightSize: Int if (resizeWidth || resizeHeight) { // If we get here, it means we want to resize to match the // drawables aspect ratio, and we have the freedom to change at // least one dimension. // Get the max possible width given our constraints widthSize = resolveAdjustedSize(w + pLeft + pRight, mMinWidth, mMaxWidth, widthMeasureSpec) // Get the max possible height given our constraints heightSize = resolveAdjustedSize(h + pTop + pBottom, mMinHeight, mMaxHeight, heightMeasureSpec) if (desiredAspect != 0.0f) { // See what our actual aspect ratio is val actualAspect = (widthSize - pLeft - pRight).toFloat() / (heightSize - pTop - pBottom) if (abs((actualAspect - desiredAspect).toDouble()) > 0.0000001) { var done = false // Try adjusting width to be proportional to height if (resizeWidth) { val newWidth = (desiredAspect * (heightSize - pTop - pBottom)).toInt() + pLeft + pRight // Allow the width to outgrow its original estimate if height is fixed. // if (!resizeHeight) { // widthSize = resolveAdjustedSize(newWidth, mMinWidth, mMaxWidth, widthMeasureSpec); // } if (isSizeAcceptable(newWidth, mMinWidth, mMaxWidth, widthMeasureSpec)) { widthSize = newWidth done = true } } // Try adjusting height to be proportional to width if (!done && resizeHeight) { val newHeight = ((widthSize - pLeft - pRight) / desiredAspect).toInt() + pTop + pBottom // Allow the height to outgrow its original estimate if width is fixed. if (!resizeWidth) { heightSize = resolveAdjustedSize( newHeight, mMinHeight, mMaxHeight, heightMeasureSpec, ) } if (isSizeAcceptable( newHeight, mMinHeight, mMaxHeight, heightMeasureSpec, ) ) { heightSize = newHeight } } } } } else { // We are either don't want to preserve the drawables aspect ratio, // or we are not allowed to change view dimensions. Just measure in // the normal way. w += pLeft + pRight h += pTop + pBottom w = max(w.toDouble(), suggestedMinimumWidth.toDouble()).toInt() h = max(h.toDouble(), suggestedMinimumHeight.toDouble()).toInt() widthSize = resolveSizeAndState(w, widthMeasureSpec, 0) heightSize = resolveSizeAndState(h, heightMeasureSpec, 0) } setMeasuredDimension(widthSize, heightSize) } companion object { private val MIN_ATTRS = intArrayOf( android.R.attr.minWidth, android.R.attr.minHeight, ) private val ATTRS = intArrayOf( android.R.attr.adjustViewBounds, android.R.attr.maxWidth, android.R.attr.maxHeight, ) } init { init(context, attrs, defStyle) } } ================================================ FILE: app/src/main/java/com/hippo/widget/IgnoreFitsSystemWindowsFullyDraggableDrawerContentLayout.kt ================================================ /* * Copyright 2022 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.widget import android.content.Context import android.util.AttributeSet import android.view.WindowInsets import com.drakeet.drawer.FullDraggableContainer class IgnoreFitsSystemWindowsFullyDraggableDrawerContentLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : FullDraggableContainer( context, attrs, defStyle, ) { override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets? = insets } ================================================ FILE: app/src/main/java/com/hippo/widget/IndicatingListView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.util.AttributeSet; import android.widget.ListView; import androidx.annotation.NonNull; import com.hippo.ehviewer.R; public class IndicatingListView extends ListView { private final Paint mPaint = new Paint(); private final Rect mTemp = new Rect(); private int mIndicatorHeight; public IndicatingListView(Context context) { super(context); init(context, null); } public IndicatingListView(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public IndicatingListView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } @SuppressLint("CustomViewStyleable") private void init(Context context, AttributeSet attrs) { //noinspection resource TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Indicating); try { mIndicatorHeight = a.getDimensionPixelOffset(R.styleable.Indicating_indicatorHeight, 1); mPaint.setColor(a.getColor(R.styleable.Indicating_indicatorColor, Color.BLACK)); mPaint.setStyle(Paint.Style.FILL); } finally { a.recycle(); } } private void fillTopIndicatorDrawRect() { mTemp.set(0, 0, getWidth(), mIndicatorHeight); } private void fillBottomIndicatorDrawRect() { mTemp.set(0, getHeight() - mIndicatorHeight, getWidth(), getHeight()); } private boolean needShowTopIndicator() { return canScrollVertically(-1); } private boolean needShowBottomIndicator() { return canScrollVertically(1); } @Override public void draw(@NonNull Canvas canvas) { super.draw(canvas); final int restoreCount = canvas.save(); canvas.translate(getScrollX(), getScrollY()); // Draw top indicator if (needShowTopIndicator()) { fillTopIndicatorDrawRect(); canvas.drawRect(mTemp, mPaint); } // Draw bottom indicator if (needShowBottomIndicator()) { fillBottomIndicatorDrawRect(); canvas.drawRect(mTemp, mPaint); } canvas.restoreToCount(restoreCount); } } ================================================ FILE: app/src/main/java/com/hippo/widget/LinkifyTextView.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.annotation.SuppressLint; import android.content.Context; import android.text.Layout; import android.text.Spanned; import android.text.style.ClickableSpan; import android.util.AttributeSet; import android.view.MotionEvent; import androidx.annotation.NonNull; public class LinkifyTextView extends ObservedTextView { private ClickableSpan mCurrentSpan; public LinkifyTextView(Context context) { super(context); } public LinkifyTextView(Context context, AttributeSet attrs) { super(context, attrs); } public LinkifyTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public ClickableSpan getCurrentSpan() { return mCurrentSpan; } public void clearCurrentSpan() { mCurrentSpan = null; } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(@NonNull MotionEvent event) { // Let the parent or grandparent of TextView to handles click aciton. // Otherwise click effect like ripple will not work, and if touch area // do not contain a url, the TextView will still get MotionEvent. // onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch // action on it, so we analyze touched url here. if (event.getAction() == MotionEvent.ACTION_DOWN) { mCurrentSpan = null; if (getText() instanceof Spanned) { // Get this code from android.text.method.LinkMovementMethod. // Work fine ! int x = (int) event.getX(); int y = (int) event.getY(); x -= getTotalPaddingLeft(); y -= getTotalPaddingTop(); x += getScrollX(); y += getScrollY(); Layout layout = getLayout(); if (null != layout) { int line = layout.getLineForVertical(y); int off = layout.getOffsetForHorizontal(line, x); ClickableSpan[] spans = ((Spanned) getText()).getSpans(off, off, ClickableSpan.class); if (spans.length > 0) { mCurrentSpan = spans[0]; } } } } return super.onTouchEvent(event); } } ================================================ FILE: app/src/main/java/com/hippo/widget/LoadImageView.kt ================================================ /* * Copyright 2015-2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget import android.content.Context import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.View import androidx.annotation.DrawableRes import androidx.annotation.IntDef import androidx.core.content.ContextCompat import coil3.load import coil3.request.allowHardware import coil3.request.crossfade import coil3.size.Size import com.hippo.drawable.PreciselyClipDrawable import com.hippo.ehviewer.R open class LoadImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : FixedAspectImageView(context, attrs, defStyleAttr), View.OnClickListener, View.OnLongClickListener { private var mOffsetX = Int.MIN_VALUE private var mOffsetY = Int.MIN_VALUE private var mClipWidth = Int.MIN_VALUE private var mClipHeight = Int.MIN_VALUE private var mKey: String? = null private var mUrl: String? = null private var mCrossfade = true private var mHardware = true @RetryType private val mRetryType: Int = context.obtainStyledAttributes(attrs, R.styleable.LoadImageView, defStyleAttr, 0).run { getInt(R.styleable.LoadImageView_retryType, 0).also { recycle() } } private fun setRetry(canRetry: Boolean) { when (mRetryType) { RETRY_TYPE_CLICK -> { setOnClickListener(if (canRetry) this else null) isClickable = canRetry } RETRY_TYPE_LONG_CLICK -> { setOnLongClickListener(if (canRetry) this else null) isLongClickable = canRetry } RETRY_TYPE_NONE -> {} } } fun setClip(offsetX: Int, offsetY: Int, clipWidth: Int, clipHeight: Int) { mOffsetX = offsetX mOffsetY = offsetY mClipWidth = clipWidth mClipHeight = clipHeight } fun resetClip() { mOffsetX = Int.MIN_VALUE mOffsetY = Int.MIN_VALUE mClipWidth = Int.MIN_VALUE mClipHeight = Int.MIN_VALUE } fun load( key: String, url: String, crossfade: Boolean = true, hardware: Boolean = true, ) { mKey = key mUrl = url mCrossfade = crossfade mHardware = hardware load(url) { // https://coil-kt.github.io/coil/recipes/#shared-element-transitions allowHardware(hardware) placeholderMemoryCacheKey(key) memoryCacheKey(key) diskCacheKey(key) size(Size.ORIGINAL) if (!crossfade) crossfade(false) listener( { setRetry(false) }, { setRetry(true) }, { _, _ -> val errorDrawable = ContextCompat.getDrawable(context, R.drawable.image_failed) onPreSetImageDrawable(errorDrawable, true) super.setImageDrawable(errorDrawable) setRetry(true) }, { _, _ -> setRetry(false) }, ) } } fun load(@DrawableRes id: Int) { onPreSetImageResource(id, true) setImageResource(id) } private fun reload() { mKey?.let { this.load(it, mUrl!!, mCrossfade, mHardware) } } override fun setImageDrawable(drawable: Drawable?) { var newDrawable = drawable if (newDrawable != null) { if (Int.MIN_VALUE != mOffsetX) { newDrawable = PreciselyClipDrawable(newDrawable, mOffsetX, mOffsetY, mClipWidth, mClipHeight) } onPreSetImageDrawable(newDrawable, true) } super.setImageDrawable(newDrawable) } override fun getDrawable(): Drawable? { var newDrawable = super.getDrawable() if (newDrawable is PreciselyClipDrawable) { newDrawable = newDrawable.drawable } return newDrawable } override fun onClick(v: View) { reload() } override fun onLongClick(v: View): Boolean { reload() return true } open fun onPreSetImageDrawable(drawable: Drawable?, isTarget: Boolean) {} open fun onPreSetImageResource(resId: Int, isTarget: Boolean) {} @IntDef(RETRY_TYPE_NONE, RETRY_TYPE_CLICK, RETRY_TYPE_LONG_CLICK) @Retention(AnnotationRetention.SOURCE) private annotation class RetryType companion object { const val RETRY_TYPE_NONE = 0 const val RETRY_TYPE_CLICK = 1 const val RETRY_TYPE_LONG_CLICK = 2 } } ================================================ FILE: app/src/main/java/com/hippo/widget/MaxSizeContainer.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import com.hippo.ehviewer.R; import com.hippo.yorozuya.AssertUtils; public class MaxSizeContainer extends ViewGroup { private int mMaxWidth; private int mMaxHeight; public MaxSizeContainer(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public MaxSizeContainer(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } private void init(Context context, AttributeSet attrs) { //noinspection resource TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxSizeContainer); try { mMaxWidth = a.getDimensionPixelOffset(R.styleable.MaxSizeContainer_maxWidth, -1); mMaxHeight = a.getDimensionPixelOffset(R.styleable.MaxSizeContainer_maxHeight, -1); } finally { a.recycle(); } } private int getMeasureSpec(int measureSpec, int max) { if (max < 0) { return measureSpec; } int size = MeasureSpec.getSize(measureSpec); int mode = MeasureSpec.getMode(measureSpec); switch (mode) { case MeasureSpec.AT_MOST -> size = Math.min(size, max); case MeasureSpec.UNSPECIFIED -> { size = max; mode = MeasureSpec.AT_MOST; } case MeasureSpec.EXACTLY -> {} } return MeasureSpec.makeMeasureSpec(size, mode); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { AssertUtils.assertEquals("getChildCount() must be 1", 1, getChildCount()); View child = getChildAt(0); if (child.getVisibility() != GONE) { child.measure(getMeasureSpec(widthMeasureSpec, mMaxWidth), getMeasureSpec(heightMeasureSpec, mMaxHeight)); setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight()); } else { setMeasuredDimension(0, 0); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { AssertUtils.assertEquals("getChildCount() must be 1", 1, getChildCount()); View child = getChildAt(0); if (child.getVisibility() != GONE) { child.layout(0, 0, r - l, b - t); } } } ================================================ FILE: app/src/main/java/com/hippo/widget/ObservedTextView.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.Context; import android.util.AttributeSet; import androidx.appcompat.widget.AppCompatTextView; public class ObservedTextView extends AppCompatTextView { private OnWindowAttachListener mOnWindowAttachListener; public ObservedTextView(Context context) { super(context); } public ObservedTextView(Context context, AttributeSet attrs) { super(context, attrs); } public ObservedTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); if (mOnWindowAttachListener != null) { mOnWindowAttachListener.onAttachedToWindow(); } } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (mOnWindowAttachListener != null) { mOnWindowAttachListener.onDetachedFromWindow(); } } public void setOnWindowAttachListener(OnWindowAttachListener onWindowAttachListener) { mOnWindowAttachListener = onWindowAttachListener; } public interface OnWindowAttachListener { void onAttachedToWindow(); void onDetachedFromWindow(); } } ================================================ FILE: app/src/main/java/com/hippo/widget/RadioGridGroup.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.widget.CompoundButton; import android.widget.RadioButton; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import com.hippo.yorozuya.ViewUtils; public class RadioGridGroup extends SimpleGridLayout { private static final int[] RADIO_ATTRS = new int[]{ android.R.attr.checkedButton }; // holds the checked id; the selection is empty by default private int mCheckedId = -1; // tracks children radio buttons checked state private CompoundButton.OnCheckedChangeListener mChildOnCheckedChangeListener; // when true, mOnCheckedChangeListener discards events private boolean mProtectFromCheckedChange = false; private OnCheckedChangeListener mOnCheckedChangeListener; private PassThroughHierarchyChangeListener mPassThroughListener; public RadioGridGroup(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public RadioGridGroup(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context, attrs); } private void init(Context context, AttributeSet attrs) { //noinspection resource final TypedArray a = context.obtainStyledAttributes(attrs, RADIO_ATTRS); try { int value = a.getResourceId(0, View.NO_ID); if (value != View.NO_ID) { mCheckedId = value; } } finally { a.recycle(); } mChildOnCheckedChangeListener = new CheckedStateTracker(); mPassThroughListener = new PassThroughHierarchyChangeListener(); super.setOnHierarchyChangeListener(mPassThroughListener); } /** * {@inheritDoc} */ @Override public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener) { // the user listener is delegated to our pass-through listener mPassThroughListener.mOnHierarchyChangeListener = listener; } /** * {@inheritDoc} */ @Override protected void onFinishInflate() { super.onFinishInflate(); // checks the appropriate radio button as requested in the XML file if (mCheckedId != -1) { mProtectFromCheckedChange = true; setCheckedStateForView(mCheckedId, true); mProtectFromCheckedChange = false; setCheckedId(mCheckedId); } } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { if (child instanceof final RadioButton button) { if (button.isChecked()) { mProtectFromCheckedChange = true; if (mCheckedId != -1) { setCheckedStateForView(mCheckedId, false); } mProtectFromCheckedChange = false; setCheckedId(button.getId()); } } super.addView(child, index, params); } /** *

Sets the selection to the radio button whose identifier is passed in * parameter. Using -1 as the selection identifier clears the selection; * such an operation is equivalent to invoking {@link #clearCheck()}.

* * @param id the unique id of the radio button to select in this group * @see #getCheckedRadioButtonId() * @see #clearCheck() */ public void check(@IdRes int id) { // don't even bother if (id != -1 && (id == mCheckedId)) { return; } if (mCheckedId != -1) { setCheckedStateForView(mCheckedId, false); } if (id != -1) { setCheckedStateForView(id, true); } setCheckedId(id); } private void setCheckedId(@IdRes int id) { mCheckedId = id; if (mOnCheckedChangeListener != null) { mOnCheckedChangeListener.onCheckedChanged(this, mCheckedId); } } private void setCheckedStateForView(int viewId, boolean checked) { View checkedView = findViewById(viewId); if (checkedView instanceof RadioButton) { ((RadioButton) checkedView).setChecked(checked); } } /** *

Returns the identifier of the selected radio button in this group. * Upon empty selection, the returned value is -1.

* * @return the unique id of the selected radio button in this group * @see #check(int) * @see #clearCheck() */ @IdRes public int getCheckedRadioButtonId() { return mCheckedId; } /** *

Clears the selection. When the selection is cleared, no radio button * in this group is selected and {@link #getCheckedRadioButtonId()} returns * null.

* * @see #check(int) * @see #getCheckedRadioButtonId() */ public void clearCheck() { check(-1); } /** *

Register a callback to be invoked when the checked radio button * changes in this group.

* * @param listener the callback to call on checked state change */ public void setOnCheckedChangeListener(OnCheckedChangeListener listener) { mOnCheckedChangeListener = listener; } /** *

Interface definition for a callback to be invoked when the checked * radio button changed in this group.

*/ public interface OnCheckedChangeListener { /** *

Called when the checked radio button has changed. When the * selection is cleared, checkedId is -1.

* * @param group the group in which the checked radio button has changed * @param checkedId the unique identifier of the newly checked radio button */ void onCheckedChanged(RadioGridGroup group, @IdRes int checkedId); } private class CheckedStateTracker implements CompoundButton.OnCheckedChangeListener { @Override public void onCheckedChanged(@NonNull CompoundButton buttonView, boolean isChecked) { // prevents from infinite recursion if (mProtectFromCheckedChange) { return; } mProtectFromCheckedChange = true; if (mCheckedId != -1) { setCheckedStateForView(mCheckedId, false); } mProtectFromCheckedChange = false; int id = buttonView.getId(); setCheckedId(id); } } /** *

A pass-through listener acts upon the events and dispatches them * to another listener. This allows the table layout to set its own internal * hierarchy change listener without preventing the user to setup his.

*/ private class PassThroughHierarchyChangeListener implements ViewGroup.OnHierarchyChangeListener { private ViewGroup.OnHierarchyChangeListener mOnHierarchyChangeListener; /** * {@inheritDoc} */ @Override public void onChildViewAdded(View parent, View child) { if (parent == RadioGridGroup.this && child instanceof RadioButton) { int id = child.getId(); // generates an id if it's missing if (id == View.NO_ID) { id = ViewUtils.generateViewId(); child.setId(id); } ((RadioButton) child).setOnCheckedChangeListener( mChildOnCheckedChangeListener); } if (mOnHierarchyChangeListener != null) { mOnHierarchyChangeListener.onChildViewAdded(parent, child); } } /** * {@inheritDoc} */ @Override public void onChildViewRemoved(View parent, View child) { if (parent == RadioGridGroup.this && child instanceof RadioButton) { ((RadioButton) child).setOnCheckedChangeListener(null); } if (mOnHierarchyChangeListener != null) { mOnHierarchyChangeListener.onChildViewRemoved(parent, child); } } } } ================================================ FILE: app/src/main/java/com/hippo/widget/SearchBarMover.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.animation.Animator; import android.animation.ValueAnimator; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.hippo.yorozuya.MathUtils; import com.hippo.yorozuya.SimpleAnimatorListener; import com.hippo.yorozuya.ViewUtils; public class SearchBarMover extends RecyclerView.OnScrollListener { private static final long ANIMATE_TIME = 300L; private final Helper mHelper; private final View mSearchBar; private boolean mShow; private ValueAnimator mSearchBarMoveAnimator; public SearchBarMover(Helper helper, View searchBar, RecyclerView... recyclerViews) { mHelper = helper; mSearchBar = searchBar; for (RecyclerView recyclerView : recyclerViews) { recyclerView.addOnScrollListener(this); } } public void cancelAnimation() { if (mSearchBarMoveAnimator != null) { mSearchBarMoveAnimator.cancel(); } } @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { if (newState == RecyclerView.SCROLL_STATE_IDLE && mHelper.isValidView(recyclerView)) { returnSearchBarPosition(); } } @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { if (mHelper.isValidView(recyclerView)) { int oldBottom = (int) ViewUtils.getY2(mSearchBar); int offsetYStep = MathUtils.clamp(-dy, -oldBottom, -(int) mSearchBar.getTranslationY()); if (offsetYStep != 0) { ViewUtils.translationYBy(mSearchBar, offsetYStep); } } } public void returnSearchBarPosition() { returnSearchBarPosition(true); } @SuppressWarnings("SimplifiableIfStatement") public void returnSearchBarPosition(boolean animation) { if (mSearchBar.getHeight() == 0) { // Layout not called return; } boolean show; if (mHelper.forceShowSearchBar()) { show = true; } else { RecyclerView recyclerView = mHelper.getValidRecyclerView(); if (recyclerView == null) { return; } if (!recyclerView.isShown()) { show = true; } else if (recyclerView.computeVerticalScrollOffset() < mSearchBar.getBottom()) { show = true; } else { show = (int) ViewUtils.getY2(mSearchBar) > (mSearchBar.getHeight()) / 2; } } int offset; if (show) { offset = -(int) mSearchBar.getTranslationY(); } else { offset = -(int) ViewUtils.getY2(mSearchBar); } if (offset == 0) { // No need to scroll return; } if (animation) { if (mSearchBarMoveAnimator != null) { if (mShow == show) { // The same target, no need to do animation return; } else { // Cancel it mSearchBarMoveAnimator.cancel(); mSearchBarMoveAnimator = null; } } mShow = show; final ValueAnimator va = ValueAnimator.ofInt(0, offset); va.setDuration(ANIMATE_TIME); va.addListener(new SimpleAnimatorListener() { @Override public void onAnimationEnd(@NonNull Animator animation) { mSearchBarMoveAnimator = null; } }); va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { int lastValue; @Override public void onAnimationUpdate(@NonNull ValueAnimator animation) { int value = (Integer) animation.getAnimatedValue(); int offsetStep = value - lastValue; lastValue = value; ViewUtils.translationYBy(mSearchBar, offsetStep); } }); mSearchBarMoveAnimator = va; va.start(); } else { if (mSearchBarMoveAnimator != null) { mSearchBarMoveAnimator.cancel(); } ViewUtils.translationYBy(mSearchBar, offset); } } public void showSearchBar() { showSearchBar(true); } public void showSearchBar(boolean animation) { if (mSearchBar.getHeight() == 0) { // Layout not called return; } final int offset = -(int) mSearchBar.getTranslationY(); if (offset == 0) { // No need to scroll return; } if (animation) { if (mSearchBarMoveAnimator != null) { if (mShow) { // The same target, no need to do animation return; } else { // Cancel it mSearchBarMoveAnimator.cancel(); mSearchBarMoveAnimator = null; } } mShow = true; final ValueAnimator va = ValueAnimator.ofInt(0, offset); va.setDuration(ANIMATE_TIME); va.addListener(new SimpleAnimatorListener() { @Override public void onAnimationEnd(@NonNull Animator animation) { mSearchBarMoveAnimator = null; } }); va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { int lastValue; @Override public void onAnimationUpdate(@NonNull ValueAnimator animation) { int value = (Integer) animation.getAnimatedValue(); int offsetStep = value - lastValue; lastValue = value; ViewUtils.translationYBy(mSearchBar, offsetStep); } }); mSearchBarMoveAnimator = va; va.start(); } else { if (mSearchBarMoveAnimator != null) { mSearchBarMoveAnimator.cancel(); } ViewUtils.translationYBy(mSearchBar, offset); } } public interface Helper { boolean isValidView(RecyclerView recyclerView); @Nullable RecyclerView getValidRecyclerView(); boolean forceShowSearchBar(); } } ================================================ FILE: app/src/main/java/com/hippo/widget/SimpleGridAutoSpanLayout.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.Context; import android.util.AttributeSet; public class SimpleGridAutoSpanLayout extends SimpleGridLayout { public static final int STRATEGY_SUITABLE_SIZE = 1; private int mColumnSize = -1; private boolean mColumnSizeChanged = true; private int mStrategy; public SimpleGridAutoSpanLayout(Context context) { super(context); } public SimpleGridAutoSpanLayout(Context context, AttributeSet attrs) { super(context, attrs); } public SimpleGridAutoSpanLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public static int getSpanCountForSuitableSize(int total, int single) { int span = total / single; if (span <= 0) { return 1; } int span2 = span + 1; float deviation = Math.abs(1 - ((float) total / span / (float) single)); float deviation2 = Math.abs(1 - ((float) total / span2 / (float) single)); return deviation < deviation2 ? span : span2; } public static int getSpanCountForMinSize(int total, int single) { return Math.max(1, total / single); } public void setColumnSize(int columnSize) { if (columnSize == mColumnSize) { return; } mColumnSize = columnSize; mColumnSizeChanged = true; } public void setStrategy(int strategy) { if (strategy == mStrategy) { return; } mStrategy = strategy; mColumnSizeChanged = true; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); if (mColumnSizeChanged && mColumnSize > 0 && widthMode == MeasureSpec.EXACTLY) { int totalSpace = widthSize - getPaddingRight() - getPaddingLeft(); int spanCount; if (mStrategy == STRATEGY_SUITABLE_SIZE) { spanCount = getSpanCountForSuitableSize(totalSpace, mColumnSize); } else { spanCount = getSpanCountForMinSize(totalSpace, mColumnSize); } setColumnCount(spanCount); mColumnSizeChanged = false; } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } } ================================================ FILE: app/src/main/java/com/hippo/widget/SimpleGridLayout.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import com.hippo.ehviewer.R; import com.hippo.yorozuya.MathUtils; import com.hippo.yorozuya.ViewUtils; /** * not scrollable * * @author Hippo */ public class SimpleGridLayout extends ViewGroup { private static final int DEFAULT_COLUMN_COUNT = 3; private int mColumnCount; private int mItemMargin; private int[] mRowHeights; private int mItemWidth; public SimpleGridLayout(Context context) { super(context); init(context, null); } public SimpleGridLayout(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public SimpleGridLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context, attrs); } private void init(Context context, AttributeSet attrs) { //noinspection resource TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SimpleGridLayout); try { mColumnCount = a.getInteger(R.styleable.SimpleGridLayout_columnCount, DEFAULT_COLUMN_COUNT); mItemMargin = a.getDimensionPixelOffset(R.styleable.SimpleGridLayout_itemMargin, 0); } finally { a.recycle(); } } public void setItemMargin(int itemMargin) { if (mItemMargin != itemMargin) { mItemMargin = itemMargin; requestLayout(); } } public void setColumnCount(int columnCount) { if (columnCount <= 0) { throw new IllegalStateException("Column count can't be " + columnCount); } if (mColumnCount != columnCount) { mColumnCount = columnCount; requestLayout(); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int maxRowCount = MathUtils.ceilDivide(getChildCount(), mColumnCount); if (mRowHeights == null || mRowHeights.length != maxRowCount) { mRowHeights = new int[maxRowCount]; } int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int maxWidth = MeasureSpec.getSize(widthMeasureSpec); int maxHeight = MeasureSpec.getSize(heightMeasureSpec); if (widthMode == MeasureSpec.UNSPECIFIED) { maxWidth = 300; } if (heightMode == MeasureSpec.UNSPECIFIED) { maxHeight = ViewUtils.MAX_SIZE; } // Get item width MeasureSpec mItemWidth = Math.max( (maxWidth - getPaddingLeft() - getPaddingRight() - ((mColumnCount - 1) * mItemMargin)) / mColumnCount, 1); int itemWidthMeasureSpec = MeasureSpec.makeMeasureSpec(mItemWidth, MeasureSpec.EXACTLY); int itemHeightMeasureSpec = MeasureSpec.UNSPECIFIED; int measuredWidth = maxWidth; int measuredHeight = 0; int rowHeight = 0; int row = 0; int count = getChildCount(); for (int index = 0, indexInRow = 0; index < count; index++, indexInRow++) { final View child = getChildAt(index); if (child.getVisibility() == View.GONE) { indexInRow--; continue; } child.measure(itemWidthMeasureSpec, itemHeightMeasureSpec); if (indexInRow == mColumnCount) { // New row indexInRow = 0; rowHeight = 0; row++; } rowHeight = Math.max(rowHeight, child.getMeasuredHeight()); if (indexInRow == mColumnCount - 1 || index == count - 1) { mRowHeights[row] = rowHeight; measuredHeight += rowHeight + mItemMargin; } } measuredHeight -= mItemMargin; measuredHeight = Math.max(0, Math.min(measuredHeight + getPaddingTop() + getPaddingBottom(), maxHeight)); setMeasuredDimension(measuredWidth, measuredHeight); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int itemWidth = mItemWidth; int itemMargin = mItemMargin; int paddingLeft = getPaddingLeft(); int left = paddingLeft; int top = getPaddingTop(); int row = 0; int count = getChildCount(); for (int index = 0, indexInRow = 0; index < count; index++, indexInRow++) { final View child = getChildAt(index); if (child.getVisibility() == View.GONE) { indexInRow--; continue; } if (indexInRow == mColumnCount) { // New row left = paddingLeft; top += mRowHeights[row] + itemMargin; indexInRow = 0; row++; } child.layout(left, top, left + child.getMeasuredWidth(), top + child.getMeasuredHeight()); left += itemWidth + itemMargin; } } } ================================================ FILE: app/src/main/java/com/hippo/widget/Slider.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.PopupWindow; import android.widget.RelativeLayout; import androidx.annotation.NonNull; import androidx.appcompat.widget.AppCompatImageView; import androidx.core.graphics.drawable.DrawableCompat; import com.hippo.ehviewer.R; import com.hippo.yorozuya.AnimationUtils; import com.hippo.yorozuya.LayoutUtils; import com.hippo.yorozuya.MathUtils; import com.hippo.yorozuya.SimpleHandler; public class Slider extends View { private static final char[] CHARACTERS = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; private static final int BUBBLE_WIDTH = 26; private static final int BUBBLE_HEIGHT = 32; private final RectF mLeftRectF = new RectF(); private final RectF mRightRectF = new RectF(); private final int[] mTemp = new int[2]; private Context mContext; private Paint mPaint; private Paint mBgPaint; private PopupWindow mPopup; private BubbleView mBubble; private int mStart; private int mEnd; private int mProgress; private float mPercent; private int mDrawProgress; private float mDrawPercent; private int mTargetProgress; private float mThickness; private float mRadius; private float mCharWidth; private float mCharHeight; private int mBubbleWidth; private int mBubbleHeight; private int mBubbleMinWidth; private int mBubbleMinHeight; private int mPopupX; private int mPopupY; private int mPopupWidth; private boolean mReverse = false; private boolean mShowBubble; private float mDrawBubbleScale = 0f; private ValueAnimator mProgressAnimation; private ValueAnimator mBubbleScaleAnimation; private OnSetProgressListener mListener; private CheckForShowBubble mCheckForShowBubble; public Slider(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public Slider(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } @SuppressWarnings("deprecation") private void init(Context context, AttributeSet attrs) { mContext = context; mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); textPaint.setTextAlign(Paint.Align.CENTER); Resources resources = context.getResources(); mBubbleMinWidth = resources.getDimensionPixelOffset(R.dimen.slider_bubble_width); mBubbleMinHeight = resources.getDimensionPixelOffset(R.dimen.slider_bubble_height); mBubble = new BubbleView(context, textPaint); mBubble.setScaleX(0.0f); mBubble.setScaleY(0.0f); RelativeLayout relativeLayout = new RelativeLayout(context); relativeLayout.addView(mBubble); relativeLayout.setBackgroundDrawable(null); mPopup = new PopupWindow(relativeLayout); mPopup.setOutsideTouchable(false); mPopup.setTouchable(false); mPopup.setFocusable(false); //noinspection resource TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Slider); try { textPaint.setColor(a.getColor(R.styleable.Slider_textColor, Color.WHITE)); textPaint.setTextSize(a.getDimensionPixelSize(R.styleable.Slider_textSize, 12)); updateTextSize(); setRange(a.getInteger(R.styleable.Slider_start, 0), a.getInteger(R.styleable.Slider_end, 0)); setProgress(a.getInteger(R.styleable.Slider_slider_progress, 0)); mThickness = a.getDimension(R.styleable.Slider_thickness, 2); mRadius = a.getDimension(R.styleable.Slider_radius, 6); setColor(a.getColor(R.styleable.Slider_color, Color.BLACK)); mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mBgPaint.setColor(a.getBoolean(R.styleable.Slider_dark, false) ? 0x4dffffff : 0x42000000); } finally { a.recycle(); } mProgressAnimation = new ValueAnimator(); mProgressAnimation.setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR); mProgressAnimation.addUpdateListener(animation -> { float value = (Float) animation.getAnimatedValue(); mDrawPercent = value; mDrawProgress = Math.round(MathUtils.lerp((float) mStart, mEnd, value)); updateBubblePosition(); mBubble.setProgress(mDrawProgress); invalidate(); }); mBubbleScaleAnimation = new ValueAnimator(); mBubbleScaleAnimation.addUpdateListener(animation -> { float value = (Float) animation.getAnimatedValue(); mDrawBubbleScale = value; mBubble.setScaleX(value); mBubble.setScaleY(value); invalidate(); }); } private void updateTextSize() { int length = CHARACTERS.length; float[] widths = new float[length]; mPaint.getTextWidths(CHARACTERS, 0, length, widths); mCharWidth = 0.0f; for (float f : widths) { mCharWidth = Math.max(mCharWidth, f); } Paint.FontMetrics fm = mPaint.getFontMetrics(); mCharHeight = fm.bottom - fm.top; } private void updateBubbleSize() { int oldWidth = mBubbleWidth; int oldHeight = mBubbleHeight; mBubbleWidth = (int) Math.max(mBubbleMinWidth, Integer.toString(mEnd).length() * mCharWidth + LayoutUtils.dp2pix(mContext, 8)); mBubbleHeight = (int) Math.max(mBubbleMinHeight, mCharHeight + LayoutUtils.dp2pix(mContext, 8)); if (oldWidth != mBubbleWidth && oldHeight != mBubbleHeight) { RelativeLayout.LayoutParams lp = (RelativeLayout.LayoutParams) mBubble.getLayoutParams(); lp.width = mBubbleWidth; lp.height = mBubbleHeight; mBubble.setLayoutParams(lp); } } private void updatePopup() { int width = getWidth(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); getLocationInWindow(mTemp); mPopupWidth = (int) (width - mRadius - mRadius + mBubbleWidth); int popupHeight = mBubbleHeight; mPopupX = (int) (mTemp[0] + mRadius - ((float) mBubbleWidth / 2)); mPopupY = (int) (mTemp[1] - popupHeight + paddingTop + ((float) (getHeight() - paddingTop - paddingBottom) / 2) - mRadius - LayoutUtils.dp2pix(mContext, 2)); mPopup.update(mPopupX, mPopupY, mPopupWidth, popupHeight, false); } private void updateBubblePosition() { float x = ((mPopupWidth - mBubbleWidth) * (mReverse ? (1.0f - mDrawPercent) : mDrawPercent)); mBubble.setX(x); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); updatePopup(); updateBubblePosition(); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); mPopup.showAtLocation(this, Gravity.TOP | Gravity.START, mPopupX, mPopupY); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); mPopup.dismiss(); } private void startProgressAnimation(float percent) { float currentValue; if (mProgressAnimation.isRunning()) { mProgressAnimation.setCurrentPlayTime(mProgressAnimation.getCurrentPlayTime()); Object value = mProgressAnimation.getAnimatedValue(); if (value instanceof Float) { currentValue = (float) value; } else { currentValue = mDrawPercent; } } else { currentValue = mDrawPercent; } mProgressAnimation.cancel(); mProgressAnimation.setFloatValues(currentValue, percent); mProgressAnimation.setDuration(Math.min(500, (long) (50 * getWidth() * Math.abs(currentValue - percent)))); mProgressAnimation.start(); } private void startShowBubbleAnimation() { mBubbleScaleAnimation.cancel(); mBubbleScaleAnimation.setFloatValues(mDrawBubbleScale, 1.0f); mBubbleScaleAnimation.setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR); mBubbleScaleAnimation.setDuration((long) (300 * Math.abs(mDrawBubbleScale - 1.0f))); mBubbleScaleAnimation.start(); } private void startHideBubbleAnimation() { mBubbleScaleAnimation.cancel(); mBubbleScaleAnimation.setFloatValues(mDrawBubbleScale, 0.0f); mBubbleScaleAnimation.setInterpolator(AnimationUtils.SLOW_FAST_INTERPOLATOR); mBubbleScaleAnimation.setDuration((long) (300 * Math.abs(mDrawBubbleScale - 0.0f))); mBubbleScaleAnimation.start(); } public void setColor(int color) { mPaint.setColor(color); mBubble.setColor(color); invalidate(); } public void setRange(int start, int end) { mStart = start; mEnd = end; setProgress(mProgress); updateBubbleSize(); } public int getProgress() { return mProgress; } public void setProgress(int progress) { progress = MathUtils.clamp(progress, mStart, mEnd); int oldProgress = mProgress; if (mProgress != progress) { mProgress = progress; mPercent = MathUtils.delerp(mStart, mEnd, mProgress); mTargetProgress = progress; if (mProgressAnimation == null) { // For init mDrawPercent = mPercent; mDrawProgress = mProgress; updateBubblePosition(); mBubble.setProgress(mDrawProgress); } else { startProgressAnimation(mPercent); } invalidate(); } if (mListener != null) { mListener.onSetProgress(this, progress, oldProgress, false, true); } } public void setReverse(boolean reverse) { if (mReverse != reverse) { mReverse = reverse; invalidate(); } } public void setOnSetProgressListener(OnSetProgressListener listener) { mListener = listener; } @Override protected void onDraw(@NonNull Canvas canvas) { int width = getWidth(); int height = getHeight(); if (width < LayoutUtils.dp2pix(mContext, 24)) { canvas.drawRect(0, 0, width, getHeight(), mPaint); } else { int paddingLeft = getPaddingLeft(); int paddingTop = getPaddingTop(); int paddingRight = getPaddingRight(); int paddingBottom = getPaddingBottom(); float thickness = mThickness; float radius = mRadius; float halfThickness = thickness / 2; int saved = canvas.save(); canvas.translate(0, paddingTop + ((float) (height - paddingTop - paddingBottom) / 2)); float currentX = paddingLeft + radius + (width - radius - radius - paddingLeft - paddingRight) * (mReverse ? (1.0f - mDrawPercent) : mDrawPercent); mLeftRectF.set(paddingLeft + radius, -halfThickness, currentX, halfThickness); mRightRectF.set(currentX, -halfThickness, width - paddingRight - radius, halfThickness); // Draw bar if (mReverse) { canvas.drawRect(mRightRectF, mPaint); canvas.drawRect(mLeftRectF, mBgPaint); } else { canvas.drawRect(mLeftRectF, mPaint); canvas.drawRect(mRightRectF, mBgPaint); } // Draw controller float scale = 1.0f - mDrawBubbleScale; if (scale != 0.0f) { canvas.scale(scale, scale, currentX, 0); canvas.drawCircle(currentX, 0, radius, mPaint); } canvas.restoreToCount(saved); } } private void setShowBubble(boolean showBubble) { if (mShowBubble != showBubble) { mShowBubble = showBubble; if (showBubble) { startShowBubbleAnimation(); } else { startHideBubbleAnimation(); } } } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(@NonNull MotionEvent event) { int action = event.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (mListener != null) { if (action == MotionEvent.ACTION_DOWN) { mListener.onFingerDown(); } else if (action == MotionEvent.ACTION_UP) { mListener.onFingerUp(); } } int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); float radius = mRadius; float x = event.getX(); int progress = Math.round(MathUtils.lerp((float) mStart, (float) mEnd, MathUtils.clamp((mReverse ? (getWidth() - paddingLeft - radius - x) : (x - radius - paddingLeft)) / (getWidth() - radius - radius - paddingLeft - paddingRight), 0.0f, 1.0f))); float percent = MathUtils.delerp(mStart, mEnd, progress); // ACTION_CANCEL not changed if (action == MotionEvent.ACTION_CANCEL) { progress = mProgress; percent = mPercent; } if (mTargetProgress != progress) { mTargetProgress = progress; startProgressAnimation(percent); if (mListener != null) { mListener.onSetProgress(this, mProgress, progress, true, false); } } if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { SimpleHandler.getInstance().removeCallbacks(mCheckForShowBubble); setShowBubble(false); } else if (action == MotionEvent.ACTION_DOWN) { if (mCheckForShowBubble == null) { mCheckForShowBubble = new CheckForShowBubble(); } SimpleHandler.getInstance().postDelayed(mCheckForShowBubble, ViewConfiguration.getTapTimeout()); } if (action == MotionEvent.ACTION_UP) { int oldProgress = mProgress; if (mProgress != progress) { mProgress = progress; mPercent = mDrawPercent; } if (mListener != null) { mListener.onSetProgress(this, progress, oldProgress, true, true); } } break; } return true; } public interface OnSetProgressListener { void onSetProgress(Slider slider, int newProgress, int oldProgress, boolean byUser, boolean confirm); void onFingerDown(); void onFingerUp(); } @SuppressLint("ViewConstructor") private static class BubbleView extends AppCompatImageView { private static final float TEXT_CENTER = (float) BUBBLE_WIDTH / 2.0f / BUBBLE_HEIGHT; private final Drawable mDrawable; private final Paint mTextPaint; private final Rect mRect = new Rect(); private String mProgressStr = ""; public BubbleView(Context context, Paint paint) { super(context); setImageResource(R.drawable.v_slider_bubble); mDrawable = DrawableCompat.wrap(getDrawable()); setImageDrawable(mDrawable); mTextPaint = paint; } public void setColor(int color) { DrawableCompat.setTint(mDrawable, color); } public void setProgress(int progress) { String str = Integer.toString(progress); if (!str.equals(mProgressStr)) { mProgressStr = str; mTextPaint.getTextBounds(str, 0, str.length(), mRect); invalidate(); } } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); setPivotX((float) w / 2); setPivotY(h); } @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); int width = getWidth(); int height = getHeight(); int x = width / 2; int y = (int) ((height * TEXT_CENTER) + ((float) mRect.height() / 2)); canvas.drawText(mProgressStr, x, y, mTextPaint); } } private final class CheckForShowBubble implements Runnable { @Override public void run() { setShowBubble(true); } } } ================================================ FILE: app/src/main/java/com/hippo/widget/TextClock.java ================================================ /* * Copyright (C) 2014 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget; import android.content.BroadcastReceiver; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.database.ContentObserver; import android.net.Uri; import android.os.Handler; import android.os.SystemClock; import android.provider.Settings; import android.text.format.DateFormat; import android.util.AttributeSet; import android.view.ViewDebug.ExportedProperty; import androidx.appcompat.widget.AppCompatTextView; import java.util.Calendar; import java.util.TimeZone; public class TextClock extends AppCompatTextView { public static final CharSequence DEFAULT_FORMAT_12_HOUR = "hh:mm a"; public static final CharSequence DEFAULT_FORMAT_24_HOUR; static { DEFAULT_FORMAT_24_HOUR = "HH:mm"; } private CharSequence mFormat12; private CharSequence mFormat24; @ExportedProperty private CharSequence mFormat; @ExportedProperty private boolean mHasSeconds; private boolean mAttached; private Calendar mTime; private String mTimeZone; private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (mTimeZone == null && Intent.ACTION_TIMEZONE_CHANGED.equals(intent.getAction())) { final String timeZone = intent.getStringExtra("time-zone"); createTime(timeZone); } onTimeChanged(); } }; public TextClock(Context context) { this(context, null); } public TextClock(Context context, AttributeSet attrs) { this(context, attrs, 0); } public TextClock(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mFormat12 = DEFAULT_FORMAT_12_HOUR; mFormat24 = DEFAULT_FORMAT_24_HOUR; mTimeZone = TimeZone.getDefault().getID(); init(); } private void init() { createTime(mTimeZone); // Wait until onAttachedToWindow() to handle the ticker chooseFormat(false); } private void createTime(String timeZone) { if (timeZone != null) { mTime = Calendar.getInstance(TimeZone.getTimeZone(timeZone)); } else { mTime = Calendar.getInstance(); } } public CharSequence getFormat12Hour() { return mFormat12; } public void setFormat12Hour(CharSequence format) { mFormat12 = format; chooseFormat(); onTimeChanged(); } public CharSequence getFormat24Hour() { return mFormat24; } private final Runnable mTicker = new Runnable() { @Override public void run() { onTimeChanged(); long now = SystemClock.uptimeMillis(); long next = now + (1000 - now % 1000); getHandler().postAtTime(mTicker, next); } }; public void setFormat24Hour(CharSequence format) { mFormat24 = format; chooseFormat(); onTimeChanged(); } public boolean is24HourModeEnabled() { return DateFormat.is24HourFormat(getContext()); } public String getTimeZone() { return mTimeZone; } public void setTimeZone(String timeZone) { mTimeZone = timeZone; createTime(timeZone); onTimeChanged(); } /** * Selects either one of {@link #getFormat12Hour()} or {@link #getFormat24Hour()} * depending on whether the user has selected 24-hour format. *

* Calling this method does not schedule or unschedule the time ticker. */ private void chooseFormat() { chooseFormat(true); } public CharSequence getFormat() { return mFormat; } /** * Selects either one of {@link #getFormat12Hour()} or {@link #getFormat24Hour()} * depending on whether the user has selected 24-hour format. * * @param handleTicker true if calling this method should schedule/unschedule the * time ticker, false otherwise */ private void chooseFormat(boolean handleTicker) { final boolean format24Requested = is24HourModeEnabled(); if (format24Requested) { mFormat = mFormat24; } else { mFormat = mFormat12; } boolean hadSeconds = mHasSeconds; mHasSeconds = DateUtils.hasSeconds(mFormat); if (handleTicker && mAttached && hadSeconds != mHasSeconds) { if (hadSeconds) getHandler().removeCallbacks(mTicker); else mTicker.run(); } } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); if (!mAttached) { mAttached = true; registerReceiver(); registerObserver(); createTime(mTimeZone); if (mHasSeconds) { mTicker.run(); } else { onTimeChanged(); } } } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (mAttached) { unregisterReceiver(); unregisterObserver(); getHandler().removeCallbacks(mTicker); mAttached = false; } } private void registerReceiver() { final IntentFilter filter = new IntentFilter(); filter.addAction(Intent.ACTION_TIME_TICK); filter.addAction(Intent.ACTION_TIME_CHANGED); filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); getContext().registerReceiver(mIntentReceiver, filter, null, getHandler()); } private final ContentObserver mFormatChangeObserver = new ContentObserver(new Handler()) { @Override public void onChange(boolean selfChange) { chooseFormat(); onTimeChanged(); } @Override public void onChange(boolean selfChange, Uri uri) { chooseFormat(); onTimeChanged(); } }; private void registerObserver() { final ContentResolver resolver = getContext().getContentResolver(); resolver.registerContentObserver(Settings.System.CONTENT_URI, true, mFormatChangeObserver); } private void unregisterReceiver() { getContext().unregisterReceiver(mIntentReceiver); } private void unregisterObserver() { final ContentResolver resolver = getContext().getContentResolver(); resolver.unregisterContentObserver(mFormatChangeObserver); } private void onTimeChanged() { mTime.setTimeInMillis(System.currentTimeMillis()); setText(DateFormat.format(mFormat, mTime)); } } ================================================ FILE: app/src/main/java/com/hippo/widget/lockpattern/LockPatternUtils.java ================================================ /* * Copyright (C) 2007 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget.lockpattern; import java.util.ArrayList; import java.util.List; /** * Utilities for the lock pattern and its settings. */ public class LockPatternUtils { /** * Deserialize a pattern. * * @param string The pattern serialized with {@link #patternToString} * @return The pattern. */ public static List stringToPattern(String string) { List result = new ArrayList<>(); final byte[] bytes = string.getBytes(); for (byte b : bytes) { result.add(LockPatternView.Cell.of(b / 3, b % 3)); } return result; } /** * Serialize a pattern. * * @param pattern The pattern. * @return The pattern in string form. */ public static String patternToString(List pattern) { if (pattern == null) { return ""; } final int patternSize = pattern.size(); byte[] res = new byte[patternSize]; for (int i = 0; i < patternSize; i++) { LockPatternView.Cell cell = pattern.get(i); res[i] = (byte) (cell.getRow() * 3 + cell.getColumn()); } return new String(res); } } ================================================ FILE: app/src/main/java/com/hippo/widget/lockpattern/LockPatternView.java ================================================ /* * Copyright (C) 2007 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget.lockpattern; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.graphics.Rect; import android.os.Debug; import android.os.Parcel; import android.os.Parcelable; import android.os.SystemClock; import android.util.AttributeSet; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; import android.view.accessibility.AccessibilityManager; import android.view.animation.Interpolator; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.interpolator.view.animation.FastOutSlowInInterpolator; import androidx.interpolator.view.animation.LinearOutSlowInInterpolator; import com.hippo.ehviewer.R; import java.util.ArrayList; import java.util.List; /** * Displays and detects the user's unlock attempt, which is a drag of a finger * across 9 regions of the screen. *

* Is also capable of displaying a static pattern in "in progress", "wrong" or * "correct" states. */ @SuppressWarnings("IntegerDivisionInFloatingPointContext") public class LockPatternView extends View { // Aspect to use when rendering this view private static final int ASPECT_SQUARE = 0; // View will be the minimum of width/height private static final int ASPECT_LOCK_WIDTH = 1; // Fixed width; height will be minimum of (w,h) private static final int ASPECT_LOCK_HEIGHT = 2; // Fixed height; width will be minimum of (w,h) private static final boolean PROFILE_DRAWING = false; /** * How many milliseconds we spend animating each circle of a lock pattern * if the animating mode is set. The entire animation should take this * constant * the length of the pattern to complete. */ private static final int MILLIS_PER_CIRCLE_ANIMATING = 700; /** * This can be used to avoid updating the display for very small motions or noisy panels. * It didn't seem to have much impact on the devices tested, so currently set to 0. */ private static final float DRAG_THRESHOLD = 0.0f; private final Cell[][] mCells; private final CellState[][] mCellStates; private final int mDotSize; private final int mDotSizeActivated; private final int mPathWidth; private final Paint mPaint = new Paint(); private final Paint mPathPaint = new Paint(); private final ArrayList mPattern = new ArrayList<>(9); /** * Lookup table for the circles of the pattern we are currently drawing. * This will be the cells of the complete pattern unless we are animating, * in which case we use this to hold the cells we are drawing for the in * progress animation. */ private final boolean[][] mPatternDrawLookup = new boolean[3][3]; private final float mHitFactor = 0.6f; private final Path mCurrentPath = new Path(); private final Rect mInvalidate = new Rect(); private final Rect mTmpInvalidateRect = new Rect(); private final int mAspect; private final int mRegularColor; private final int mErrorColor; private final int mSuccessColor; private final Interpolator mFastOutSlowInInterpolator; private final Interpolator mLinearOutSlowInInterpolator; private boolean mDrawingProfilingStarted = false; private OnPatternListener mOnPatternListener; /** * the in progress point: * - during interaction: where the user's finger is * - during animation: the current tip of the animating line */ private float mInProgressX = -1; private float mInProgressY = -1; private long mAnimatingPeriodStart; private DisplayMode mPatternDisplayMode = DisplayMode.Correct; private boolean mInputEnabled = true; private boolean mInStealthMode = false; private boolean mPatternInProgress = false; private float mSquareWidth; private float mSquareHeight; public LockPatternView(Context context) { this(context, null); } public LockPatternView(Context context, AttributeSet attrs) { super(context, attrs); //TypedArray a = context.obtainStyledAttributes(attrs, LockPatternView); //final String aspect = a.getString(R.styleable.LockPatternView_aspect); //if ("square".equals(aspect)) { // mAspect = ASPECT_SQUARE; //} else if ("lock_width".equals(aspect)) { // mAspect = ASPECT_LOCK_WIDTH; //} else if ("lock_height".equals(aspect)) { // mAspect = ASPECT_LOCK_HEIGHT; //} else { mAspect = ASPECT_SQUARE; //} setClickable(true); mPathPaint.setAntiAlias(true); mPathPaint.setDither(true); mRegularColor = ContextCompat.getColor(context, R.color.lock_pattern_view_regular_color); mErrorColor = ContextCompat.getColor(context, R.color.lock_pattern_view_error_color); mSuccessColor = ContextCompat.getColor(context, R.color.lock_pattern_view_success_color); mPathPaint.setColor(mRegularColor); mPathPaint.setStyle(Paint.Style.STROKE); mPathPaint.setStrokeJoin(Paint.Join.ROUND); mPathPaint.setStrokeCap(Paint.Cap.ROUND); mPathWidth = getResources().getDimensionPixelSize(R.dimen.lock_pattern_dot_line_width); mPathPaint.setStrokeWidth(mPathWidth); mDotSize = getResources().getDimensionPixelSize(R.dimen.lock_pattern_dot_size); mDotSizeActivated = getResources().getDimensionPixelSize( R.dimen.lock_pattern_dot_size_activated); mPaint.setAntiAlias(true); mPaint.setDither(true); mCellStates = new CellState[3][3]; for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { mCellStates[i][j] = new CellState(); mCellStates[i][j].radius = mDotSize / 2; mCellStates[i][j].row = i; mCellStates[i][j].col = j; } } mCells = new Cell[3][3]; for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { mCells[i][j] = Cell.of(i, j); } } mFastOutSlowInInterpolator = new FastOutSlowInInterpolator(); mLinearOutSlowInInterpolator = new LinearOutSlowInInterpolator(); } /** * Returns the greatest common divisor of {@code a, b}. Returns {@code 0} if * {@code a == 0 && b == 0}. * * @throws IllegalArgumentException if {@code a < 0} or {@code b < 0} */ private static int gcd(int a, int b) { /* * The reason we require both arguments to be >= 0 is because otherwise, what do you return * on gcd(0, Integer.MIN_VALUE)? BigInteger.gcd would return positive 2^31, but positive * 2^31 isn't an int. */ //checkNonNegative("a", a); if (a < 0) { throw new IllegalArgumentException("a (" + a + ") must be >= 0"); } //checkNonNegative("b", b); if (b < 0) { throw new IllegalArgumentException("b (" + b + ") must be >= 0"); } if (a == 0) { // 0 % b == 0, so b divides a, but the converse doesn't hold. // BigInteger.gcd is consistent with this decision. return b; } else if (b == 0) { return a; // similar logic } /* * Uses the binary GCD algorithm; see http://en.wikipedia.org/wiki/Binary_GCD_algorithm. * This is >40% faster than the Euclidean algorithm in benchmarks. */ int aTwos = Integer.numberOfTrailingZeros(a); a >>= aTwos; // divide out all 2s int bTwos = Integer.numberOfTrailingZeros(b); b >>= bTwos; // divide out all 2s while (a != b) { // both a, b are odd // The key to the binary GCD algorithm is as follows: // Both a and b are odd. Assume a > b; then gcd(a - b, b) = gcd(a, b). // But in gcd(a - b, b), a - b is even and b is odd, so we can divide out powers of two. // We bend over backwards to avoid branching, adapting a technique from // http://graphics.stanford.edu/~seander/bithacks.html#IntegerMinOrMax int delta = a - b; // can't overflow, since a and b are nonnegative int minDeltaOrZero = delta & (delta >> (Integer.SIZE - 1)); // equivalent to Math.min(delta, 0) a = delta - minDeltaOrZero - minDeltaOrZero; // sets a to Math.abs(a - b) // a is now nonnegative and even b += minDeltaOrZero; // sets b to min(old a, b) a >>= Integer.numberOfTrailingZeros(a); // divide out all 2s, since 2 doesn't divide b } return a << Math.min(aTwos, bTwos); } public int getCellSize() { return mPattern.size(); } public String getPatternString() { return LockPatternUtils.patternToString(mPattern); } /** * Set the call back for pattern detection. * * @param onPatternListener The call back. */ public void setOnPatternListener( OnPatternListener onPatternListener) { mOnPatternListener = onPatternListener; } /** * Set the pattern explicitly (rather than waiting for the user to input * a pattern). * * @param displayMode How to display the pattern. * @param pattern The pattern. */ public void setPattern(DisplayMode displayMode, List pattern) { mPattern.clear(); mPattern.addAll(pattern); clearPatternDrawLookup(); for (Cell cell : pattern) { mPatternDrawLookup[cell.getRow()][cell.getColumn()] = true; } setDisplayMode(displayMode); } /** * Set the display mode of the current pattern. This can be useful, for * instance, after detecting a pattern to tell this view whether change the * in progress result to correct or wrong. * * @param displayMode The display mode. */ public void setDisplayMode(DisplayMode displayMode) { mPatternDisplayMode = displayMode; if (displayMode == DisplayMode.Animate) { if (mPattern.isEmpty()) { throw new IllegalStateException("you must have a pattern to " + "animate if you want to set the display mode to animate"); } mAnimatingPeriodStart = SystemClock.elapsedRealtime(); //noinspection SequencedCollectionMethodCanBeUsed final Cell first = mPattern.get(0); mInProgressX = getCenterXForColumn(first.getColumn()); mInProgressY = getCenterYForRow(first.getRow()); clearPatternDrawLookup(); } invalidate(); } private void notifyCellAdded() { if (mOnPatternListener != null) { mOnPatternListener.onPatternCellAdded(mPattern); } } private void notifyPatternStarted() { if (mOnPatternListener != null) { mOnPatternListener.onPatternStart(); } } private void notifyPatternDetected() { if (mOnPatternListener != null) { mOnPatternListener.onPatternDetected(mPattern); } } private void notifyPatternCleared() { if (mOnPatternListener != null) { mOnPatternListener.onPatternCleared(); } } /** * Reset all pattern state. */ private void resetPattern() { mPattern.clear(); clearPatternDrawLookup(); mPatternDisplayMode = DisplayMode.Correct; invalidate(); } /** * Clear the pattern lookup table. */ private void clearPatternDrawLookup() { for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { mPatternDrawLookup[i][j] = false; } } } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { final int width = w - getPaddingLeft() - getPaddingRight(); mSquareWidth = width / 3.0f; final int height = h - getPaddingTop() - getPaddingBottom(); mSquareHeight = height / 3.0f; } @SuppressLint("SwitchIntDef") private int resolveMeasured(int measureSpec, int desired) { int specSize = MeasureSpec.getSize(measureSpec); return switch (MeasureSpec.getMode(measureSpec)) { case MeasureSpec.UNSPECIFIED -> desired; case MeasureSpec.AT_MOST -> Math.max(specSize, desired); default -> specSize; }; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final int minimumWidth = getSuggestedMinimumWidth(); final int minimumHeight = getSuggestedMinimumHeight(); int viewWidth = resolveMeasured(widthMeasureSpec, minimumWidth); int viewHeight = resolveMeasured(heightMeasureSpec, minimumHeight); switch (mAspect) { case ASPECT_SQUARE: viewWidth = viewHeight = Math.min(viewWidth, viewHeight); break; case ASPECT_LOCK_WIDTH: viewHeight = Math.min(viewWidth, viewHeight); break; case ASPECT_LOCK_HEIGHT: viewWidth = Math.min(viewWidth, viewHeight); break; } // Log.v(TAG, "LockPatternView dimensions: " + viewWidth + "x" + viewHeight); setMeasuredDimension(viewWidth, viewHeight); } // From // https://github.com/google/guava/blob/c462d69329709f72a17a64cb229d15e76e72199c/guava/src/com/google/common/math/IntMath.java /** * Determines whether the point x, y will add a new point to the current * pattern (in addition to finding the cell, also makes heuristic choices * such as filling in gaps based on current pattern). * * @param x The x coordinate. * @param y The y coordinate. */ private Cell detectAndAddHit(float x, float y) { final Cell cell = checkForNewHit(x, y); if (cell != null) { // check for gaps in existing pattern final ArrayList pattern = mPattern; if (!pattern.isEmpty()) { //noinspection SequencedCollectionMethodCanBeUsed final Cell lastCell = pattern.get(pattern.size() - 1); int dRow = cell.row - lastCell.row; int dColumn = cell.column - lastCell.column; int dGcd = gcd(Math.abs(dRow), Math.abs(dColumn)); if (dGcd > 0) { int fillInRow = lastCell.row; int fillInColumn = lastCell.column; int fillInRowStep = dRow / dGcd; int fillInColumnStep = dColumn / dGcd; for (int i = 1; i < dGcd; ++i) { fillInRow += fillInRowStep; fillInColumn += fillInColumnStep; if (!mPatternDrawLookup[fillInRow][fillInColumn]) { addCellToPattern(mCells[fillInRow][fillInColumn]); } } } } addCellToPattern(cell); performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY, HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING | HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING); return cell; } return null; } private void addCellToPattern(Cell newCell) { mPatternDrawLookup[newCell.getRow()][newCell.getColumn()] = true; mPattern.add(newCell); if (!mInStealthMode) { startCellActivatedAnimation(newCell); } notifyCellAdded(); } private void startCellActivatedAnimation(Cell cell) { final CellState cellState = mCellStates[cell.row][cell.column]; startRadiusAnimation(mDotSize / 2, mDotSizeActivated / 2, 96, mLinearOutSlowInInterpolator, cellState, () -> startRadiusAnimation(mDotSizeActivated / 2, mDotSize / 2, 192, mFastOutSlowInInterpolator, cellState, null)); startLineEndAnimation(cellState, mInProgressX, mInProgressY, getCenterXForColumn(cell.column), getCenterYForRow(cell.row)); } private void startLineEndAnimation(final CellState state, final float startX, final float startY, final float targetX, final float targetY) { ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1); valueAnimator.addUpdateListener(animation -> { float t = (float) animation.getAnimatedValue(); state.lineEndX = (1 - t) * startX + t * targetX; state.lineEndY = (1 - t) * startY + t * targetY; invalidate(); }); valueAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { state.lineAnimator = null; } }); valueAnimator.setInterpolator(mFastOutSlowInInterpolator); valueAnimator.setDuration(100); valueAnimator.start(); state.lineAnimator = valueAnimator; } private void startRadiusAnimation(float start, float end, long duration, Interpolator interpolator, final CellState state, final Runnable endRunnable) { ValueAnimator valueAnimator = ValueAnimator.ofFloat(start, end); valueAnimator.addUpdateListener(animation -> { state.radius = (float) animation.getAnimatedValue(); invalidate(); }); if (endRunnable != null) { valueAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { endRunnable.run(); } }); } valueAnimator.setInterpolator(interpolator); valueAnimator.setDuration(duration); valueAnimator.start(); } // helper method to find which cell a point maps to private Cell checkForNewHit(float x, float y) { final int rowHit = getRowHit(y); if (rowHit < 0) { return null; } final int columnHit = getColumnHit(x); if (columnHit < 0) { return null; } if (mPatternDrawLookup[rowHit][columnHit]) { return null; } return mCells[rowHit][columnHit]; } /** * Helper method to find the row that y falls into. * * @param y The y coordinate * @return The row that y falls in, or -1 if it falls in no row. */ private int getRowHit(float y) { final float squareHeight = mSquareHeight; float hitSize = squareHeight * mHitFactor; float offset = getPaddingTop() + (squareHeight - hitSize) / 2f; for (int i = 0; i < 3; i++) { final float hitTop = offset + squareHeight * i; if (y >= hitTop && y <= hitTop + hitSize) { return i; } } return -1; } /** * Helper method to find the column x fallis into. * * @param x The x coordinate. * @return The column that x falls in, or -1 if it falls in no column. */ private int getColumnHit(float x) { final float squareWidth = mSquareWidth; float hitSize = squareWidth * mHitFactor; float offset = getPaddingLeft() + (squareWidth - hitSize) / 2f; for (int i = 0; i < 3; i++) { final float hitLeft = offset + squareWidth * i; if (x >= hitLeft && x <= hitLeft + hitSize) { return i; } } return -1; } @Override public boolean onHoverEvent(@NonNull MotionEvent event) { AccessibilityManager accessibilityManager = (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); if (accessibilityManager.isTouchExplorationEnabled()) { final int action = event.getAction(); switch (action) { case MotionEvent.ACTION_HOVER_ENTER: event.setAction(MotionEvent.ACTION_DOWN); break; case MotionEvent.ACTION_HOVER_MOVE: event.setAction(MotionEvent.ACTION_MOVE); break; case MotionEvent.ACTION_HOVER_EXIT: event.setAction(MotionEvent.ACTION_UP); break; } onTouchEvent(event); event.setAction(action); } return super.onHoverEvent(event); } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(@NonNull MotionEvent event) { if (!mInputEnabled || !isEnabled()) { return false; } switch (event.getAction()) { case MotionEvent.ACTION_DOWN: handleActionDown(event); return true; case MotionEvent.ACTION_UP: handleActionUp(); return true; case MotionEvent.ACTION_MOVE: handleActionMove(event); return true; case MotionEvent.ACTION_CANCEL: if (mPatternInProgress) { setPatternInProgress(false); resetPattern(); notifyPatternCleared(); } if (PROFILE_DRAWING) { if (mDrawingProfilingStarted) { Debug.stopMethodTracing(); mDrawingProfilingStarted = false; } } return true; } return false; } private void setPatternInProgress(boolean progress) { mPatternInProgress = progress; } private void handleActionMove(MotionEvent event) { // Handle all recent motion events so we don't skip any cells even when the device // is busy... final float radius = mPathWidth; final int historySize = event.getHistorySize(); mTmpInvalidateRect.setEmpty(); boolean invalidateNow = false; for (int i = 0; i < historySize + 1; i++) { final float x = i < historySize ? event.getHistoricalX(i) : event.getX(); final float y = i < historySize ? event.getHistoricalY(i) : event.getY(); Cell hitCell = detectAndAddHit(x, y); final int patternSize = mPattern.size(); if (hitCell != null && patternSize == 1) { setPatternInProgress(true); notifyPatternStarted(); } // note current x and y for rubber banding of in progress patterns final float dx = Math.abs(x - mInProgressX); final float dy = Math.abs(y - mInProgressY); if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) { invalidateNow = true; } if (mPatternInProgress && patternSize > 0) { final Cell lastCell = mPattern.get(patternSize - 1); float lastCellCenterX = getCenterXForColumn(lastCell.column); float lastCellCenterY = getCenterYForRow(lastCell.row); // Adjust for drawn segment from last cell to (x,y). Radius accounts for line width. float left = Math.min(lastCellCenterX, x) - radius; float right = Math.max(lastCellCenterX, x) + radius; float top = Math.min(lastCellCenterY, y) - radius; float bottom = Math.max(lastCellCenterY, y) + radius; // Invalidate between the pattern's new cell and the pattern's previous cell if (hitCell != null) { final float width = mSquareWidth * 0.5f; final float height = mSquareHeight * 0.5f; final float hitCellCenterX = getCenterXForColumn(hitCell.column); final float hitCellCenterY = getCenterYForRow(hitCell.row); left = Math.min(hitCellCenterX - width, left); right = Math.max(hitCellCenterX + width, right); top = Math.min(hitCellCenterY - height, top); bottom = Math.max(hitCellCenterY + height, bottom); } // Invalidate between the pattern's last cell and the previous location mTmpInvalidateRect.union(Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)); } } mInProgressX = event.getX(); mInProgressY = event.getY(); // To save updates, we only invalidate if the user moved beyond a certain amount. if (invalidateNow) { mInvalidate.union(mTmpInvalidateRect); invalidate(mInvalidate); mInvalidate.set(mTmpInvalidateRect); } } private void handleActionUp() { // report pattern detected if (!mPattern.isEmpty()) { setPatternInProgress(false); cancelLineAnimations(); notifyPatternDetected(); invalidate(); } if (PROFILE_DRAWING) { if (mDrawingProfilingStarted) { Debug.stopMethodTracing(); mDrawingProfilingStarted = false; } } } private void cancelLineAnimations() { for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { CellState state = mCellStates[i][j]; if (state.lineAnimator != null) { state.lineAnimator.cancel(); state.lineEndX = Float.MIN_VALUE; state.lineEndY = Float.MIN_VALUE; } } } } private void handleActionDown(MotionEvent event) { resetPattern(); final float x = event.getX(); final float y = event.getY(); final Cell hitCell = detectAndAddHit(x, y); if (hitCell != null) { setPatternInProgress(true); mPatternDisplayMode = DisplayMode.Correct; notifyPatternStarted(); } else if (mPatternInProgress) { setPatternInProgress(false); notifyPatternCleared(); } if (hitCell != null) { final float startX = getCenterXForColumn(hitCell.column); final float startY = getCenterYForRow(hitCell.row); final float widthOffset = mSquareWidth / 2f; final float heightOffset = mSquareHeight / 2f; invalidate((int) (startX - widthOffset), (int) (startY - heightOffset), (int) (startX + widthOffset), (int) (startY + heightOffset)); } mInProgressX = x; mInProgressY = y; if (PROFILE_DRAWING) { if (!mDrawingProfilingStarted) { Debug.startMethodTracing("LockPatternDrawing"); mDrawingProfilingStarted = true; } } } private float getCenterXForColumn(int column) { return getPaddingLeft() + column * mSquareWidth + mSquareWidth / 2f; } private float getCenterYForRow(int row) { return getPaddingTop() + row * mSquareHeight + mSquareHeight / 2f; } @Override protected void onDraw(@NonNull Canvas canvas) { final ArrayList pattern = mPattern; final int count = pattern.size(); final boolean[][] drawLookup = mPatternDrawLookup; if (mPatternDisplayMode == DisplayMode.Animate) { // figure out which circles to draw // + 1 so we pause on complete pattern final int oneCycle = (count + 1) * MILLIS_PER_CIRCLE_ANIMATING; final int spotInCycle = (int) (SystemClock.elapsedRealtime() - mAnimatingPeriodStart) % oneCycle; final int numCircles = spotInCycle / MILLIS_PER_CIRCLE_ANIMATING; clearPatternDrawLookup(); for (int i = 0; i < numCircles; i++) { final Cell cell = pattern.get(i); drawLookup[cell.getRow()][cell.getColumn()] = true; } // figure out in progress portion of ghosting line final boolean needToUpdateInProgressPoint = numCircles > 0 && numCircles < count; if (needToUpdateInProgressPoint) { final float percentageOfNextCircle = ((float) (spotInCycle % MILLIS_PER_CIRCLE_ANIMATING)) / MILLIS_PER_CIRCLE_ANIMATING; final Cell currentCell = pattern.get(numCircles - 1); final float centerX = getCenterXForColumn(currentCell.column); final float centerY = getCenterYForRow(currentCell.row); final Cell nextCell = pattern.get(numCircles); final float dx = percentageOfNextCircle * (getCenterXForColumn(nextCell.column) - centerX); final float dy = percentageOfNextCircle * (getCenterYForRow(nextCell.row) - centerY); mInProgressX = centerX + dx; mInProgressY = centerY + dy; } // TODO: Infinite loop here... invalidate(); } final Path currentPath = mCurrentPath; currentPath.rewind(); // draw the circles for (int i = 0; i < 3; i++) { float centerY = getCenterYForRow(i); for (int j = 0; j < 3; j++) { CellState cellState = mCellStates[i][j]; float centerX = getCenterXForColumn(j); float translationY = cellState.translationY; drawCircle(canvas, (int) centerX, (int) centerY + translationY, cellState.radius, drawLookup[i][j], cellState.alpha); } } // TODO: the path should be created and cached every time we hit-detect a cell // only the last segment of the path should be computed here // draw the path of the pattern (unless we are in stealth mode) final boolean drawPath = !mInStealthMode; if (drawPath) { mPathPaint.setColor(getCurrentColor(true /* partOfPattern */)); // Anyway other drawing sets their own alpha ignoring the original; And in this way we // can use ?colorControlNormal better. mPathPaint.setAlpha(255); boolean anyCircles = false; float lastX = 0f; float lastY = 0f; for (int i = 0; i < count; i++) { Cell cell = pattern.get(i); // only draw the part of the pattern stored in // the lookup table (this is only different in the case // of animation). if (!drawLookup[cell.row][cell.column]) { break; } anyCircles = true; float centerX = getCenterXForColumn(cell.column); float centerY = getCenterYForRow(cell.row); if (i != 0) { CellState state = mCellStates[cell.row][cell.column]; currentPath.rewind(); currentPath.moveTo(lastX, lastY); if (state.lineEndX != Float.MIN_VALUE && state.lineEndY != Float.MIN_VALUE) { currentPath.lineTo(state.lineEndX, state.lineEndY); } else { currentPath.lineTo(centerX, centerY); } canvas.drawPath(currentPath, mPathPaint); } lastX = centerX; lastY = centerY; } // draw last in progress section if ((mPatternInProgress || mPatternDisplayMode == DisplayMode.Animate) && anyCircles) { currentPath.rewind(); currentPath.moveTo(lastX, lastY); currentPath.lineTo(mInProgressX, mInProgressY); mPathPaint.setAlpha((int) (calculateLastSegmentAlpha( mInProgressX, mInProgressY, lastX, lastY) * 255f)); canvas.drawPath(currentPath, mPathPaint); } } } private float calculateLastSegmentAlpha(float x, float y, float lastX, float lastY) { float diffX = x - lastX; float diffY = y - lastY; float dist = (float) Math.sqrt(diffX * diffX + diffY * diffY); float frac = dist / mSquareWidth; return Math.min(1f, Math.max(0f, (frac - 0.3f) * 4f)); } private int getCurrentColor(boolean partOfPattern) { if (!partOfPattern || mInStealthMode || mPatternInProgress) { // unselected circle return mRegularColor; } else if (mPatternDisplayMode == DisplayMode.Wrong) { // the pattern is wrong return mErrorColor; } else if (mPatternDisplayMode == DisplayMode.Correct || mPatternDisplayMode == DisplayMode.Animate) { return mSuccessColor; } else { throw new IllegalStateException("unknown display mode " + mPatternDisplayMode); } } /** * @param partOfPattern Whether this circle is part of the pattern. */ private void drawCircle(Canvas canvas, float centerX, float centerY, float radius, boolean partOfPattern, float alpha) { mPaint.setColor(getCurrentColor(partOfPattern)); mPaint.setAlpha((int) (alpha * 255)); canvas.drawCircle(centerX, centerY, radius, mPaint); } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); return new SavedState(superState, LockPatternUtils.patternToString(mPattern), mPatternDisplayMode.ordinal(), mInputEnabled, mInStealthMode); } @Override protected void onRestoreInstanceState(Parcelable state) { final SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); setPattern( DisplayMode.Correct, LockPatternUtils.stringToPattern(ss.getSerializedPattern())); mPatternDisplayMode = DisplayMode.values()[ss.getDisplayMode()]; mInputEnabled = ss.isInputEnabled(); mInStealthMode = ss.isInStealthMode(); } /** * How to display the current pattern. */ public enum DisplayMode { /** * The pattern drawn is correct (i.e draw it in a friendly color) */ Correct, /** * Animate the pattern (for demo, and help). */ Animate, /** * The pattern is wrong (i.e draw a foreboding color) */ Wrong } /** * The call back interface for detecting patterns entered by the user. */ public interface OnPatternListener { /** * A new pattern has begun. */ void onPatternStart(); /** * The pattern was cleared. */ void onPatternCleared(); /** * The user extended the pattern currently being drawn by one cell. * * @param pattern The pattern with newly added cell. */ void onPatternCellAdded(List pattern); /** * A pattern was detected from the user. * * @param pattern The pattern. */ void onPatternDetected(List pattern); } /** * Represents a cell in the 3 X 3 matrix of the unlock pattern view. */ public static class Cell { // keep # objects limited to 9 static Cell[][] sCells = new Cell[3][3]; static { for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { sCells[i][j] = new Cell(i, j); } } } int row; int column; /** * @param row The row of the cell. * @param column The column of the cell. */ private Cell(int row, int column) { checkRange(row, column); this.row = row; this.column = column; } /** * @param row The row of the cell. * @param column The column of the cell. */ public static synchronized Cell of(int row, int column) { checkRange(row, column); return sCells[row][column]; } private static void checkRange(int row, int column) { if (row < 0 || row > 2) { throw new IllegalArgumentException("row must be in range 0-2"); } if (column < 0 || column > 2) { throw new IllegalArgumentException("column must be in range 0-2"); } } public int getRow() { return row; } public int getColumn() { return column; } @NonNull public String toString() { return "(row=" + row + ",clmn=" + column + ")"; } } public static class CellState { public float lineEndX = Float.MIN_VALUE; public float lineEndY = Float.MIN_VALUE; public ValueAnimator lineAnimator; int row; int col; float radius; float translationY; float alpha = 1f; } /** * The parecelable for saving and restoring a lock pattern view. */ private static class SavedState extends BaseSavedState { public static final Creator CREATOR = new Creator<>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; private final String mSerializedPattern; private final int mDisplayMode; private final boolean mInputEnabled; private final boolean mInStealthMode; /** * Constructor called from {@link LockPatternView#onSaveInstanceState()} */ private SavedState(Parcelable superState, String serializedPattern, int displayMode, boolean inputEnabled, boolean inStealthMode) { super(superState); mSerializedPattern = serializedPattern; mDisplayMode = displayMode; mInputEnabled = inputEnabled; mInStealthMode = inStealthMode; } /** * Constructor called from {@link #CREATOR} */ @SuppressLint("ParcelClassLoader") private SavedState(Parcel in) { super(in); mSerializedPattern = in.readString(); mDisplayMode = in.readInt(); mInputEnabled = (Boolean) in.readValue(null); mInStealthMode = (Boolean) in.readValue(null); } public String getSerializedPattern() { return mSerializedPattern; } public int getDisplayMode() { return mDisplayMode; } public boolean isInputEnabled() { return mInputEnabled; } public boolean isInStealthMode() { return mInStealthMode; } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeString(mSerializedPattern); dest.writeInt(mDisplayMode); dest.writeValue(mInputEnabled); dest.writeValue(mInStealthMode); } } } ================================================ FILE: app/src/main/java/com/hippo/widget/recyclerview/AutoGridLayoutManager.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget.recyclerview; import android.content.Context; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.List; public class AutoGridLayoutManager extends GridLayoutManager { public static final int STRATEGY_SUITABLE_SIZE = 1; private int mColumnSize = -1; private boolean mColumnSizeChanged = true; private int mStrategy; private int fakePadding; private List mListeners; public AutoGridLayoutManager(Context context, int columnSize, int fakePadding) { super(context, 1); this.fakePadding = fakePadding; setColumnSize(columnSize); } public AutoGridLayoutManager(Context context, int columnSize, int orientation, boolean reverseLayout) { super(context, 1, orientation, reverseLayout); setColumnSize(columnSize); } public static int getSpanCountForSuitableSize(int total, int single) { int span = total / single; if (span <= 0) { return 1; } int span2 = span + 1; float deviation = Math.abs(1 - ((float) total / span / (float) single)); float deviation2 = Math.abs(1 - ((float) total / span2 / (float) single)); return deviation < deviation2 ? span : span2; } public static int getSpanCountForMinSize(int total, int single) { return Math.max(1, total / single); } public void setColumnSize(int columnSize) { if (columnSize == mColumnSize) { return; } mColumnSize = columnSize; mColumnSizeChanged = true; } public void setStrategy(int strategy) { if (strategy == mStrategy) { return; } mStrategy = strategy; mColumnSizeChanged = true; } @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { if (mColumnSizeChanged && mColumnSize > 0) { int totalSpace; if (getOrientation() == RecyclerView.VERTICAL) { totalSpace = getWidth() - getPaddingRight() - getPaddingLeft() - fakePadding; } else { totalSpace = getHeight() - getPaddingTop() - getPaddingBottom() - fakePadding; } int spanCount; if (mStrategy == STRATEGY_SUITABLE_SIZE) { spanCount = getSpanCountForSuitableSize(totalSpace, mColumnSize); } else { spanCount = getSpanCountForMinSize(totalSpace, mColumnSize); } setSpanCount(spanCount); mColumnSizeChanged = false; if (null != mListeners) { for (int i = 0, n = mListeners.size(); i < n; i++) { mListeners.get(i).onUpdateSpanCount(spanCount); } } } super.onLayoutChildren(recycler, state); } public void addOnUpdateSpanCountListener(OnUpdateSpanCountListener listener) { if (null == mListeners) { mListeners = new ArrayList<>(); } mListeners.add(listener); } public void removeOnUpdateSpanCountListener(OnUpdateSpanCountListener listener) { if (null != mListeners) { mListeners.remove(listener); } } public interface OnUpdateSpanCountListener { void onUpdateSpanCount(int spanCount); } } ================================================ FILE: app/src/main/java/com/hippo/widget/recyclerview/AutoStaggeredGridLayoutManager.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.widget.recyclerview; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; import java.util.ArrayList; import java.util.List; public class AutoStaggeredGridLayoutManager extends StaggeredGridLayoutManager { public static final int STRATEGY_MIN_SIZE = 0; public static final int STRATEGY_SUITABLE_SIZE = 1; private int mColumnSize = -1; private boolean mColumnSizeChanged = true; private int mStrategy; private List mListeners; public AutoStaggeredGridLayoutManager(int columnSize, int orientation) { super(1, orientation); setColumnSize(columnSize); } public static int getSpanCountForSuitableSize(int total, int single) { int span = total / single; if (span <= 0) { return 1; } int span2 = span + 1; float deviation = Math.abs(1 - ((float) total / span / (float) single)); float deviation2 = Math.abs(1 - ((float) total / span2 / (float) single)); return deviation < deviation2 ? span : span2; } public static int getSpanCountForMinSize(int total, int single) { return Math.max(1, total / single); } public void setColumnSize(int columnSize) { if (columnSize == mColumnSize) { return; } mColumnSize = columnSize; mColumnSizeChanged = true; } public void setStrategy(int strategy) { if (strategy == mStrategy) { return; } mStrategy = strategy; mColumnSizeChanged = true; } @Override public void onMeasure(@NonNull RecyclerView.Recycler recycler, @NonNull RecyclerView.State state, int widthSpec, int heightSpec) { if (mColumnSizeChanged && mColumnSize > 0) { int totalSpace; if (getOrientation() == VERTICAL) { if (View.MeasureSpec.EXACTLY != View.MeasureSpec.getMode(widthSpec)) { throw new IllegalStateException("RecyclerView need a fixed width for AutoStaggeredGridLayoutManager"); } totalSpace = View.MeasureSpec.getSize(widthSpec) - getPaddingRight() - getPaddingLeft(); } else { if (View.MeasureSpec.EXACTLY != View.MeasureSpec.getMode(heightSpec)) { throw new IllegalStateException("RecyclerView need a fixed height for AutoStaggeredGridLayoutManager"); } totalSpace = View.MeasureSpec.getSize(heightSpec) - getPaddingTop() - getPaddingBottom(); } int spanCount; if (mStrategy == STRATEGY_SUITABLE_SIZE) { spanCount = getSpanCountForSuitableSize(totalSpace, mColumnSize); } else { spanCount = getSpanCountForMinSize(totalSpace, mColumnSize); } setSpanCount(spanCount); mColumnSizeChanged = false; if (null != mListeners) { for (int i = 0, n = mListeners.size(); i < n; i++) { mListeners.get(i).onUpdateSpanCount(spanCount); } } } super.onMeasure(recycler, state, widthSpec, heightSpec); } public void addOnUpdateSpanCountListener(OnUpdateSpanCountListener listener) { if (null == mListeners) { mListeners = new ArrayList<>(); } mListeners.add(listener); } public void removeOnUpdateSpanCountListener(OnUpdateSpanCountListener listener) { if (null != mListeners) { mListeners.remove(listener); } } public interface OnUpdateSpanCountListener { void onUpdateSpanCount(int spanCount); } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/AnimationUtils.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.view.animation.Interpolator; import androidx.interpolator.view.animation.FastOutLinearInInterpolator; import androidx.interpolator.view.animation.FastOutSlowInInterpolator; import androidx.interpolator.view.animation.LinearOutSlowInInterpolator; public final class AnimationUtils { public static final Interpolator FAST_SLOW_INTERPOLATOR = new LinearOutSlowInInterpolator(); public static final Interpolator SLOW_FAST_INTERPOLATOR = new FastOutLinearInInterpolator(); public static final Interpolator SLOW_FAST_SLOW_INTERPOLATOR = new FastOutSlowInInterpolator(); private AnimationUtils() { } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/AssertError.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; public class AssertError extends Error { public AssertError(String detailMessage) { super(detailMessage); } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/AssertException.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; public class AssertException extends Exception { public AssertException(String detailMessage) { super(detailMessage); } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/AssertUtils.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; public final class AssertUtils { private AssertUtils() { } public static void assertTrue(boolean cond) { assertTrue("Condition has to be true.", cond); } public static void assertTrueEx(boolean cond) throws AssertException { assertTrueEx("Condition has to be true.", cond); } public static void assertTrue(String message, boolean cond) { if (!cond) { throw new AssertError(message); } } public static void assertTrueEx(String message, boolean cond) throws AssertException { if (!cond) { throw new AssertException(message); } } public static void assertNull(Object object) { assertNull("Should be null", object); } public static void assertNullEx(Object object) throws AssertException { assertNullEx("Should be null", object); } public static void assertNull(String message, Object object) { if (object != null) { throw new AssertError(message); } } public static void assertNullEx(String message, Object object) throws AssertException { if (object != null) { throw new AssertException(message); } } public static void assertNotNull(Object object) { assertNotNull("Should not be null", object); } public static void assertNotNullEx(Object object) throws AssertException { assertNotNullEx("Should not be null", object); } public static void assertNotNull(String message, Object object) { if (object == null) { throw new AssertError(message); } } public static void assertNotNullEx(String message, Object object) throws AssertException { if (object == null) { throw new AssertException(message); } } public static void assertEquals(int expected, int actual) { assertEquals("Should be " + expected + ", but it is " + actual, expected, actual); } public static void assertEqualsEx(int expected, int actual) throws AssertException { assertEqualsEx("Should be " + expected + ", but it is " + actual, expected, actual); } public static void assertEquals(String message, int expected, int actual) { if (expected != actual) { throw new AssertError(message); } } public static void assertEqualsEx(String message, int expected, int actual) throws AssertException { if (expected != actual) { throw new AssertException(message); } } public static void assertInstanceOf(Object obj, Class clazz) { assertInstanceOf("The object should be instance of " + clazz.getName(), obj, clazz); } public static void assertInstanceOfEx(Object obj, Class clazz) throws AssertException { assertInstanceOfEx("The object should be instance of " + clazz.getName(), obj, clazz); } public static void assertInstanceOf(String message, Object obj, Class clazz) { if (!clazz.isInstance(obj)) { throw new AssertError(message); } } public static void assertInstanceOfEx(String message, Object obj, Class clazz) throws AssertException { if (!clazz.isInstance(obj)) { throw new AssertException(message); } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/ConcurrentPool.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; public class ConcurrentPool { private final T[] mArray; private final int mMaxSize; private int mSize; @SuppressWarnings("unchecked") public ConcurrentPool(int size) { if (size <= 0) { throw new IllegalStateException("Pool size must > 0, it is " + size); } mArray = (T[]) new Object[size]; mMaxSize = size; mSize = 0; } public synchronized void push(T t) { if (t != null && mSize < mMaxSize) { mArray[mSize++] = t; } } public synchronized T pop() { if (mSize > 0) { T t = mArray[--mSize]; mArray[mSize] = null; return t; } else { return null; } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/FileUtils.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.Locale; public final class FileUtils { private static final char[] FORBIDDEN_FILENAME_CHARACTERS = { '\\', '/', ':', '*', '?', '"', '<', '>', '|', }; private FileUtils() { } public static boolean ensureFile(File file) { return file != null && (!file.exists() || file.isFile()); } public static boolean ensureDirectory(File file) { if (file != null) { if (file.exists()) { return file.isDirectory(); } else { return file.mkdirs(); } } else { return false; } } /** * Convert byte to human readable string.
* ... * * @param bytes the bytes to convert * @param si si units * @return the human readable string */ public static String humanReadableByteCount(long bytes, boolean si) { int unit = si ? 1000 : 1024; if (bytes < unit) return bytes + " B"; int exp = (int) (Math.log(bytes) / Math.log(unit)); String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp - 1) + (si ? "" : "i"); return String.format(Locale.US, "%.1f %sB", bytes / Math.pow(unit, exp), pre); } /** * Try to delete file, dir and it's children * * @param file the file to delete * The dir to deleted */ public static boolean delete(File file) { if (file == null) { return false; } boolean success = true; File[] files = file.listFiles(); if (files != null) { for (File f : files) { success &= delete(f); } } /* final File to = new File(file.getAbsolutePath() + System.currentTimeMillis()); if (file.renameTo(to)) { success &= to.delete(); } else success &= file.delete(); } */ success &= file.delete(); return success; } public static boolean deleteContent(File file) { if (file == null) { return false; } boolean success = true; File[] files = file.listFiles(); if (files != null) { for (File f : files) { success &= delete(f); } } return success; } /** * @return {@code null} for get exception */ @Nullable public static String read(File file) { if (file == null) { return null; } InputStream is = null; try { is = new FileInputStream(file); return IOUtils.readString(is, "utf-8"); } catch (IOException e) { Log.e("FileUtils", "Error reading file", e); return null; } finally { IOUtils.closeQuietly(is); } } public static boolean write(File file, String str) { if (file == null) { return false; } if (str == null) { return true; } OutputStream os = null; try { os = new FileOutputStream(file); os.write(str.getBytes(StandardCharsets.UTF_8)); return true; } catch (IOException e) { Log.e("FileUtils", "Error writing file", e); return false; } finally { IOUtils.closeQuietly(os); } } public static String sanitizeFilename(@NonNull String filename) { // Remove forbidden_filename_characters filename = StringUtils.remove(filename, FORBIDDEN_FILENAME_CHARACTERS); // Ensure utf-8 byte count <= 255 int byteCount = 0; int length = 0; for (int len = filename.length(); length < len; length++) { char ch = filename.charAt(length); if (ch <= 0x7F) { byteCount++; } else if (ch <= 0x7FF) { byteCount += 2; } else if (Character.isHighSurrogate(ch)) { byteCount += 4; ++length; } else { byteCount += 3; } // Meet max byte count if (byteCount > 255) { filename = filename.substring(0, length); break; } } // Trim return StringUtils.trim(filename); } /** * Get extension from filename * * @param filename the complete filename * @return null for can't find extension, "" empty String for ending with . dot */ public static String getExtensionFromFilename(@Nullable String filename) { if (null == filename) { return null; } int index = filename.lastIndexOf('.'); if (index != -1) { return filename.substring(index + 1); } else { return null; } } /** * Get name from filename * * @param filename the complete filename * @return null for start with . dot */ public static String getNameFromFilename(@Nullable String filename) { if (null == filename) { return null; } int index = filename.lastIndexOf('.'); if (index != -1) { String name = filename.substring(0, index); return TextUtils.isEmpty(name) ? null : name; } else { return filename; } } /** * Create a temp file, you need to delete it by you self. * * @param parent The temp file's parent * @param extension The extension of temp file * @return The temp file or null */ @Nullable public static File createTempFile(@Nullable File parent, @Nullable String extension) { if (parent == null) { return null; } long now = System.currentTimeMillis(); for (int i = 0; i < 100; i++) { String filename = Long.toString(now + i); if (extension != null) { filename = filename + '.' + extension; } File tempFile = new File(parent, filename); if (!tempFile.exists()) { return tempFile; } } // Unbelievable return null; } /** * Create a temp dir, you need to delete it by you self. * * @param parent The temp file's parent * @return The temp dir or null */ @Nullable public static File createTempDir(@Nullable File parent) { if (parent == null) { return null; } long now = System.currentTimeMillis(); for (int i = 0; i < 100; i++) { String filename = Long.toString(now + i); File tempFile = new File(parent, filename); if (!tempFile.exists() && tempFile.mkdirs()) { return tempFile; } } // Unbelievable return null; } /** * Only support file now * @noinspection ResultOfMethodCallIgnored */ public static boolean rename(@NonNull File from, @NonNull File to) { if (!from.isFile() || to.exists()) { return false; } boolean ok = from.renameTo(to); if (ok && !from.exists() && to.isFile()) { return true; } // Copy content InputStream is = null; OutputStream os = null; try { is = new FileInputStream(from); os = new FileOutputStream(to); IOUtils.copy(is, os); ok = true; } catch (IOException e) { IOUtils.closeQuietly(is); IOUtils.closeQuietly(os); ok = false; } if (!ok) { to.delete(); return false; } // delete old one from.delete(); return true; } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/IOUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; public final class IOUtils { private static final int EOF = -1; private static final int DEFAULT_BUFFER_SIZE = 1024 * 4; private IOUtils() { } /** * Close the closeable stuff. Don't worry about anything. * * @param is the closeable stuff */ public static void closeQuietly(Closeable is) { try { if (is != null) { is.close(); } } catch (IOException e) { // Ignore } } /** * Copy bytes from an InputStream to an * OutputStream. * * @param input the InputStream * @param output the OutputStream * @return the number of bytes copied * @throws IOException the exception */ public static long copy(InputStream input, OutputStream output) throws IOException { byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; long count = 0; int n; while (EOF != (n = input.read(buffer))) { output.write(buffer, 0, n); count += n; } return count; } /** * Returns the ASCII characters up to but not including the next "\r\n", or * "\n". * * @throws java.io.EOFException if the stream is exhausted before the next * newline character. */ public static String readAsciiLine(final InputStream in) throws IOException { final StringBuilder result = new StringBuilder(80); while (true) { final int c = in.read(); if (c == -1) { throw new EOFException(); } else if (c == '\n') { break; } result.append((char) c); } final int length = result.length(); if (length > 0 && result.charAt(length - 1) == '\r') { result.setLength(length - 1); } return result.toString(); } public static String readString(final InputStream is, String encoding) throws IOException { InputStreamReader reader = new InputStreamReader(is, encoding); StringBuilder sb = new StringBuilder(); char[] buffer = new char[DEFAULT_BUFFER_SIZE]; int n; while (EOF != (n = reader.read(buffer))) { sb.append(buffer, 0, n); } return sb.toString(); } public static byte[] getAllByte(InputStream is) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); copy(is, baos); return baos.toByteArray(); } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/IOUtils.kt ================================================ /* * Copyright 2023 Tarsin Norbin * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.yorozuya import com.hippo.util.isAtLeastN import java.io.File import okhttp3.ResponseBody import okio.buffer import okio.sink fun ResponseBody.copyToFile(file: File) { file.outputStream().use { os -> source().use { // Prior to the adoption of OpenJDK, transferFrom will call ByteBuffer.allocate((int) count) if (isAtLeastN) { os.channel.transferFrom(it, 0, Long.MAX_VALUE) } else { os.sink().buffer().use { buffer -> buffer.writeAll(source()) } } } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/IntIdGenerator.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import java.util.concurrent.atomic.AtomicInteger; public final class IntIdGenerator { public static final int INVALID_ID = -1; private final AtomicInteger mId = new AtomicInteger(); public IntIdGenerator() { } public IntIdGenerator(int init) { setNextId(init); } @SuppressWarnings("StatementWithEmptyBody") public int nextId() { int id; while ((id = mId.getAndIncrement()) == INVALID_ID) ; return id; } public void setNextId(int id) { checkInValidId(id); mId.set(id); } private void checkInValidId(int id) { if (INVALID_ID == id) { throw new IllegalStateException("Can't set INVALID_ID"); } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/LayoutUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.content.Context; public final class LayoutUtils { private LayoutUtils() { } /** * dp conversion to pix * * @param context The context * @param dp The value you want to conversion * @return value in pix */ public static int dp2pix(Context context, float dp) { return (int) (dp * context.getResources().getDisplayMetrics().density + 0.5f); } /** * pix conversion to dp * * @param context The context * @param pix The value you want to conversion * @return value in dp */ public static float pix2dp(Context context, int pix) { return pix / context.getResources().getDisplayMetrics().density; } /** * sp conversion to pix * * @param sp The value you want to conversion * @return value in pix */ public static int sp2pix(Context context, float sp) { return (int) (sp * context.getResources().getDisplayMetrics().scaledDensity + 0.5f); } /** * pix conversion to sp * * @param pix The value you want to conversion * @return value in sp */ public static float pix2sp(Context context, float pix) { return pix / context.getResources().getDisplayMetrics().scaledDensity; } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/MathUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import java.util.Random; // Get most code from android.util.MathUtils public final class MathUtils { private static final Random sRandom = new Random(); private static final float DEG_TO_RAD = 3.1415926f / 180.0f; private static final float RAD_TO_DEG = 180.0f / 3.1415926f; private MathUtils() { } public static float abs(float v) { return v > 0 ? v : -v; } public static float log(float a) { return (float) Math.log(a); } public static float exp(float a) { return (float) Math.exp(a); } public static float pow(float a, float b) { return (float) Math.pow(a, b); } public static float max(float a, float b) { return Math.max(a, b); } public static float max(int a, int b) { return Math.max(a, b); } public static float max(float a, float b, float c) { return a > b ? Math.max(a, c) : Math.max(b, c); } public static float max(int a, int b, int c) { return a > b ? Math.max(a, c) : Math.max(b, c); } public static float max(float... arg) { int length = arg.length; if (length == 0) { throw new IllegalArgumentException("Empty argument"); } else { float n = arg[0]; float m; for (int i = 1; i < length; i++) { m = arg[i]; if (m > n) n = m; } return n; } } public static int max(int... arg) { int length = arg.length; if (length == 0) { throw new IllegalArgumentException("Empty argument"); } else { int n = arg[0]; int m; for (int i = 1; i < length; i++) { m = arg[i]; if (m > n) n = m; } return n; } } public static float min(float a, float b) { return Math.min(a, b); } public static float min(int a, int b) { return Math.min(a, b); } public static float min(float a, float b, float c) { return a < b ? Math.min(a, c) : Math.min(b, c); } public static float min(int a, int b, int c) { return a < b ? Math.min(a, c) : Math.min(b, c); } public static float min(float... args) { int length = args.length; if (length == 0) { throw new IllegalArgumentException("Empty argument"); } else { float n = args[0]; float m; for (int i = 1; i < length; i++) { m = args[i]; if (m < n) n = m; } return n; } } public static int min(int... args) { int length = args.length; if (length == 0) { throw new IllegalArgumentException("Empty argument"); } else { int n = args[0]; int m; for (int i = 1; i < length; i++) { m = args[i]; if (m < n) n = m; } return n; } } public static float dist(float x1, float y1, float x2, float y2) { final float x = (x2 - x1); final float y = (y2 - y1); return (float) Math.hypot(x, y); } public static float dist(float x1, float y1, float z1, float x2, float y2, float z2) { final float x = (x2 - x1); final float y = (y2 - y1); final float z = (z2 - z1); return (float) Math.sqrt(x * x + y * y + z * z); } public static boolean near(float x1, float y1, float x2, float y2, float slop) { return dist(x1, y1, x2, y2) < slop; } public static boolean near(float x1, float y1, float z1, float x2, float y2, float z2, float slop) { return dist(x1, y1, z1, x2, y2, z2) < slop; } public static float mag(float a, float b) { return (float) Math.hypot(a, b); } public static float mag(float a, float b, float c) { return (float) Math.sqrt(a * a + b * b + c * c); } public static float sq(float v) { return v * v; } public static float cross(float v1x, float v1y, float v2x, float v2y) { return v1x * v2y - v1y * v2x; } public static float radians(float degrees) { return degrees * DEG_TO_RAD; } public static float degrees(float radians) { return radians * RAD_TO_DEG; } public static float acos(float value) { return (float) Math.acos(value); } public static float asin(float value) { return (float) Math.asin(value); } public static float atan(float value) { return (float) Math.atan(value); } public static float atan2(float a, float b) { return (float) Math.atan2(a, b); } public static float tan(float angle) { return (float) Math.tan(angle); } public static int lerp(int start, int stop, float amount) { return start + (int) ((stop - start) * amount); } public static float lerp(float start, float stop, float amount) { return start + (stop - start) * amount; } public static float delerp(int start, int stop, int value) { if (stop == start) { return 1.0f; } else { return (float) (value - start) / (float) (stop - start); } } public static float delerp(float start, float stop, float value) { if (stop == start) { return 1.0f; } else { return (value - start) / (stop - start); } } public static float norm(float start, float stop, float value) { return (value - start) / (stop - start); } public static float map(float minStart, float minStop, float maxStart, float maxStop, float value) { return maxStart + (maxStart - maxStop) * ((value - minStart) / (minStop - minStart)); } /** * Returns the input value x clamped to the range [min, max]. */ public static int clamp(int x, int min, int max) { if (x > max) return max; return Math.max(x, min); } /** * Returns the input value x clamped to the range [min, max]. */ public static float clamp(float x, float min, float max) { if (x > max) return max; return Math.max(x, min); } /** * Returns the input value x clamped to the range [min, max]. */ public static long clamp(long x, long min, long max) { if (x > max) return max; return Math.max(x, min); } /** * Returns the next power of two. * Returns the input if it is already power of 2. * Throws IllegalArgumentException if the input is <= 0 or * the answer overflows. */ public static int nextPowerOf2(int n) { if (n <= 0 || n > (1 << 30)) throw new IllegalArgumentException("n is invalid: " + n); n -= 1; n |= n >> 16; n |= n >> 8; n |= n >> 4; n |= n >> 2; n |= n >> 1; return n + 1; } /** * Returns the previous power of two. * Returns the input if it is already power of 2. * Throws IllegalArgumentException if the input is <= 0 or * the answer overflows. */ public static int previousPowerOf2(int n) { if (n <= 0 || n > (1 << 30)) throw new IllegalArgumentException("n is invalid: " + n); n |= n >> 1; n |= n >> 2; n |= n >> 4; n |= n >> 8; n |= n >> 16; return n - (n >> 1); } // http://stackoverflow.com/questions/109023/how-to-count-the-number-of-set-bits-in-a-32-bit-integer public static int hammingWeight(int n) { n = n - ((n >>> 1) & 0x55555555); n = (n & 0x33333333) + ((n >>> 2) & 0x33333333); return (((n + (n >>> 4)) & 0x0F0F0F0F) * 0x01010101) >>> 24; } /** * divide and ceil */ public static int ceilDivide(int a, int b) { return (a + b - 1) / b; } /** * divide and ceil */ public static long ceilDivide(long a, long b) { return (a + b - 1) / b; } /** * Get coverage radius of a area * * @param w the width of the area * @param h the height of the area * @param x the x of point in area * @param y the y of point in area * @return the radius */ public static float coverageRadius(float w, float h, float x, float y) { float x2; float y2; if (x > w / 2) { x2 = 0; } else { x2 = w; } if (y > h / 2) { y2 = 0; } else { y2 = h; } return dist(x, y, x2, y2); } public static float positiveModulo(float x, float y) { float result = x % y; if (x < 0) { result += y; } return result; } public static float negativeModulo(float x, float y) { float result = x % y; if (x > 0) { result -= y; } return result; } /** * [0, howbig) */ public static int random(int howbig) { return (int) (sRandom.nextFloat() * howbig); } /** * [howsmall, howbig) */ public static int random(int howsmall, int howbig) { if (howsmall >= howbig) return howsmall; return lerp(howsmall, howbig, sRandom.nextFloat()); } /** * [0, howbig) */ public static float random(float howbig) { return sRandom.nextFloat() * howbig; } /** * [howsmall, howbig) */ public static float random(float howsmall, float howbig) { if (howsmall >= howbig) return howsmall; return lerp(howsmall, howbig, sRandom.nextFloat()); } public static void randomSeed(long seed) { sRandom.setSeed(seed); } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/NumberUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; public final class NumberUtils { private NumberUtils() { } /** * 0 for false, Non 0 for true * * @param integer the int * @return the boolean */ public static boolean int2boolean(int integer) { return integer != 0; } /** * false for 0, true for 1 * * @param bool the boolean * @return the int */ public static int boolean2int(boolean bool) { return bool ? 1 : 0; } /** * Do not throw NumberFormatException, use default value * * @param str the string to be parsed * @param defaultValue the value to return when get error * @return the value of the string */ public static int parseIntSafely(String str, int defaultValue) { try { return Integer.parseInt(str); } catch (Throwable e) { return defaultValue; } } /** * Do not throw NumberFormatException, use default value * * @param str the string to be parsed * @param defaultValue the value to return when get error * @return the value of the string */ public static long parseLongSafely(String str, long defaultValue) { try { return Long.parseLong(str); } catch (Throwable e) { return defaultValue; } } /** * Do not throw NumberFormatException, use default value * * @param str the string to be parsed * @param defaultValue the value to return when get error * @return the value of the string */ public static float parseFloatSafely(String str, float defaultValue) { try { return Float.parseFloat(str); } catch (Throwable e) { return defaultValue; } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/OSUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.os.Looper; import android.util.Log; import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; import java.util.regex.Matcher; import java.util.regex.Pattern; public final class OSUtils { private static final String PROCFS_MEMFILE = "/proc/meminfo"; private static final Pattern PROCFS_MEMFILE_FORMAT = Pattern.compile("^([a-zA-Z]*):[ \t]*([0-9]*)[ \t]kB"); private static final String MEMTOTAL_STRING = "MemTotal"; private static long sTotalMem = Long.MIN_VALUE; private OSUtils() { } public static void checkMainLoop() { if (Looper.myLooper() != Looper.getMainLooper()) { throw new IllegalStateException("It is in not main loop!"); } } /** * Get application allocated memory size */ public static long getAppAllocatedMemory() { return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); } /** * Get application max memory size */ public static long getAppMaxMemory() { return Runtime.getRuntime().maxMemory(); } /** * Get device RAM size */ public static long getTotalMemory() { if (sTotalMem == Long.MIN_VALUE) { BufferedReader reader = null; try { reader = new BufferedReader(new FileReader(PROCFS_MEMFILE), 64); String line; while ((line = reader.readLine()) != null) { Matcher matcher = PROCFS_MEMFILE_FORMAT.matcher(line); if (matcher.find() && MEMTOTAL_STRING.equals(matcher.group(1))) { long mem = NumberUtils.parseLongSafely(matcher.group(2), -1L); if (mem != -1L) { mem *= 1024; } sTotalMem = mem; break; } } } catch (IOException e) { Log.e("OSUtils", "Error getting total memory", e); } finally { IOUtils.closeQuietly(reader); } if (sTotalMem == Long.MIN_VALUE) { sTotalMem = -1L; } } return sTotalMem; } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/ObjectUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import androidx.annotation.Nullable; import java.io.PrintWriter; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; public final class ObjectUtils { private ObjectUtils() { } /** * Returns true if two possibly-null objects are equal. */ public static boolean equal(@Nullable Object a, @Nullable Object b) { return Objects.equals(a, b); } /** * Returns "null" for null or {@code o.toString()}. */ public static String toString(Object o) { return (o == null) ? "null" : o.toString(); } private static void dumpObject(Object o, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(o.getClass().getName()); writer.write('\n'); } private static void dumpBoolean(boolean z, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Boolean.toString(z)); writer.write('\n'); } private static void dumpByte(byte b, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Byte.toString(b)); writer.write('\n'); } private static void dumpChar(char c, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Character.toString(c)); writer.write('\n'); } private static void dumpShort(short s, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Short.toString(s)); writer.write('\n'); } private static void dumpInt(int i, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Integer.toString(i)); writer.write('\n'); } private static void dumpLong(long j, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Long.toString(j)); writer.write('\n'); } private static void dumpFloat(float f, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Float.toString(f)); writer.write('\n'); } private static void dumpDouble(double d, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(Double.toString(d)); writer.write('\n'); } private static void dumpArray(Object array, PrintWriter writer, String prefix, boolean skipFirstPrefix) { if (skipFirstPrefix) { dumpObject(array, writer, ""); } else { dumpObject(array, writer, prefix); } String newPrefix = prefix + " "; writer.write(newPrefix); writer.write('['); writer.write('\n'); switch (array) { case Object[] a -> { for (Object o : a) { dump(o, writer, newPrefix, false); } } case boolean[] a -> { for (boolean b : a) { dumpBoolean(b, writer, newPrefix); } } case byte[] a -> { for (byte b : a) { dumpByte(b, writer, newPrefix); } } case char[] a -> { for (char c : a) { dumpChar(c, writer, newPrefix); } } case short[] a -> { for (short value : a) { dumpShort(value, writer, newPrefix); } } case int[] a -> { for (int j : a) { dumpInt(j, writer, newPrefix); } } case long[] a -> { for (long value : a) { dumpLong(value, writer, newPrefix); } } case float[] a -> { for (float v : a) { dumpFloat(v, writer, newPrefix); } } case double[] a -> { for (double v : a) { dumpDouble(v, writer, newPrefix); } } case List list -> { for (Object o : list) { dump(o, writer, newPrefix); } } case Set set -> { for (Object o : set) { dump(o, writer, newPrefix); } } case Map map -> { for (Object key : map.keySet()) { Object value = map.get(key); writer.write(newPrefix); writer.write(key.toString()); writer.write(": "); dump(value, writer, newPrefix, true); } } default -> throw new IllegalStateException(array + " is not array"); } writer.write(newPrefix); writer.write(']'); writer.write('\n'); } public static void dump(Object o, PrintWriter writer) { dump(o, writer, "", false); } public static void dump(Object o, PrintWriter writer, String prefix) { dump(o, writer, prefix, false); } public static void dump(Object o, PrintWriter writer, String prefix, boolean skipFirstPrefix) { if (o == null) { if (!skipFirstPrefix) { writer.write(prefix); } writer.write("null\n"); } else if (o.getClass().isArray() || o instanceof List || o instanceof Set || o instanceof Map) { dumpArray(o, writer, prefix, skipFirstPrefix); } else if (o.getClass().isPrimitive() || o instanceof Boolean || o instanceof Byte || o instanceof Character || o instanceof Short || o instanceof Integer || o instanceof Long || o instanceof Float || o instanceof Double || o instanceof String) { if (!skipFirstPrefix) { writer.write(prefix); } writer.write(o.toString()); writer.write('\n'); } else { if (skipFirstPrefix) { dumpObject(o, writer, ""); } else { dumpObject(o, writer, prefix); } String newPrefix = prefix + " "; for (Field field : o.getClass().getDeclaredFields()) { // Skip static filed if (Modifier.isStatic(field.getModifiers())) { continue; } field.setAccessible(true); String name = field.getName(); try { Object value = field.get(o); writer.write(newPrefix); writer.write(name); writer.write(": "); dump(value, writer, newPrefix, true); } catch (IllegalAccessException e) { // Ignore } } } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/Pool.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; public class Pool { private final T[] mArray; private final int mMaxSize; private int mSize; @SuppressWarnings("unchecked") public Pool(int size) { if (size <= 0) { throw new IllegalStateException("Pool size must > 0, it is " + size); } mArray = (T[]) new Object[size]; mMaxSize = size; mSize = 0; } public void push(T t) { if (t != null && mSize < mMaxSize) { mArray[mSize++] = t; } } public T pop() { if (mSize > 0) { T t = mArray[--mSize]; mArray[mSize] = null; return t; } else { return null; } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/ResourcesUtils.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.content.Context; import android.content.res.Resources; import android.util.TypedValue; import androidx.annotation.AttrRes; public final class ResourcesUtils { private static final Object mAccessLock = new Object(); private static TypedValue mTmpValue = new TypedValue(); private ResourcesUtils() { } public static float getFloat(Resources resources, int resId) { TypedValue outValue = new TypedValue(); resources.getValue(resId, outValue, true); return outValue.getFloat(); } private static void getAttrValue(Context context, int attrId, TypedValue value) { context.getTheme().resolveAttribute(attrId, value, true); } public static int getAttrColor(Context context, @AttrRes int attrId) { TypedValue value; synchronized (mAccessLock) { value = mTmpValue; if (value == null) { value = new TypedValue(); } getAttrValue(context, attrId, value); if (value.type >= TypedValue.TYPE_FIRST_INT && value.type <= TypedValue.TYPE_LAST_INT) { mTmpValue = value; return value.data; } else { throw new Resources.NotFoundException( "Attribute ID #0x" + Integer.toHexString(attrId) + " type #0x" + Integer.toHexString(value.type) + " is not valid"); } } } public static boolean getAttrBoolean(Context context, @AttrRes int attrId) { synchronized (mAccessLock) { TypedValue value = mTmpValue; if (value == null) { mTmpValue = value = new TypedValue(); } getAttrValue(context, attrId, value); if (value.type >= TypedValue.TYPE_FIRST_INT && value.type <= TypedValue.TYPE_LAST_INT) { return value.data != 0; } throw new Resources.NotFoundException( "Attribute ID #0x" + Integer.toHexString(attrId) + " type #0x" + Integer.toHexString(value.type) + " is not valid"); } } public static int getAttrDimensionPixelOffset(Context context, @AttrRes int attrId) { synchronized (mAccessLock) { TypedValue value = mTmpValue; if (value == null) { mTmpValue = value = new TypedValue(); } getAttrValue(context, attrId, value); if (value.type == TypedValue.TYPE_DIMENSION) { return TypedValue.complexToDimensionPixelOffset( value.data, context.getResources().getDisplayMetrics()); } throw new Resources.NotFoundException( "Attribute ID #0x" + Integer.toHexString(attrId) + " type #0x" + Integer.toHexString(value.type) + " is not valid"); } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/SimpleAnimatorListener.java ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.animation.Animator; import androidx.annotation.NonNull; public abstract class SimpleAnimatorListener implements Animator.AnimatorListener { @Override public void onAnimationStart(@NonNull Animator animation) { } @Override public void onAnimationEnd(@NonNull Animator animation) { } @Override public void onAnimationCancel(@NonNull Animator animation) { } @Override public void onAnimationRepeat(@NonNull Animator animation) { } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/SimpleHandler.java ================================================ /* * Copyright (C) 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.os.Handler; import android.os.Looper; public final class SimpleHandler extends Handler { private static Handler sInstance; private SimpleHandler(Looper mainLooper) { super(mainLooper); } public static Handler getInstance() { if (sInstance == null) { sInstance = new Handler(Looper.getMainLooper()); } return sInstance; } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/StringUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.text.TextUtils; import org.jsoup.parser.Parser; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public final class StringUtils { public static final String[] EMPTY_STRING_ARRAY = new String[0]; public static final char[] WHITE_SPACE_ARRAY = { '\t', // TAB ' ', // SPACE '\u00A0', // NO-BREAK SPACE '\u3000', // IDEOGRAPHIC SPACE }; private StringUtils() { } /** * Unescape xml. It do not work perfectly. */ public static String unescapeXml(String str) { return Parser.unescapeEntities(str, true); } /** *

Replaces all occurrences of a String within another String.

* *

A {@code null} reference passed to this method is a no-op.

* *
     * StringUtils.replace(null, *, *)        = null
     * StringUtils.replace("", *, *)          = ""
     * StringUtils.replace("any", null, *)    = "any"
     * StringUtils.replace("any", *, null)    = "any"
     * StringUtils.replace("any", "", *)      = "any"
     * StringUtils.replace("aba", "a", null)  = "aba"
     * StringUtils.replace("aba", "a", "")    = "b"
     * StringUtils.replace("aba", "a", "z")   = "zbz"
     * 
* * @param text text to search and replace in, may be null * @param searchString the String to search for, may be null * @param replacement the String to replace it with, may be null * @return the text with any replacements processed, * {@code null} if null String input * @see #replace(String text, String searchString, String replacement, int max) */ public static String replace(final String text, final String searchString, final String replacement) { return replace(text, searchString, replacement, -1); } /** *

Replaces a String with another String inside a larger String, * for the first {@code max} values of the search String.

* *

A {@code null} reference passed to this method is a no-op.

* *
     * StringUtils.replace(null, *, *, *)         = null
     * StringUtils.replace("", *, *, *)           = ""
     * StringUtils.replace("any", null, *, *)     = "any"
     * StringUtils.replace("any", *, null, *)     = "any"
     * StringUtils.replace("any", "", *, *)       = "any"
     * StringUtils.replace("any", *, *, 0)        = "any"
     * StringUtils.replace("abaa", "a", null, -1) = "abaa"
     * StringUtils.replace("abaa", "a", "", -1)   = "b"
     * StringUtils.replace("abaa", "a", "z", 0)   = "abaa"
     * StringUtils.replace("abaa", "a", "z", 1)   = "zbaa"
     * StringUtils.replace("abaa", "a", "z", 2)   = "zbza"
     * StringUtils.replace("abaa", "a", "z", -1)  = "zbzz"
     * 
* * @param text text to search and replace in, may be null * @param searchString the String to search for, may be null * @param replacement the String to replace it with, may be null * @param max maximum number of values to replace, or {@code -1} if no maximum * @return the text with any replacements processed, * {@code null} if null String input */ public static String replace(final String text, final String searchString, final String replacement, int max) { if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchString) || replacement == null || max == 0) { return text; } int start = 0; int end = text.indexOf(searchString, start); if (end < 0) { return text; } final int replLength = searchString.length(); int increase = replacement.length() - replLength; increase = Math.max(increase, 0); increase *= max < 0 ? 16 : Math.min(max, 64); final StringBuilder buf = new StringBuilder(text.length() + increase); while (end >= 0) { buf.append(text.substring(start, end)).append(replacement); start = end + replLength; if (--max == 0) { break; } end = text.indexOf(searchString, start); } buf.append(text.substring(start)); return buf.toString(); } public static boolean endsWith(String string, String[] suffixs) { for (String suffix : suffixs) { if (string.endsWith(suffix)) { return true; } } return false; } /** *

Splits the provided text into an array, separator specified. * This is an alternative to using StringTokenizer.

* *

The separator is not included in the returned String array. * Adjacent separators are treated as one separator. * For more control over the split use the StrTokenizer class.

* *

A {@code null} input String returns {@code null}.

* *
     * StringUtils.split(null, *)         = null
     * StringUtils.split("", *)           = []
     * StringUtils.split("a.b.c", '.')    = ["a", "b", "c"]
     * StringUtils.split("a..b.c", '.')   = ["a", "b", "c"]
     * StringUtils.split("a:b:c", '.')    = ["a:b:c"]
     * StringUtils.split("a b c", ' ')    = ["a", "b", "c"]
     * 
* * @param str the String to parse, may be null * @param separatorChar the character used as the delimiter * @return an array of parsed Strings, {@code null} if null String input * @since 2.0 */ // Get from org.apache.commons.lang3.StringUtils public static String[] split(final String str, final char separatorChar) { return splitWorker(str, separatorChar, false); } /** * Performs the logic for the {@code split} and * {@code splitPreserveAllTokens} methods that do not return a * maximum array length. * * @param str the String to parse, may be {@code null} * @param separatorChar the separate character * @param preserveAllTokens if {@code true}, adjacent separators are * treated as empty token separators; if {@code false}, adjacent * separators are treated as one separator. * @return an array of parsed Strings, {@code null} if null String input */ // Get from org.apache.commons.lang3.StringUtils private static String[] splitWorker(final String str, final char separatorChar, final boolean preserveAllTokens) { // Performance tuned for 2.0 (JDK1.4) if (str == null) { return null; } final int len = str.length(); if (len == 0) { return EMPTY_STRING_ARRAY; } final List list = new ArrayList<>(); int i = 0, start = 0; boolean match = false; boolean lastMatch = false; while (i < len) { if (str.charAt(i) == separatorChar) { if (match || preserveAllTokens) { list.add(str.substring(start, i)); match = false; lastMatch = true; } start = ++i; continue; } lastMatch = false; match = true; i++; } //noinspection ConstantValue if (match || preserveAllTokens && lastMatch) { list.add(str.substring(start, i)); } return list.toArray(new String[0]); } public static String avoidNull(String value) { return avoidNull(value, ""); } public static String avoidNull(String value, String defaultValue) { return value == null ? defaultValue : value; } public static boolean isAllDigit(String str) { if (TextUtils.isEmpty(str)) { return false; } else { for (int i = 0, n = str.length(); i < n; i++) { char ch = str.charAt(i); if (ch < '0' || ch > '9') { return false; } } return true; } } public static int length(String str) { return null == str ? 0 : str.length(); } /** * All null or empty, or all not */ public static boolean equals(String str1, String str2) { return (TextUtils.isEmpty(str1) && TextUtils.isEmpty(str2)) || (!TextUtils.isEmpty(str1) && !TextUtils.isEmpty(str2) && str1.equals(str2)); } public static int ordinalIndexOf(String str, char c, int n) { if (null == str || n < 0) { return -1; } int pos = -1; do { pos = str.indexOf(c, pos + 1); } while (n-- > 0 && pos != -1); return pos; } /** * Works like {@link String#trim()}, but more white space is excluded. * The white space characters is {@link #WHITE_SPACE_ARRAY}. * * @see #trim(String, char[]) */ public static String trim(String str) { return trim(str, WHITE_SPACE_ARRAY); } /** * Works like {@link String#trim()}, but custom characters is excluded. * * @see #trim(String) */ public static String trim(String str, char[] excluded) { if (null == str) { return null; } int start = 0, last = str.length() - 1; int end = last; while ((start <= end) && (Arrays.binarySearch(excluded, str.charAt(start)) >= 0)) { start++; } while ((end >= start) && (Arrays.binarySearch(excluded, str.charAt(end)) >= 0)) { end--; } if (start == 0 && end == last) { return str; } return str.substring(start, end + 1); } public static String remove(String str, char[] removed) { if (TextUtils.isEmpty(str) || null == removed || 0 == removed.length) { return str; } final char[] chars = str.toCharArray(); int pos = 0; for (int i = 0; i < chars.length; i++) { if (!Utilities.contain(removed, chars[i])) { chars[pos++] = chars[i]; } } return new String(chars, 0, pos); } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/StringUtils.kt ================================================ /* * Copyright 2023 Moedog * * This file is part of EhViewer * * EhViewer is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * EhViewer is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with EhViewer. * If not, see . */ package com.hippo.yorozuya import org.jsoup.parser.Parser fun String.cleanAsDirname(): String = this .replace(Regex("[^\\p{L}\\p{N}\\p{P}\\p{Z}]"), "") .replace(Regex("\\s+"), " ") .trim() fun String.unescapeXml(): String = Parser.unescapeEntities(this, true) inline infix fun CharSequence.trimAnd(block: CharSequence.() -> T): T = block(trim()) ================================================ FILE: app/src/main/java/com/hippo/yorozuya/Utilities.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import androidx.annotation.Nullable; public final class Utilities { /** * Whether the array contain the element * * @param array the array * @param obj the element * @return true for the array contain the element */ public static boolean contain(@Nullable Object[] array, @Nullable Object obj) { if (null == array) { return false; } for (Object o : array) { if (ObjectUtils.equal(o, obj)) { return true; } } return false; } /** * Whether the array contain the element * * @param array the array * @param ch the element * @return true for the array contain the element */ public static boolean contain(@Nullable char[] array, char ch) { if (null == array) { return false; } for (char c : array) { if (c == ch) { return true; } } return false; } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/ViewUtils.java ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya; import android.app.Activity; import android.app.Dialog; import android.graphics.Bitmap; import android.graphics.Canvas; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.ViewTreeObserver; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.io.PrintWriter; import java.util.concurrent.atomic.AtomicInteger; public final class ViewUtils { public static final int MAX_SIZE = Integer.MAX_VALUE & ~(0x3 << 30); private static final AtomicInteger sNextGeneratedId = new AtomicInteger(1); private ViewUtils() { } /** * Get view center location in window * * @param view the view to check * @param location an array of two integers in which to hold the coordinates */ public static void getCenterInWindows(View view, int[] location) { getLocationInWindow(view, location); location[0] += view.getWidth() / 2; location[1] += view.getHeight() / 2; } /** * Get view location in window * * @param view the view to check * @param location an array of two integers in which to hold the coordinates */ public static void getLocationInWindow(View view, int[] location) { getLocationInAncestor(view, location, android.R.id.content); } /** * Get view center in ths ancestor * * @param view the view to start with * @param location the container of result * @param ancestorId the ancestor id */ public static void getCenterInAncestor(View view, int[] location, int ancestorId) { getLocationInAncestor(view, location, ancestorId); location[0] += view.getWidth() / 2; location[1] += view.getHeight() / 2; } /** * Get view location in ths ancestor * * @param view the view to start with * @param location the container of result * @param ancestorId the ancestor id */ public static void getLocationInAncestor(View view, int[] location, int ancestorId) { if (location == null || location.length < 2) { throw new IllegalArgumentException( "location must be an array of two integers"); } float[] position = new float[2]; position[0] = view.getLeft(); position[1] = view.getTop(); ViewParent viewParent = view.getParent(); while (viewParent instanceof View) { view = (View) viewParent; if (view.getId() == ancestorId) { break; } position[0] -= view.getScrollX(); position[1] -= view.getScrollY(); position[0] += view.getLeft(); position[1] += view.getTop(); viewParent = view.getParent(); } location[0] = (int) (position[0] + 0.5f); location[1] = (int) (position[1] + 0.5f); } /** * Get view center in ths ancestor * * @param view the view to start with * @param location the container of result * @param ancestor the ancestor */ public static void getCenterInAncestor(View view, int[] location, View ancestor) { getLocationInAncestor(view, location, ancestor); location[0] += view.getWidth() / 2; location[1] += view.getHeight() / 2; } /** * Get view location in ths ancestor * * @param view the view to start with * @param location the container of result * @param ancestor the ancestor */ public static void getLocationInAncestor(View view, int[] location, View ancestor) { if (location == null || location.length < 2) { throw new IllegalArgumentException( "location must be an array of two integers"); } float[] position = new float[2]; position[0] = view.getLeft(); position[1] = view.getTop(); ViewParent viewParent = view.getParent(); while (viewParent instanceof View) { view = (View) viewParent; if (viewParent == ancestor) { break; } position[0] -= view.getScrollX(); position[1] -= view.getScrollY(); position[0] += view.getLeft(); position[1] += view.getTop(); viewParent = view.getParent(); } location[0] = (int) (position[0] + 0.5f); location[1] = (int) (position[1] + 0.5f); } /** * Look for a ancestor view with the given id. If this view has the given * id, return this view. * * @param view the view to start with * @param id The id to search for. * @return The view that has the given id in the hierarchy or null */ public static View getAncestor(View view, int id) { if (view.getId() == id) { return view; } ViewParent viewParent = view.getParent(); while (viewParent instanceof View) { view = (View) viewParent; if (view.getId() == id) { return view; } viewParent = view.getParent(); } return null; } /** * Look for a child view with the given id. If this view has the given * id, return this view. * * @param view the view to start with * @param id the id to search for * @return the view that has the given id in the hierarchy or null */ public static View getChild(View view, int id) { if (view.getId() == id) { return view; } if (view instanceof ViewGroup viewGroup) { for (int i = 0, n = viewGroup.getChildCount(); i < n; i++) { View child = viewGroup.getChildAt(i); View result = getChild(child, id); if (result != null) { return result; } } } return null; } /** * Returns a bitmap showing a screenshot of the view passed in. * * @param v The view to get screenshot * @return The screenshot */ public static Bitmap getBitmapFromView(View v) { int width = v.getWidth(); int height = v.getHeight(); if (width == 0 && height == 0) { width = v.getMeasuredWidth(); height = v.getMeasuredHeight(); } Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); canvas.translate(-v.getScrollX(), -v.getScrollY()); v.draw(canvas); return bitmap; } /** * Remove view from its parent * * @param view the view to remove */ public static void removeFromParent(View view) { ViewParent vp = view.getParent(); if (vp instanceof ViewGroup) ((ViewGroup) vp).removeView(view); } /** * Method that removes the support for HardwareAcceleration from a * {@link View}.
*
* Check AOSP notice:
* *
     * 'ComposeShader can only contain shaders of different types (a BitmapShader and a
     * LinearGradient for instance, but not two instances of BitmapShader)'. But, 'If your
     * application is affected by any of these missing features or limitations, you can turn
     * off hardware acceleration for just the affected portion of your application by calling
     * setLayerType(View.LAYER_TYPE_SOFTWARE, null).'
     * 
* * @param v The view */ public static void removeHardwareAccelerationSupport(View v) { if (v.getLayerType() != View.LAYER_TYPE_SOFTWARE) { v.setLayerType(View.LAYER_TYPE_SOFTWARE, null); } } public static void addHardwareAccelerationSupport(View v) { if (v.getLayerType() != View.LAYER_TYPE_HARDWARE) { v.setLayerType(View.LAYER_TYPE_HARDWARE, null); } } public static void measureView(View v, int width, int height) { int widthMeasureSpec; int heightMeasureSpec; if (width == ViewGroup.LayoutParams.WRAP_CONTENT) widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); else widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(Math.max(width, 0), View.MeasureSpec.EXACTLY); if (height == ViewGroup.LayoutParams.WRAP_CONTENT) heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); else heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(Math.max(height, 0), View.MeasureSpec.EXACTLY); v.measure(widthMeasureSpec, heightMeasureSpec); } /** * Determine if the supplied view is under the given point in the * parent view's coordinate system. * * @param view Child view of the parent to hit test * @param x X position to test in the parent's coordinate system * @param y Y position to test in the parent's coordinate system * @param slop the slop out of the view, or negative for inside * @return true if the supplied view is under the given point, false otherwise */ public static boolean isViewUnder(@Nullable View view, int x, int y, int slop) { if (view == null) { return false; } else { float translationX = view.getTranslationX(); float translationY = view.getTranslationY(); return x >= view.getLeft() + translationX - slop && x < view.getRight() + translationX + slop && y >= view.getTop() + translationY - slop && y < view.getBottom() + translationY + slop; } } /** * Utility to return a default size. Uses the supplied size if the * MeasureSpec imposed no constraints. Will get suitable if allowed * by the MeasureSpec. * * @param size Default size for this view * @param measureSpec Constraints imposed by the parent * @return The size this view should be. */ public static int getSuitableSize(int size, int measureSpec) { int result = size; int specMode = View.MeasureSpec.getMode(measureSpec); int specSize = View.MeasureSpec.getSize(measureSpec); switch (specMode) { case View.MeasureSpec.UNSPECIFIED: break; case View.MeasureSpec.EXACTLY: result = specSize; break; case View.MeasureSpec.AT_MOST: return size == 0 ? specSize : Math.min(size, specSize); } return result; } /** * removeOnGlobalLayoutListener * * @param viewTreeObserver the ViewTreeObserver * @param l the OnGlobalLayoutListener */ public static void removeOnGlobalLayoutListener(ViewTreeObserver viewTreeObserver, ViewTreeObserver.OnGlobalLayoutListener l) { viewTreeObserver.removeOnGlobalLayoutListener(l); } /** * Get index in parent * * @param view The view * @return The index */ public static int getIndexInParent(View view) { ViewParent parent = view.getParent(); if (parent instanceof ViewGroup viewParent) { int count = viewParent.getChildCount(); for (int i = 0; i < count; i++) { View v = viewParent.getChildAt(i); if (v == view) { return i; } } } return -1; } /** * Transform point from parent to child * * @param point the point * @param parent the parent * @param child the child */ public static void transformPointToViewLocal(float[] point, View parent, View child) { point[0] += parent.getScrollX() - child.getLeft(); point[1] += parent.getScrollY() - child.getTop(); } public static void setEnabledRecursively(View view, boolean enabled) { if (view instanceof ViewGroup viewGroup) { for (int i = 0, n = viewGroup.getChildCount(); i < n; i++) { setEnabledRecursively(viewGroup.getChildAt(i), enabled); } } view.setEnabled(enabled); } public static void dumpViewHierarchy(View view, PrintWriter writer) { dumpViewHierarchy(view, writer, ""); } private static void dumpViewHierarchy(View view, PrintWriter writer, String prefix) { writer.write(prefix); writer.write(view.getClass().getName()); writer.write('\n'); if (view instanceof ViewGroup viewGroup) { String newPrefix = prefix + " "; for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) { View child = viewGroup.getChildAt(i); dumpViewHierarchy(child, writer, newPrefix); } } writer.flush(); } /** * Generate a value suitable for use in {@link View#setId(int)}. * This value will not collide with ID values generated at build time by aapt for R.id. * * @return a generated ID value */ public static int generateViewId() { for (; ; ) { final int result = sNextGeneratedId.get(); // aapt-generated IDs have the high byte nonzero; clamp to the range under that. int newValue = result + 1; if (newValue > 0x00FFFFFF) newValue = 1; // Roll over to 1, not 0. if (sNextGeneratedId.compareAndSet(result, newValue)) { return result; } } } /** * Offset this view translationX */ public static void translationXBy(View view, float offset) { view.setTranslationX(view.getTranslationX() + offset); } /** * Offset this view translationY */ public static void translationYBy(View view, float offset) { view.setTranslationY(view.getTranslationY() + offset); } /** * The visual right position of this view, in pixels. This is equivalent to the * {@link View#setTranslationX(float) translationX} property plus the current * {@link View#getRight() right} property. * * @return The visual right position of this view, in pixels. */ public static float getX2(View view) { return view.getRight() + view.getTranslationX(); } /** * The visual bottom position of this view, in pixels. This is equivalent to the * {@link View#setTranslationY(float) translationY} property plus the current * {@link View#getBottom()} () bottom} property. * * @return The visual bottom position of this view, in pixels. */ public static float getY2(View view) { return view.getBottom() + view.getTranslationY(); } @NonNull public static View $$(Activity activity, @IdRes int id) { View result = activity.findViewById(id); if (null == result) { throw new NullPointerException("Can't find view with id: " + id); } return result; } @NonNull public static View $$(Dialog dialog, @IdRes int id) { View result = dialog.findViewById(id); if (null == result) { throw new NullPointerException("Can't find view with id: " + id); } return result; } @NonNull public static View $$(View view, @IdRes int id) { View result = view.findViewById(id); if (null == result) { throw new NullPointerException("Can't find view with id: " + id); } return result; } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/collect/IntList.kt ================================================ /* * Copyright 2015 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya.collect import android.os.Parcelable import kotlinx.parcelize.Parcelize @Suppress("JavaDefaultMethodsNotOverriddenByDelegation") @Parcelize class IntList @JvmOverloads constructor( private val delegate: MutableList = mutableListOf(), ) : Parcelable, MutableList by delegate { override val size: Int get() = delegate.size override fun isEmpty(): Boolean = delegate.isEmpty() override fun clear() = delegate.clear() override fun add(element: Int): Boolean = delegate.add(element) override fun removeAt(index: Int): Int = delegate.removeAt(index) fun getInternalArray(): IntArray = delegate.toIntArray() } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/collect/LongList.kt ================================================ /* * Copyright 2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya.collect import android.os.Parcelable import kotlinx.parcelize.Parcelize @Suppress("JavaDefaultMethodsNotOverriddenByDelegation") @Parcelize class LongList( private val delegate: MutableList = mutableListOf(), ) : Parcelable, MutableList by delegate ================================================ FILE: app/src/main/java/com/hippo/yorozuya/thread/InfiniteThreadExecutor.java ================================================ /* * Copyright 2015-2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya.thread; import android.util.Log; import androidx.annotation.NonNull; import java.util.Queue; import java.util.concurrent.Executor; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class InfiniteThreadExecutor implements Executor { private final long mKeepAliveMillis; private final Queue mWorkQueue; private final ThreadFactory mThreadFactory; private final AtomicInteger mThreadCount = new AtomicInteger(); private final Object mLock = new Object(); private int mEmptyThreadCount; public InfiniteThreadExecutor(long keepAliveMillis, Queue workQueue, ThreadFactory threadFactory) { mKeepAliveMillis = keepAliveMillis; mWorkQueue = workQueue; mThreadFactory = threadFactory; } @Override public void execute(@NonNull Runnable command) { synchronized (mLock) { mWorkQueue.add(command); if (mEmptyThreadCount > 0) { --mEmptyThreadCount; mLock.notify(); return; } } mThreadFactory.newThread(new Task()).start(); } public int getThreadCount() { return mThreadCount.get(); } private class Task implements Runnable { @Override public void run() { mThreadCount.incrementAndGet(); boolean hasWait = false; for (; ; ) { Runnable command; synchronized (mLock) { command = mWorkQueue.poll(); if (command == null) { if (hasWait) { --mEmptyThreadCount; } break; } } try { command.run(); } catch (Exception e) { Log.e("InfiniteThreadExecutor", "Error running command", e); } synchronized (mLock) { ++mEmptyThreadCount; try { mLock.wait(mKeepAliveMillis); } catch (InterruptedException e) { // Ignore } } hasWait = true; } mThreadCount.decrementAndGet(); } } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/thread/PriorityThread.java ================================================ /* * Copyright 2015-2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya.thread; import android.os.Process; public class PriorityThread extends Thread { private final int mPriority; public PriorityThread(Runnable runnable, int priority) { super(runnable); mPriority = priority; } public PriorityThread(Runnable runnable, String name, int priority) { super(runnable, name); mPriority = priority; } @Override public void run() { Process.setThreadPriority(mPriority); super.run(); } } ================================================ FILE: app/src/main/java/com/hippo/yorozuya/thread/PriorityThreadFactory.java ================================================ /* * Copyright 2015-2016 Hippo Seven * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.hippo.yorozuya.thread; import androidx.annotation.NonNull; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * A thread factory that creates threads with a given thread priority. */ public class PriorityThreadFactory implements ThreadFactory { private final int mPriority; private final AtomicInteger mIdGenerator = new AtomicInteger(); private final String mName; public PriorityThreadFactory(String name, int priority) { mName = name; mPriority = priority; } @Override public Thread newThread(@NonNull Runnable r) { return new PriorityThread(r, mName + "-" + mIdGenerator.getAndIncrement(), mPriority); } } ================================================ FILE: app/src/main/java/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt ================================================ package eu.kanade.tachiyomi.network.interceptor import android.content.Context import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.core.content.ContextCompat import com.hippo.ehviewer.R import com.hippo.ehviewer.client.EhCookieStore import com.hippo.ehviewer.client.exception.CloudflareBypassException import com.hippo.ehviewer.util.isWebViewOutdated import com.hippo.util.launchIO import com.hippo.util.launchUI import java.io.IOException import java.util.concurrent.CountDownLatch import kotlinx.coroutines.DelicateCoroutinesApi import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response import org.jsoup.Jsoup class CloudflareInterceptor(val context: Context) : WebViewInterceptor(context) { private val executor = ContextCompat.getMainExecutor(context) override fun shouldIntercept(response: Response): Boolean { // Check if Cloudflare anti-bot is on return if (response.code in ERROR_CODES && response.header("Server") in SERVER_CHECK) { val document = Jsoup.parse( response.peekBody(Long.MAX_VALUE).string(), response.request.url.toString(), ) // solve with webview only on captcha, not on geo block document.getElementById("challenge-error-title") != null || document.getElementById("challenge-error-text") != null } else { false } } @OptIn(DelicateCoroutinesApi::class) override fun intercept( chain: Interceptor.Chain, request: Request, response: Response, ): Response { // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // we don't crash the entire app return runCatching { response.close() launchIO { EhCookieStore.deleteCookie(request.url, EhCookieStore.KEY_CLOUDFLARE) } resolveWithWebView(request) chain.proceed(request) }.getOrElse { throw IOException(it) } } @OptIn(DelicateCoroutinesApi::class) private fun resolveWithWebView(originalRequest: Request) { // We need to lock this thread until the WebView finds the challenge solution url, because // OkHttp doesn't support asynchronous interceptors. val latch = CountDownLatch(1) var webview: WebView? = null var challengeFound = false var cloudflareBypassed = false val origRequestUrl = originalRequest.url.toString() val headers = parseHeaders(originalRequest.headers) EhCookieStore.loadForWebView(origRequestUrl) { it.name != EhCookieStore.KEY_CLOUDFLARE } executor.execute { webview = createWebView() webview.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String) { cloudflareBypassed = EhCookieStore.saveFromWebView(origRequestUrl) { it.name == EhCookieStore.KEY_CLOUDFLARE } if (cloudflareBypassed) { latch.countDown() } if (url == origRequestUrl && !challengeFound) { // The first request didn't return the challenge, abort. latch.countDown() } } override fun onReceivedHttpError( view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?, ) { if (request?.isForMainFrame == true) { if (errorResponse?.statusCode in ERROR_CODES) { // Found the Cloudflare challenge page. challengeFound = true } else { // Unlock thread, the challenge wasn't found. latch.countDown() } } } } webview.loadUrl(origRequestUrl, headers) } latch.awaitFor30Seconds() executor.execute { webview?.run { stopLoading() destroy() } } // Throw exception if we failed to bypass Cloudflare if (!cloudflareBypassed) { // Prompt user to update WebView if it seems too outdated if (isWebViewOutdated) { launchUI { Toast.makeText(context, R.string.information_webview_outdated, Toast.LENGTH_LONG).show() } } throw CloudflareBypassException() } } } private val ERROR_CODES = listOf(403, 503) private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") ================================================ FILE: app/src/main/java/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt ================================================ package eu.kanade.tachiyomi.network.interceptor import android.content.Context import android.webkit.WebSettings import android.webkit.WebView import com.hippo.ehviewer.util.setDefaultSettings import java.util.Locale import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response abstract class WebViewInterceptor(private val context: Context) : Interceptor { /** * When this is called, it initializes the WebView if it wasn't already. We use this to avoid * blocking the main thread too much. If used too often we could consider moving it to the * Application class. */ private val initWebView by lazy { try { WebSettings.getDefaultUserAgent(context) } catch (_: Exception) { // Avoid some crashes like when Chrome/WebView is being updated. } } abstract fun shouldIntercept(response: Response): Boolean abstract fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val response = chain.proceed(request) if (!shouldIntercept(response)) { return response } initWebView return intercept(chain, request, response) } fun parseHeaders(headers: Headers): Map = headers // Keeping unsafe header makes webview throw [net::ERR_INVALID_ARGUMENT] .filter { (name, value) -> isRequestHeaderSafe(name, value) } .groupBy(keySelector = { (name, _) -> name }) { (_, value) -> value } .mapValues { it.value.getOrNull(0).orEmpty() } fun CountDownLatch.awaitFor30Seconds() { await(30, TimeUnit.SECONDS) } fun createWebView(): WebView = WebView(context).apply { setDefaultSettings() } } // Based on [IsRequestHeaderSafe] in https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc private fun isRequestHeaderSafe(name: String, value: String): Boolean { val name = name.lowercase(Locale.ENGLISH) val value = value.lowercase(Locale.ENGLISH) if (name in unsafeHeaderNames || name.startsWith("proxy-")) return false return !(name == "connection" && value == "upgrade") } private val unsafeHeaderNames = arrayOf( "content-length", "host", "trailer", "te", "upgrade", "cookie2", "keep-alive", "transfer-encoding", "set-cookie", ) ================================================ FILE: app/src/main/res/anim/accelerate_quart.xml ================================================ ================================================ FILE: app/src/main/res/anim/decelerate_quart.xml ================================================ ================================================ FILE: app/src/main/res/anim/decelerate_quint.xml ================================================ ================================================ FILE: app/src/main/res/anim/scene_close_exit.xml ================================================ ================================================ FILE: app/src/main/res/anim/scene_open_enter.xml ================================================ ================================================ FILE: app/src/main/res/anim/scene_open_enter_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/anim/scene_open_exit.xml ================================================ ================================================ FILE: app/src/main/res/color/content_reactive.xml ================================================ ================================================ FILE: app/src/main/res/color/content_reactive_black.xml ================================================ ================================================ FILE: app/src/main/res/color/primary_text_material_black.xml ================================================ ================================================ FILE: app/src/main/res/drawable/big_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/big_filter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/big_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/big_sad_pandroid.xml ================================================ ================================================ FILE: app/src/main/res/drawable/category_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/check_text_view_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/default_avatar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/divider_gallery_detail.xml ================================================ ================================================ FILE: app/src/main/res/drawable/divider_gallery_detail_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_dark_mode_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_format_list_numbered_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_menu_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_reorder_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_warning_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_monochrome.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pause_108dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_arrow_108dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/image_failed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/round_side_rect.xml ================================================ ================================================ FILE: app/src/main/res/drawable/spacer_keyline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/spacer_x6.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tile_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_adb_primary_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_archive_primary_x48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_arrow_left_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_book_open_primary_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_book_open_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_check_all_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_check_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_clear_all_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_close_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_cookie_brown_x48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_delete_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_dots_vertical_secondary_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_download_box_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_download_primary_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_download_x16.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_download_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_eh_subscription_black_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_filter_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_fire_black_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_folder_move_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_go_to_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_heart_box_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_heart_broken_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_heart_outline_primary_x48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_heart_primary_x48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_heart_x16.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_heart_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_help_circle_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_history_black_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_homepage_black_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_info_outline_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_info_primary_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_last_page_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_magnify_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_pause_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_pencil_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_pin_top_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_play_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_plus_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_refresh_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_reply_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_sad_panda_primary_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_sec_primary_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_send_dark_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_settings_black_x24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_share_primary_x48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_similar_primary_x48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_slider_bubble.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_star_half_x16.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_star_outline_x16.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_star_x16.xml ================================================ ================================================ FILE: app/src/main/res/drawable/v_utorrent_primary_x48.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v25/ic_shortcut_start.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v25/ic_shortcut_stop.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v26/ic_shortcut_start.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v26/ic_shortcut_stop.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_filter.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_gallery.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_preference.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_set_security.xml ================================================