Repository: yuliskov/SmartTube Branch: master Commit: ce402b5cf741 Files: 2868 Total size: 30.3 MB Directory structure: gitextract_iykr0_hr/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1-bug_report.yml │ │ ├── 2-feature-request.yml │ │ └── config.yml │ └── workflows/ │ ├── CI.yml │ ├── cleanup.yml │ ├── stale.yml │ └── virustotal_scan.yml ├── .gitignore ├── .gitmodules ├── .reuse/ │ └── dep5 ├── LICENSE ├── README.md ├── build.gradle ├── chatkit/ │ ├── .gitignore │ ├── LICENSE │ ├── build.gradle │ ├── proguard.txt │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── stfalcon/ │ │ └── chatkit/ │ │ ├── commons/ │ │ │ ├── DebouncedOnClickListener.java │ │ │ ├── ImageLoader.java │ │ │ ├── InputTrackingRecyclerViewAdapter.java │ │ │ ├── Style.java │ │ │ ├── ViewHolder.java │ │ │ ├── models/ │ │ │ │ ├── IDialog.java │ │ │ │ ├── IMessage.java │ │ │ │ ├── IUser.java │ │ │ │ └── MessageContentType.java │ │ │ └── widgets/ │ │ │ ├── FocusFixRelativeLayout.java │ │ │ └── WrapWidthTextView.java │ │ ├── dialogs/ │ │ │ ├── DialogListStyle.java │ │ │ ├── DialogsList.java │ │ │ └── DialogsListAdapter.java │ │ ├── messages/ │ │ │ ├── MessageHolders.java │ │ │ ├── MessageInput.java │ │ │ ├── MessageInputStyle.java │ │ │ ├── MessagesList.java │ │ │ ├── MessagesListAdapter.java │ │ │ ├── MessagesListStyle.java │ │ │ └── RecyclerScrollMoreListener.java │ │ └── utils/ │ │ ├── DateFormatter.java │ │ ├── RoundedImageView.java │ │ └── ShapeImageView.java │ └── res/ │ ├── color/ │ │ └── textchange.xml │ ├── drawable/ │ │ ├── bgchange.xml │ │ ├── bubble_circle.xml │ │ ├── shape_incoming_message.xml │ │ ├── shape_incoming_message_focused.xml │ │ └── shape_outcoming_message.xml │ ├── layout/ │ │ ├── item_date_header.xml │ │ ├── item_dialog.xml │ │ ├── item_incoming_image_message.xml │ │ ├── item_incoming_text_message.xml │ │ ├── item_outcoming_image_message.xml │ │ ├── item_outcoming_text_message.xml │ │ └── view_message_input.xml │ ├── values/ │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── fonts.xml │ │ ├── ids.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── values-v21/ │ └── fonts.xml ├── common/ │ ├── .gitignore │ ├── build.gradle │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── liskovsoft/ │ │ │ └── smartyoutubetv2/ │ │ │ └── common/ │ │ │ ├── app/ │ │ │ │ ├── models/ │ │ │ │ │ ├── data/ │ │ │ │ │ │ ├── BrowseSection.java │ │ │ │ │ │ ├── Playlist.java │ │ │ │ │ │ ├── SettingsGroup.java │ │ │ │ │ │ ├── SettingsItem.java │ │ │ │ │ │ ├── SimpleMediaItem.java │ │ │ │ │ │ ├── Video.java │ │ │ │ │ │ └── VideoGroup.java │ │ │ │ │ ├── errors/ │ │ │ │ │ │ ├── CategoryEmptyError.java │ │ │ │ │ │ ├── ErrorFragmentData.java │ │ │ │ │ │ ├── PasswordError.java │ │ │ │ │ │ └── SignInError.java │ │ │ │ │ ├── playback/ │ │ │ │ │ │ ├── BasePlayerController.java │ │ │ │ │ │ ├── controllers/ │ │ │ │ │ │ │ ├── AutoFrameRateController.java │ │ │ │ │ │ │ ├── ChatController.java │ │ │ │ │ │ │ ├── CommentsController.java │ │ │ │ │ │ │ ├── HQDialogController.java │ │ │ │ │ │ │ ├── PlayerUIController.java │ │ │ │ │ │ │ ├── RemoteController.java │ │ │ │ │ │ │ ├── SponsorBlockController.java │ │ │ │ │ │ │ ├── SuggestionsController.java │ │ │ │ │ │ │ ├── VideoLoaderController.java │ │ │ │ │ │ │ └── VideoStateController.java │ │ │ │ │ │ ├── listener/ │ │ │ │ │ │ │ ├── PlayerEngineEventListener.java │ │ │ │ │ │ │ ├── PlayerEventListener.java │ │ │ │ │ │ │ ├── PlayerUiEventListener.java │ │ │ │ │ │ │ └── ViewEventListener.java │ │ │ │ │ │ ├── manager/ │ │ │ │ │ │ │ ├── PlayerConstants.java │ │ │ │ │ │ │ ├── PlayerEngine.java │ │ │ │ │ │ │ ├── PlayerManager.java │ │ │ │ │ │ │ └── PlayerUI.java │ │ │ │ │ │ ├── service/ │ │ │ │ │ │ │ └── VideoStateService.java │ │ │ │ │ │ └── ui/ │ │ │ │ │ │ ├── AbstractCommentsReceiver.java │ │ │ │ │ │ ├── ChatReceiver.java │ │ │ │ │ │ ├── ChatReceiverImpl.java │ │ │ │ │ │ ├── CommentsReceiver.java │ │ │ │ │ │ ├── OptionCallback.java │ │ │ │ │ │ ├── OptionCategory.java │ │ │ │ │ │ ├── OptionItem.java │ │ │ │ │ │ ├── SeekBarSegment.java │ │ │ │ │ │ └── UiOptionItem.java │ │ │ │ │ └── search/ │ │ │ │ │ ├── MediaServiceSearchTagProvider.java │ │ │ │ │ ├── PrefsSearchTagsProvider.java │ │ │ │ │ ├── SearchTagsProvider.java │ │ │ │ │ └── vineyard/ │ │ │ │ │ ├── Option.java │ │ │ │ │ ├── Tag.java │ │ │ │ │ └── User.java │ │ │ │ ├── presenters/ │ │ │ │ │ ├── AddDevicePresenter.java │ │ │ │ │ ├── AppDialogPresenter.java │ │ │ │ │ ├── BrowsePresenter.java │ │ │ │ │ ├── ChannelPresenter.java │ │ │ │ │ ├── ChannelUploadsPresenter.java │ │ │ │ │ ├── DetailsPresenter.java │ │ │ │ │ ├── GoogleSignInPresenter.java │ │ │ │ │ ├── PlaybackPresenter.java │ │ │ │ │ ├── SearchPresenter.java │ │ │ │ │ ├── SignInPresenter.java │ │ │ │ │ ├── SplashPresenter.java │ │ │ │ │ ├── WebBrowserPresenter.java │ │ │ │ │ ├── YTSignInPresenter.java │ │ │ │ │ ├── base/ │ │ │ │ │ │ └── BasePresenter.java │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ ├── ATVBridgePresenter.java │ │ │ │ │ │ ├── AccountSelectionPresenter.java │ │ │ │ │ │ ├── AmazonBridgePresenter.java │ │ │ │ │ │ ├── AppUpdatePresenter.java │ │ │ │ │ │ ├── BootDialogPresenter.java │ │ │ │ │ │ ├── BridgePresenter.java │ │ │ │ │ │ ├── QuickRestorePresenter.java │ │ │ │ │ │ ├── StableRestorePresenter.java │ │ │ │ │ │ ├── VideoActionPresenter.java │ │ │ │ │ │ └── menu/ │ │ │ │ │ │ ├── BaseMenuPresenter.java │ │ │ │ │ │ ├── ChannelUploadsMenuPresenter.java │ │ │ │ │ │ ├── SectionMenuPresenter.java │ │ │ │ │ │ ├── VideoMenuPresenter.java │ │ │ │ │ │ └── providers/ │ │ │ │ │ │ ├── ContextMenuManager.java │ │ │ │ │ │ ├── ContextMenuProvider.java │ │ │ │ │ │ └── channelgroup/ │ │ │ │ │ │ ├── ChannelGroupMenuProvider.java │ │ │ │ │ │ ├── ChannelGroupServiceWrapper.java │ │ │ │ │ │ ├── RemoveGroupMenuProvider.java │ │ │ │ │ │ └── RenameGroupMenuProvider.java │ │ │ │ │ ├── interfaces/ │ │ │ │ │ │ ├── Presenter.java │ │ │ │ │ │ ├── SectionPresenter.java │ │ │ │ │ │ └── VideoGroupPresenter.java │ │ │ │ │ ├── service/ │ │ │ │ │ │ └── SidebarService.java │ │ │ │ │ └── settings/ │ │ │ │ │ ├── AboutSettingsPresenter.java │ │ │ │ │ ├── AboutSimpleSettingsPresenter.java │ │ │ │ │ ├── AccountSettingsPresenter.java │ │ │ │ │ ├── AutoFrameRateSettingsPresenter.java │ │ │ │ │ ├── BackupSettingsPresenter.java │ │ │ │ │ ├── DeArrowSettingsPresenter.java │ │ │ │ │ ├── GeneralSettingsPresenter.java │ │ │ │ │ ├── LanguageSettingsPresenter.java │ │ │ │ │ ├── MainUISettingsPresenter.java │ │ │ │ │ ├── PlayerSettingsPresenter.java │ │ │ │ │ ├── RemoteControlSettingsPresenter.java │ │ │ │ │ ├── SearchSettingsPresenter.java │ │ │ │ │ ├── SponsorBlockSettingsPresenter.java │ │ │ │ │ ├── SubtitleSettingsPresenter.java │ │ │ │ │ └── UIScaleSettingsPresenter.java │ │ │ │ └── views/ │ │ │ │ ├── AddDeviceView.java │ │ │ │ ├── AppDialogView.java │ │ │ │ ├── AppUpdateView.java │ │ │ │ ├── BrowseView.java │ │ │ │ ├── ChannelUploadsView.java │ │ │ │ ├── ChannelView.java │ │ │ │ ├── DetailsView.java │ │ │ │ ├── PlaybackView.java │ │ │ │ ├── SearchView.java │ │ │ │ ├── SignInView.java │ │ │ │ ├── SplashView.java │ │ │ │ ├── ViewManager.java │ │ │ │ └── WebBrowserView.java │ │ │ ├── autoframerate/ │ │ │ │ ├── AutoFrameRateHelper.java │ │ │ │ ├── ModeSyncManager.java │ │ │ │ └── internal/ │ │ │ │ ├── DisplayHolder.java │ │ │ │ ├── DisplaySyncHelper.java │ │ │ │ ├── UhdHelper.java │ │ │ │ └── UhdHelperListener.java │ │ │ ├── exoplayer/ │ │ │ │ ├── ExoMediaSourceFactory.java │ │ │ │ ├── LiveDashManifestParser.java │ │ │ │ ├── controller/ │ │ │ │ │ ├── ExoPlayerController.java │ │ │ │ │ └── PlayerView.java │ │ │ │ ├── errors/ │ │ │ │ │ ├── DashDefaultLoadErrorHandlingPolicy.java │ │ │ │ │ ├── SabrDefaultLoadErrorHandlingPolicy.java │ │ │ │ │ └── TrackErrorFixer.java │ │ │ │ ├── other/ │ │ │ │ │ ├── DebugInfoManager.java │ │ │ │ │ ├── ExoPlayerInitializer.java │ │ │ │ │ ├── SubtitleManager.java │ │ │ │ │ ├── VideoZoomManager.java │ │ │ │ │ └── VolumeBooster.java │ │ │ │ ├── selector/ │ │ │ │ │ ├── ExoFormatItem.java │ │ │ │ │ ├── FormatItem.java │ │ │ │ │ ├── TrackInfoFormatter2.java │ │ │ │ │ ├── TrackSelectorManager.java │ │ │ │ │ ├── TrackSelectorUtil.java │ │ │ │ │ └── track/ │ │ │ │ │ ├── AudioTrack.java │ │ │ │ │ ├── MediaTrack.java │ │ │ │ │ ├── SubtitleTrack.java │ │ │ │ │ └── VideoTrack.java │ │ │ │ └── versions/ │ │ │ │ ├── ExoUtils.java │ │ │ │ ├── renderer/ │ │ │ │ │ ├── CustomOverridesRenderersFactory.java │ │ │ │ │ ├── CustomRenderersFactoryBase.java │ │ │ │ │ ├── DebugInfoMediaCodecVideoRenderer.java │ │ │ │ │ ├── DelayMediaCodecAudioRenderer.java │ │ │ │ │ └── TweaksMediaCodecVideoRenderer.java │ │ │ │ └── selector/ │ │ │ │ ├── BlacklistMediaCodecSelector.java │ │ │ │ ├── RestoreTrackSelector.java │ │ │ │ └── backport/ │ │ │ │ └── Definition.java │ │ │ ├── misc/ │ │ │ │ ├── AppDataSourceManager.java │ │ │ │ ├── BackgroundPlaybackService.java │ │ │ │ ├── BackupAndRestoreHelper.java │ │ │ │ ├── BackupAndRestoreManager.java │ │ │ │ ├── BackupReceiverActivity.java │ │ │ │ ├── BrowseProcessor.java │ │ │ │ ├── BrowseProcessorManager.java │ │ │ │ ├── CrashRestorer.java │ │ │ │ ├── DeArrowProcessor.java │ │ │ │ ├── GDriveBackupManager.java │ │ │ │ ├── GDriveBackupManagerOld.java │ │ │ │ ├── GDriveBackupWorker.java │ │ │ │ ├── GlobalKeyTranslator.java │ │ │ │ ├── KeyTranslator.java │ │ │ │ ├── LocalDriveBackupWorker.java │ │ │ │ ├── MediaServiceManager.java │ │ │ │ ├── MotherActivity.java │ │ │ │ ├── PlayerKeyTranslator.java │ │ │ │ ├── RemoteControlReceiver.java │ │ │ │ ├── RemoteControlService.java │ │ │ │ ├── RemoteControlWorker.java │ │ │ │ ├── ScreensaverManager.java │ │ │ │ ├── SharedPreferencesHelper.java │ │ │ │ ├── StreamReminderService.java │ │ │ │ ├── TickleManager.java │ │ │ │ ├── UnlocalizedTitleProcessor.java │ │ │ │ ├── ZipHelper.java │ │ │ │ └── ZipHelper2.java │ │ │ ├── prefs/ │ │ │ │ ├── AccountsData.java │ │ │ │ ├── AppPrefs.java │ │ │ │ ├── BlockedChannelData.java │ │ │ │ ├── DeArrowData.java │ │ │ │ ├── GeneralData.java │ │ │ │ ├── HiddenPrefs.java │ │ │ │ ├── MainUIData.java │ │ │ │ ├── PlayerData.java │ │ │ │ ├── PlayerTweaksData.java │ │ │ │ ├── RemoteControlData.java │ │ │ │ ├── SearchData.java │ │ │ │ ├── SponsorBlockData.java │ │ │ │ └── common/ │ │ │ │ ├── DataChangeBase.java │ │ │ │ └── DataSaverBase.java │ │ │ ├── proxy/ │ │ │ │ ├── PasswdInetSocketAddress.java │ │ │ │ ├── PasswdURI.java │ │ │ │ ├── Proxy.java │ │ │ │ ├── ProxyManager.java │ │ │ │ └── WebProxyDialog.java │ │ │ └── utils/ │ │ │ ├── AppDialogUtil.java │ │ │ ├── ClickbaitRemover.java │ │ │ ├── CopyOnWriteHashList.java │ │ │ ├── HttpURLConnectionUtils.java │ │ │ ├── IntentExtractor.java │ │ │ ├── LoadingManager.java │ │ │ ├── SimpleEditDialog.java │ │ │ ├── TvQuickActions.java │ │ │ ├── UserAgentManager.java │ │ │ └── Utils.java │ │ └── res/ │ │ ├── layout/ │ │ │ ├── debug_view.xml │ │ │ ├── dim_container.xml │ │ │ ├── simple_edit_dialog.xml │ │ │ └── web_proxy_dialog.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── countries.xml │ │ │ ├── dimens.xml │ │ │ ├── donations.xml │ │ │ ├── feedback.xml │ │ │ ├── ids.xml │ │ │ ├── languages.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ ├── unlocalized-strings.xml │ │ │ └── update_urls.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-az/ │ │ │ └── strings.xml │ │ ├── values-bg/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ ├── values-cs/ │ │ │ └── strings.xml │ │ ├── values-da/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-el/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-et/ │ │ │ └── strings.xml │ │ ├── values-fa-rIR/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-gl-rES/ │ │ │ └── strings.xml │ │ ├── values-he/ │ │ │ └── strings.xml │ │ ├── values-hi/ │ │ │ └── strings.xml │ │ ├── values-hr/ │ │ │ └── strings.xml │ │ ├── values-hu/ │ │ │ └── strings.xml │ │ ├── values-hy/ │ │ │ └── strings.xml │ │ ├── values-in/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-iw/ │ │ │ └── strings.xml │ │ ├── values-ja/ │ │ │ └── strings.xml │ │ ├── values-ko/ │ │ │ └── strings.xml │ │ ├── values-lt/ │ │ │ └── strings.xml │ │ ├── values-lv/ │ │ │ └── strings.xml │ │ ├── values-mo/ │ │ │ └── strings.xml │ │ ├── values-nb/ │ │ │ └── strings.xml │ │ ├── values-nl/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR/ │ │ │ └── strings.xml │ │ ├── values-pt-rPT/ │ │ │ └── strings.xml │ │ ├── values-ro/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sk/ │ │ │ └── strings.xml │ │ ├── values-sl/ │ │ │ └── strings.xml │ │ ├── values-sq/ │ │ │ └── strings.xml │ │ ├── values-sr/ │ │ │ └── strings.xml │ │ ├── values-sv/ │ │ │ └── strings.xml │ │ ├── values-te/ │ │ │ └── strings.xml │ │ ├── values-th/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-zh/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ └── strings.xml │ │ ├── volume-fa/ │ │ │ └── strings.xml │ │ └── xml/ │ │ └── app_prefs.xml │ ├── stbeta/ │ │ └── res/ │ │ └── values/ │ │ └── update_urls.xml │ └── ststable/ │ └── res/ │ └── values/ │ └── update_urls.xml ├── crowdin.yml ├── doubletapplayerview/ │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── build.gradle │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── github/ │ │ └── vkay94/ │ │ └── dtpv/ │ │ ├── DoubleTapPlayerAdapter.kt │ │ ├── DoubleTapPlayerView.kt │ │ ├── DoubleTapPlayerViewImpl.kt │ │ ├── PlayerDoubleTapListener.java │ │ ├── SeekListener.kt │ │ └── youtube/ │ │ ├── YouTubeOverlay.kt │ │ └── views/ │ │ ├── CircleClipTapView.kt │ │ └── YouTubeSecondsView.kt │ └── res/ │ ├── drawable/ │ │ └── ic_play_triangle.xml │ ├── layout/ │ │ ├── yt_overlay.xml │ │ └── yt_seconds_view.xml │ ├── values/ │ │ ├── dtpv.xml │ │ ├── plurals.xml │ │ ├── public.xml │ │ └── yt_overlay.xml │ ├── values-af/ │ │ └── plurals.xml │ ├── values-am/ │ │ └── plurals.xml │ ├── values-ar/ │ │ └── plurals.xml │ ├── values-az/ │ │ └── plurals.xml │ ├── values-b+sr+Latn/ │ │ └── plurals.xml │ ├── values-be/ │ │ └── plurals.xml │ ├── values-bg/ │ │ └── plurals.xml │ ├── values-bn/ │ │ └── plurals.xml │ ├── values-bs/ │ │ └── plurals.xml │ ├── values-ca/ │ │ └── plurals.xml │ ├── values-cs/ │ │ └── plurals.xml │ ├── values-da/ │ │ └── plurals.xml │ ├── values-de/ │ │ └── plurals.xml │ ├── values-el/ │ │ └── plurals.xml │ ├── values-en-rGB/ │ │ └── plurals.xml │ ├── values-en-rIN/ │ │ └── plurals.xml │ ├── values-es/ │ │ └── plurals.xml │ ├── values-es-rUS/ │ │ └── plurals.xml │ ├── values-et/ │ │ └── plurals.xml │ ├── values-eu/ │ │ └── plurals.xml │ ├── values-fa/ │ │ └── plurals.xml │ ├── values-fi/ │ │ └── plurals.xml │ ├── values-fr/ │ │ └── plurals.xml │ ├── values-fr-rCA/ │ │ └── plurals.xml │ ├── values-gl/ │ │ └── plurals.xml │ ├── values-gu/ │ │ └── plurals.xml │ ├── values-hi/ │ │ └── plurals.xml │ ├── values-hr/ │ │ └── plurals.xml │ ├── values-hu/ │ │ └── plurals.xml │ ├── values-hy/ │ │ └── plurals.xml │ ├── values-in/ │ │ └── plurals.xml │ ├── values-is/ │ │ └── plurals.xml │ ├── values-it/ │ │ └── plurals.xml │ ├── values-iw/ │ │ └── plurals.xml │ ├── values-ja/ │ │ └── plurals.xml │ ├── values-ka/ │ │ └── plurals.xml │ ├── values-kk/ │ │ └── plurals.xml │ ├── values-km/ │ │ └── plurals.xml │ ├── values-kn/ │ │ └── plurals.xml │ ├── values-ko/ │ │ └── plurals.xml │ ├── values-ky/ │ │ └── plurals.xml │ ├── values-lo/ │ │ └── plurals.xml │ ├── values-lt/ │ │ └── plurals.xml │ ├── values-lv/ │ │ └── plurals.xml │ ├── values-mk/ │ │ └── plurals.xml │ ├── values-ml/ │ │ └── plurals.xml │ ├── values-mn/ │ │ └── plurals.xml │ ├── values-mr/ │ │ └── plurals.xml │ ├── values-ms/ │ │ └── plurals.xml │ ├── values-my/ │ │ └── plurals.xml │ ├── values-nb/ │ │ └── plurals.xml │ ├── values-ne/ │ │ └── plurals.xml │ ├── values-nl/ │ │ └── plurals.xml │ ├── values-pa/ │ │ └── plurals.xml │ ├── values-pl/ │ │ └── plurals.xml │ ├── values-pt-rBR/ │ │ └── plurals.xml │ ├── values-pt-rPT/ │ │ └── plurals.xml │ ├── values-ro/ │ │ └── plurals.xml │ ├── values-ru/ │ │ └── plurals.xml │ ├── values-si/ │ │ └── plurals.xml │ ├── values-sk/ │ │ └── plurals.xml │ ├── values-sl/ │ │ └── plurals.xml │ ├── values-sq/ │ │ └── plurals.xml │ ├── values-sr/ │ │ └── plurals.xml │ ├── values-sv/ │ │ └── plurals.xml │ ├── values-sw/ │ │ └── plurals.xml │ ├── values-ta/ │ │ └── plurals.xml │ ├── values-te/ │ │ └── plurals.xml │ ├── values-th/ │ │ └── plurals.xml │ ├── values-tl/ │ │ └── plurals.xml │ ├── values-tr/ │ │ └── plurals.xml │ ├── values-uk/ │ │ └── plurals.xml │ ├── values-ur/ │ │ └── plurals.xml │ ├── values-uz/ │ │ └── plurals.xml │ ├── values-vi/ │ │ └── plurals.xml │ ├── values-zh-rCN/ │ │ └── plurals.xml │ ├── values-zh-rHK/ │ │ └── plurals.xml │ ├── values-zh-rTW/ │ │ └── plurals.xml │ └── values-zu/ │ └── plurals.xml ├── exoplayer-amzn-2.10.6/ │ ├── .gitignore │ ├── .hgignore │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README-ORIGINAL.md │ ├── README.md │ ├── RELEASENOTES.md │ ├── build.gradle │ ├── constants.gradle │ ├── core_settings.gradle │ ├── demos/ │ │ ├── README.md │ │ ├── cast/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── castdemo/ │ │ │ │ ├── DefaultReceiverPlayerManager.java │ │ │ │ ├── DemoUtil.java │ │ │ │ ├── MainActivity.java │ │ │ │ └── PlayerManager.java │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ └── ic_plus.xml │ │ │ ├── layout/ │ │ │ │ ├── cast_context_error.xml │ │ │ │ ├── main_activity.xml │ │ │ │ └── sample_list.xml │ │ │ ├── menu/ │ │ │ │ └── menu.xml │ │ │ └── values/ │ │ │ └── strings.xml │ │ ├── ima/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── imademo/ │ │ │ │ ├── MainActivity.java │ │ │ │ └── PlayerManager.java │ │ │ └── res/ │ │ │ ├── layout/ │ │ │ │ └── main_activity.xml │ │ │ └── values/ │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ └── main/ │ │ ├── README.md │ │ ├── build.gradle │ │ ├── proguard-rules.txt │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── media.exolist.json │ │ ├── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── demo/ │ │ │ ├── DemoApplication.java │ │ │ ├── DemoDownloadService.java │ │ │ ├── DownloadTracker.java │ │ │ ├── PlayerActivity.java │ │ │ ├── SampleChooserActivity.java │ │ │ └── TrackSelectionDialog.java │ │ └── res/ │ │ ├── layout/ │ │ │ ├── player_activity.xml │ │ │ ├── sample_chooser_activity.xml │ │ │ ├── sample_list_item.xml │ │ │ └── track_selection_dialog.xml │ │ ├── menu/ │ │ │ └── sample_chooser_menu.xml │ │ └── values/ │ │ ├── strings.xml │ │ └── styles.xml │ ├── extensions/ │ │ ├── README.md │ │ ├── cast/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── cast/ │ │ │ │ ├── CastPlayer.java │ │ │ │ ├── CastTimeline.java │ │ │ │ ├── CastTimelineTracker.java │ │ │ │ ├── CastUtils.java │ │ │ │ ├── DefaultCastOptionsProvider.java │ │ │ │ ├── MediaItem.java │ │ │ │ ├── MediaItemQueue.java │ │ │ │ └── SessionAvailabilityListener.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── cast/ │ │ │ ├── CastTimelineTrackerTest.java │ │ │ └── MediaItemTest.java │ │ ├── cronet/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── cronet/ │ │ │ │ ├── ByteArrayUploadDataProvider.java │ │ │ │ ├── CronetDataSource.java │ │ │ │ ├── CronetDataSourceFactory.java │ │ │ │ └── CronetEngineWrapper.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── cronet/ │ │ │ ├── ByteArrayUploadDataProviderTest.java │ │ │ └── CronetDataSourceTest.java │ │ ├── ffmpeg/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── java/ │ │ │ │ │ └── com/ │ │ │ │ │ └── google/ │ │ │ │ │ └── android/ │ │ │ │ │ └── exoplayer2/ │ │ │ │ │ └── ext/ │ │ │ │ │ └── ffmpeg/ │ │ │ │ │ ├── FfmpegAudioRenderer.java │ │ │ │ │ ├── FfmpegDecoder.java │ │ │ │ │ ├── FfmpegDecoderException.java │ │ │ │ │ └── FfmpegLibrary.java │ │ │ │ └── jni/ │ │ │ │ ├── Android.mk │ │ │ │ ├── Application.mk │ │ │ │ └── ffmpeg_jni.cc │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── ffmpeg/ │ │ │ └── DefaultRenderersFactoryTest.java │ │ ├── flac/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ ├── androidTest/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── assets/ │ │ │ │ │ ├── bear-flac.mka │ │ │ │ │ ├── bear.flac │ │ │ │ │ ├── bear.flac.0.dump │ │ │ │ │ ├── bear.flac.1.dump │ │ │ │ │ ├── bear.flac.2.dump │ │ │ │ │ ├── bear.flac.3.dump │ │ │ │ │ ├── bear_no_seek.flac │ │ │ │ │ ├── bear_with_id3.flac │ │ │ │ │ ├── bear_with_id3.flac.0.dump │ │ │ │ │ ├── bear_with_id3.flac.1.dump │ │ │ │ │ ├── bear_with_id3.flac.2.dump │ │ │ │ │ └── bear_with_id3.flac.3.dump │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── flac/ │ │ │ │ ├── FlacBinarySearchSeekerTest.java │ │ │ │ ├── FlacExtractorSeekTest.java │ │ │ │ ├── FlacExtractorTest.java │ │ │ │ └── FlacPlaybackTest.java │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── java/ │ │ │ │ │ └── com/ │ │ │ │ │ └── google/ │ │ │ │ │ └── android/ │ │ │ │ │ └── exoplayer2/ │ │ │ │ │ └── ext/ │ │ │ │ │ └── flac/ │ │ │ │ │ ├── FlacBinarySearchSeeker.java │ │ │ │ │ ├── FlacDecoder.java │ │ │ │ │ ├── FlacDecoderException.java │ │ │ │ │ ├── FlacDecoderJni.java │ │ │ │ │ ├── FlacExtractor.java │ │ │ │ │ ├── FlacLibrary.java │ │ │ │ │ └── LibflacAudioRenderer.java │ │ │ │ └── jni/ │ │ │ │ ├── Android.mk │ │ │ │ ├── Application.mk │ │ │ │ ├── flac_jni.cc │ │ │ │ ├── flac_parser.cc │ │ │ │ ├── flac_sources.mk │ │ │ │ └── include/ │ │ │ │ ├── data_source.h │ │ │ │ └── flac_parser.h │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── flac/ │ │ │ ├── DefaultExtractorsFactoryTest.java │ │ │ └── DefaultRenderersFactoryTest.java │ │ ├── gvr/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── gvr/ │ │ │ │ ├── GvrAudioProcessor.java │ │ │ │ └── GvrPlayerActivity.java │ │ │ └── res/ │ │ │ ├── layout/ │ │ │ │ └── vr_ui.xml │ │ │ ├── values/ │ │ │ │ └── styles.xml │ │ │ └── values-v21/ │ │ │ └── styles.xml │ │ ├── ima/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── ima/ │ │ │ │ └── ImaAdsLoader.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── ima/ │ │ │ ├── FakeAd.java │ │ │ ├── FakeAdsLoader.java │ │ │ ├── FakeAdsRequest.java │ │ │ ├── FakePlayer.java │ │ │ ├── ImaAdsLoaderTest.java │ │ │ └── SingletonImaFactory.java │ │ ├── jobdispatcher/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── jobdispatcher/ │ │ │ └── JobDispatcherScheduler.java │ │ ├── leanback/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── leanback/ │ │ │ └── LeanbackPlayerAdapter.java │ │ ├── mediasession/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── mediasession/ │ │ │ │ ├── MediaSessionConnector.java │ │ │ │ ├── RepeatModeActionProvider.java │ │ │ │ ├── TimelineQueueEditor.java │ │ │ │ └── TimelineQueueNavigator.java │ │ │ └── res/ │ │ │ ├── drawable-anydpi-v21/ │ │ │ │ ├── exo_media_action_repeat_all.xml │ │ │ │ ├── exo_media_action_repeat_off.xml │ │ │ │ └── exo_media_action_repeat_one.xml │ │ │ ├── values/ │ │ │ │ └── strings.xml │ │ │ ├── values-af/ │ │ │ │ └── strings.xml │ │ │ ├── values-am/ │ │ │ │ └── strings.xml │ │ │ ├── values-ar/ │ │ │ │ └── strings.xml │ │ │ ├── values-az/ │ │ │ │ └── strings.xml │ │ │ ├── values-b+sr+Latn/ │ │ │ │ └── strings.xml │ │ │ ├── values-be/ │ │ │ │ └── strings.xml │ │ │ ├── values-bg/ │ │ │ │ └── strings.xml │ │ │ ├── values-bn/ │ │ │ │ └── strings.xml │ │ │ ├── values-bs/ │ │ │ │ └── strings.xml │ │ │ ├── values-ca/ │ │ │ │ └── strings.xml │ │ │ ├── values-cs/ │ │ │ │ └── strings.xml │ │ │ ├── values-da/ │ │ │ │ └── strings.xml │ │ │ ├── values-de/ │ │ │ │ └── strings.xml │ │ │ ├── values-el/ │ │ │ │ └── strings.xml │ │ │ ├── values-en-rAU/ │ │ │ │ └── strings.xml │ │ │ ├── values-en-rGB/ │ │ │ │ └── strings.xml │ │ │ ├── values-en-rIN/ │ │ │ │ └── strings.xml │ │ │ ├── values-es/ │ │ │ │ └── strings.xml │ │ │ ├── values-es-rUS/ │ │ │ │ └── strings.xml │ │ │ ├── values-et/ │ │ │ │ └── strings.xml │ │ │ ├── values-eu/ │ │ │ │ └── strings.xml │ │ │ ├── values-fa/ │ │ │ │ └── strings.xml │ │ │ ├── values-fi/ │ │ │ │ └── strings.xml │ │ │ ├── values-fr/ │ │ │ │ └── strings.xml │ │ │ ├── values-fr-rCA/ │ │ │ │ └── strings.xml │ │ │ ├── values-gl/ │ │ │ │ └── strings.xml │ │ │ ├── values-gu/ │ │ │ │ └── strings.xml │ │ │ ├── values-hi/ │ │ │ │ └── strings.xml │ │ │ ├── values-hr/ │ │ │ │ └── strings.xml │ │ │ ├── values-hu/ │ │ │ │ └── strings.xml │ │ │ ├── values-hy/ │ │ │ │ └── strings.xml │ │ │ ├── values-in/ │ │ │ │ └── strings.xml │ │ │ ├── values-is/ │ │ │ │ └── strings.xml │ │ │ ├── values-it/ │ │ │ │ └── strings.xml │ │ │ ├── values-iw/ │ │ │ │ └── strings.xml │ │ │ ├── values-ja/ │ │ │ │ └── strings.xml │ │ │ ├── values-ka/ │ │ │ │ └── strings.xml │ │ │ ├── values-kk/ │ │ │ │ └── strings.xml │ │ │ ├── values-km/ │ │ │ │ └── strings.xml │ │ │ ├── values-kn/ │ │ │ │ └── strings.xml │ │ │ ├── values-ko/ │ │ │ │ └── strings.xml │ │ │ ├── values-ky/ │ │ │ │ └── strings.xml │ │ │ ├── values-lo/ │ │ │ │ └── strings.xml │ │ │ ├── values-lt/ │ │ │ │ └── strings.xml │ │ │ ├── values-lv/ │ │ │ │ └── strings.xml │ │ │ ├── values-mk/ │ │ │ │ └── strings.xml │ │ │ ├── values-ml/ │ │ │ │ └── strings.xml │ │ │ ├── values-mn/ │ │ │ │ └── strings.xml │ │ │ ├── values-mr/ │ │ │ │ └── strings.xml │ │ │ ├── values-ms/ │ │ │ │ └── strings.xml │ │ │ ├── values-my/ │ │ │ │ └── strings.xml │ │ │ ├── values-nb/ │ │ │ │ └── strings.xml │ │ │ ├── values-ne/ │ │ │ │ └── strings.xml │ │ │ ├── values-nl/ │ │ │ │ └── strings.xml │ │ │ ├── values-pa/ │ │ │ │ └── strings.xml │ │ │ ├── values-pl/ │ │ │ │ └── strings.xml │ │ │ ├── values-pt/ │ │ │ │ └── strings.xml │ │ │ ├── values-pt-rPT/ │ │ │ │ └── strings.xml │ │ │ ├── values-ro/ │ │ │ │ └── strings.xml │ │ │ ├── values-ru/ │ │ │ │ └── strings.xml │ │ │ ├── values-si/ │ │ │ │ └── strings.xml │ │ │ ├── values-sk/ │ │ │ │ └── strings.xml │ │ │ ├── values-sl/ │ │ │ │ └── strings.xml │ │ │ ├── values-sq/ │ │ │ │ └── strings.xml │ │ │ ├── values-sr/ │ │ │ │ └── strings.xml │ │ │ ├── values-sv/ │ │ │ │ └── strings.xml │ │ │ ├── values-sw/ │ │ │ │ └── strings.xml │ │ │ ├── values-ta/ │ │ │ │ └── strings.xml │ │ │ ├── values-te/ │ │ │ │ └── strings.xml │ │ │ ├── values-th/ │ │ │ │ └── strings.xml │ │ │ ├── values-tl/ │ │ │ │ └── strings.xml │ │ │ ├── values-tr/ │ │ │ │ └── strings.xml │ │ │ ├── values-uk/ │ │ │ │ └── strings.xml │ │ │ ├── values-ur/ │ │ │ │ └── strings.xml │ │ │ ├── values-uz/ │ │ │ │ └── strings.xml │ │ │ ├── values-vi/ │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rCN/ │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rHK/ │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rTW/ │ │ │ │ └── strings.xml │ │ │ └── values-zu/ │ │ │ └── strings.xml │ │ ├── okhttp/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── okhttp/ │ │ │ ├── OkHttpDataSource.java │ │ │ └── OkHttpDataSourceFactory.java │ │ ├── opus/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ ├── androidTest/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── assets/ │ │ │ │ │ └── bear-opus.webm │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── opus/ │ │ │ │ └── OpusPlaybackTest.java │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── java/ │ │ │ │ │ └── com/ │ │ │ │ │ └── google/ │ │ │ │ │ └── android/ │ │ │ │ │ └── exoplayer2/ │ │ │ │ │ └── ext/ │ │ │ │ │ └── opus/ │ │ │ │ │ ├── LibopusAudioRenderer.java │ │ │ │ │ ├── OpusDecoder.java │ │ │ │ │ ├── OpusDecoderException.java │ │ │ │ │ └── OpusLibrary.java │ │ │ │ └── jni/ │ │ │ │ ├── Android.mk │ │ │ │ ├── Application.mk │ │ │ │ ├── convert_android_asm.sh │ │ │ │ ├── libopus.mk │ │ │ │ └── opus_jni.cc │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── opus/ │ │ │ └── DefaultRenderersFactoryTest.java │ │ ├── rtmp/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── rtmp/ │ │ │ │ ├── RtmpDataSource.java │ │ │ │ └── RtmpDataSourceFactory.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── rtmp/ │ │ │ └── DefaultDataSourceTest.java │ │ ├── vp9/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ ├── androidTest/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── assets/ │ │ │ │ │ ├── bear-vp9-odd-dimensions.webm │ │ │ │ │ ├── bear-vp9.webm │ │ │ │ │ ├── invalid-bitstream.webm │ │ │ │ │ └── roadtrip-vp92-10bit.webm │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ext/ │ │ │ │ └── vp9/ │ │ │ │ └── VpxPlaybackTest.java │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── java/ │ │ │ │ │ └── com/ │ │ │ │ │ └── google/ │ │ │ │ │ └── android/ │ │ │ │ │ └── exoplayer2/ │ │ │ │ │ └── ext/ │ │ │ │ │ └── vp9/ │ │ │ │ │ ├── LibvpxVideoRenderer.java │ │ │ │ │ ├── VpxDecoder.java │ │ │ │ │ ├── VpxDecoderException.java │ │ │ │ │ ├── VpxInputBuffer.java │ │ │ │ │ ├── VpxLibrary.java │ │ │ │ │ ├── VpxOutputBuffer.java │ │ │ │ │ ├── VpxOutputBufferRenderer.java │ │ │ │ │ ├── VpxRenderer.java │ │ │ │ │ └── VpxVideoSurfaceView.java │ │ │ │ └── jni/ │ │ │ │ ├── Android.mk │ │ │ │ ├── Application.mk │ │ │ │ ├── generate_libvpx_android_configs.sh │ │ │ │ ├── libvpx.mk │ │ │ │ └── vpx_jni.cc │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── ext/ │ │ │ └── vp9/ │ │ │ └── DefaultRenderersFactoryTest.java │ │ └── workmanager/ │ │ ├── README.md │ │ ├── build.gradle │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── google/ │ │ └── android/ │ │ └── exoplayer2/ │ │ └── ext/ │ │ └── workmanager/ │ │ └── WorkManagerScheduler.java │ ├── gradle/ │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── gradlew │ ├── gradlew.bat │ ├── issues/ │ │ └── player-accessed-on-wrong-thread.md │ ├── javadoc_combined.gradle │ ├── javadoc_library.gradle │ ├── javadoc_util.gradle │ ├── library/ │ │ ├── README.md │ │ ├── all/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ └── main/ │ │ │ └── AndroidManifest.xml │ │ ├── core/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ ├── proguard-rules.txt │ │ │ └── src/ │ │ │ ├── androidTest/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── upstream/ │ │ │ │ └── ContentDataSourceTest.java │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ ├── BasePlayer.java │ │ │ │ ├── BaseRenderer.java │ │ │ │ ├── C.java │ │ │ │ ├── ControlDispatcher.java │ │ │ │ ├── DefaultControlDispatcher.java │ │ │ │ ├── DefaultLoadControl.java │ │ │ │ ├── DefaultMediaClock.java │ │ │ │ ├── DefaultRenderersFactory.java │ │ │ │ ├── ExoPlaybackException.java │ │ │ │ ├── ExoPlayer.java │ │ │ │ ├── ExoPlayerFactory.java │ │ │ │ ├── ExoPlayerImpl.java │ │ │ │ ├── ExoPlayerImplInternal.java │ │ │ │ ├── ExoPlayerLibraryInfo.java │ │ │ │ ├── Format.java │ │ │ │ ├── FormatHolder.java │ │ │ │ ├── IllegalSeekPositionException.java │ │ │ │ ├── LoadControl.java │ │ │ │ ├── MediaPeriodHolder.java │ │ │ │ ├── MediaPeriodInfo.java │ │ │ │ ├── MediaPeriodQueue.java │ │ │ │ ├── NoSampleRenderer.java │ │ │ │ ├── ParserException.java │ │ │ │ ├── PlaybackInfo.java │ │ │ │ ├── PlaybackParameters.java │ │ │ │ ├── PlaybackPreparer.java │ │ │ │ ├── Player.java │ │ │ │ ├── PlayerMessage.java │ │ │ │ ├── Renderer.java │ │ │ │ ├── RendererCapabilities.java │ │ │ │ ├── RendererConfiguration.java │ │ │ │ ├── RenderersFactory.java │ │ │ │ ├── SeekParameters.java │ │ │ │ ├── SimpleExoPlayer.java │ │ │ │ ├── Timeline.java │ │ │ │ ├── analytics/ │ │ │ │ │ ├── AnalyticsCollector.java │ │ │ │ │ ├── AnalyticsListener.java │ │ │ │ │ └── DefaultAnalyticsListener.java │ │ │ │ ├── audio/ │ │ │ │ │ ├── Ac3Util.java │ │ │ │ │ ├── Ac4Util.java │ │ │ │ │ ├── AudioAttributes.java │ │ │ │ │ ├── AudioCapabilities.java │ │ │ │ │ ├── AudioCapabilitiesReceiver.java │ │ │ │ │ ├── AudioDecoderException.java │ │ │ │ │ ├── AudioFocusManager.java │ │ │ │ │ ├── AudioListener.java │ │ │ │ │ ├── AudioProcessor.java │ │ │ │ │ ├── AudioRendererEventListener.java │ │ │ │ │ ├── AudioSink.java │ │ │ │ │ ├── AudioTimestampPoller.java │ │ │ │ │ ├── AudioTrackPositionTracker.java │ │ │ │ │ ├── AuxEffectInfo.java │ │ │ │ │ ├── BaseAudioProcessor.java │ │ │ │ │ ├── ChannelMappingAudioProcessor.java │ │ │ │ │ ├── DefaultAudioSink.java │ │ │ │ │ ├── DolbyPassthroughAudioTrack.java │ │ │ │ │ ├── DtsUtil.java │ │ │ │ │ ├── FloatResamplingAudioProcessor.java │ │ │ │ │ ├── MediaCodecAudioRenderer.java │ │ │ │ │ ├── ResamplingAudioProcessor.java │ │ │ │ │ ├── SilenceSkippingAudioProcessor.java │ │ │ │ │ ├── SimpleDecoderAudioRenderer.java │ │ │ │ │ ├── Sonic.java │ │ │ │ │ ├── SonicAudioProcessor.java │ │ │ │ │ ├── TeeAudioProcessor.java │ │ │ │ │ ├── TrimmingAudioProcessor.java │ │ │ │ │ └── WavUtil.java │ │ │ │ ├── database/ │ │ │ │ │ ├── DatabaseIOException.java │ │ │ │ │ ├── DatabaseProvider.java │ │ │ │ │ ├── DefaultDatabaseProvider.java │ │ │ │ │ ├── ExoDatabaseProvider.java │ │ │ │ │ └── VersionTable.java │ │ │ │ ├── decoder/ │ │ │ │ │ ├── Buffer.java │ │ │ │ │ ├── CryptoInfo.java │ │ │ │ │ ├── Decoder.java │ │ │ │ │ ├── DecoderCounters.java │ │ │ │ │ ├── DecoderInputBuffer.java │ │ │ │ │ ├── OutputBuffer.java │ │ │ │ │ ├── SimpleDecoder.java │ │ │ │ │ └── SimpleOutputBuffer.java │ │ │ │ ├── drm/ │ │ │ │ │ ├── ClearKeyUtil.java │ │ │ │ │ ├── DecryptionException.java │ │ │ │ │ ├── DecryptionResource.java │ │ │ │ │ ├── DefaultDrmSession.java │ │ │ │ │ ├── DefaultDrmSessionEventListener.java │ │ │ │ │ ├── DefaultDrmSessionManager.java │ │ │ │ │ ├── DrmInitData.java │ │ │ │ │ ├── DrmSession.java │ │ │ │ │ ├── DrmSessionManager.java │ │ │ │ │ ├── ErrorStateDrmSession.java │ │ │ │ │ ├── ExoMediaCrypto.java │ │ │ │ │ ├── ExoMediaDrm.java │ │ │ │ │ ├── FrameworkMediaCrypto.java │ │ │ │ │ ├── FrameworkMediaDrm.java │ │ │ │ │ ├── HttpMediaDrmCallback.java │ │ │ │ │ ├── KeysExpiredException.java │ │ │ │ │ ├── LocalMediaDrmCallback.java │ │ │ │ │ ├── MediaDrmCallback.java │ │ │ │ │ ├── OfflineLicenseHelper.java │ │ │ │ │ ├── UnsupportedDrmException.java │ │ │ │ │ └── WidevineUtil.java │ │ │ │ ├── extractor/ │ │ │ │ │ ├── BinarySearchSeeker.java │ │ │ │ │ ├── ChunkIndex.java │ │ │ │ │ ├── ConstantBitrateSeekMap.java │ │ │ │ │ ├── DefaultExtractorInput.java │ │ │ │ │ ├── DefaultExtractorsFactory.java │ │ │ │ │ ├── DummyExtractorOutput.java │ │ │ │ │ ├── DummyTrackOutput.java │ │ │ │ │ ├── Extractor.java │ │ │ │ │ ├── ExtractorInput.java │ │ │ │ │ ├── ExtractorOutput.java │ │ │ │ │ ├── ExtractorsFactory.java │ │ │ │ │ ├── GaplessInfoHolder.java │ │ │ │ │ ├── Id3Peeker.java │ │ │ │ │ ├── MpegAudioHeader.java │ │ │ │ │ ├── PositionHolder.java │ │ │ │ │ ├── SeekMap.java │ │ │ │ │ ├── SeekPoint.java │ │ │ │ │ ├── TrackOutput.java │ │ │ │ │ ├── amr/ │ │ │ │ │ │ └── AmrExtractor.java │ │ │ │ │ ├── flv/ │ │ │ │ │ │ ├── AudioTagPayloadReader.java │ │ │ │ │ │ ├── FlvExtractor.java │ │ │ │ │ │ ├── ScriptTagPayloadReader.java │ │ │ │ │ │ ├── TagPayloadReader.java │ │ │ │ │ │ └── VideoTagPayloadReader.java │ │ │ │ │ ├── mkv/ │ │ │ │ │ │ ├── DefaultEbmlReader.java │ │ │ │ │ │ ├── EbmlProcessor.java │ │ │ │ │ │ ├── EbmlReader.java │ │ │ │ │ │ ├── MatroskaExtractor.java │ │ │ │ │ │ ├── Sniffer.java │ │ │ │ │ │ └── VarintReader.java │ │ │ │ │ ├── mp3/ │ │ │ │ │ │ ├── ConstantBitrateSeeker.java │ │ │ │ │ │ ├── MlltSeeker.java │ │ │ │ │ │ ├── Mp3Extractor.java │ │ │ │ │ │ ├── Seeker.java │ │ │ │ │ │ ├── VbriSeeker.java │ │ │ │ │ │ └── XingSeeker.java │ │ │ │ │ ├── mp4/ │ │ │ │ │ │ ├── Atom.java │ │ │ │ │ │ ├── AtomParsers.java │ │ │ │ │ │ ├── DefaultSampleValues.java │ │ │ │ │ │ ├── FixedSampleSizeRechunker.java │ │ │ │ │ │ ├── FragmentedMp4Extractor.java │ │ │ │ │ │ ├── MdtaMetadataEntry.java │ │ │ │ │ │ ├── MetadataUtil.java │ │ │ │ │ │ ├── Mp4Extractor.java │ │ │ │ │ │ ├── PsshAtomUtil.java │ │ │ │ │ │ ├── Sniffer.java │ │ │ │ │ │ ├── Track.java │ │ │ │ │ │ ├── TrackEncryptionBox.java │ │ │ │ │ │ ├── TrackFragment.java │ │ │ │ │ │ └── TrackSampleTable.java │ │ │ │ │ ├── ogg/ │ │ │ │ │ │ ├── DefaultOggSeeker.java │ │ │ │ │ │ ├── FlacReader.java │ │ │ │ │ │ ├── OggExtractor.java │ │ │ │ │ │ ├── OggPacket.java │ │ │ │ │ │ ├── OggPageHeader.java │ │ │ │ │ │ ├── OggSeeker.java │ │ │ │ │ │ ├── OpusReader.java │ │ │ │ │ │ ├── StreamReader.java │ │ │ │ │ │ ├── VorbisBitArray.java │ │ │ │ │ │ ├── VorbisReader.java │ │ │ │ │ │ └── VorbisUtil.java │ │ │ │ │ ├── rawcc/ │ │ │ │ │ │ └── RawCcExtractor.java │ │ │ │ │ ├── ts/ │ │ │ │ │ │ ├── Ac3Extractor.java │ │ │ │ │ │ ├── Ac3Reader.java │ │ │ │ │ │ ├── Ac4Extractor.java │ │ │ │ │ │ ├── Ac4Reader.java │ │ │ │ │ │ ├── AdtsExtractor.java │ │ │ │ │ │ ├── AdtsReader.java │ │ │ │ │ │ ├── DefaultTsPayloadReaderFactory.java │ │ │ │ │ │ ├── DtsReader.java │ │ │ │ │ │ ├── DvbSubtitleReader.java │ │ │ │ │ │ ├── ElementaryStreamReader.java │ │ │ │ │ │ ├── H262Reader.java │ │ │ │ │ │ ├── H264Reader.java │ │ │ │ │ │ ├── H265Reader.java │ │ │ │ │ │ ├── Id3Reader.java │ │ │ │ │ │ ├── LatmReader.java │ │ │ │ │ │ ├── MpegAudioReader.java │ │ │ │ │ │ ├── NalUnitTargetBuffer.java │ │ │ │ │ │ ├── PesReader.java │ │ │ │ │ │ ├── PsBinarySearchSeeker.java │ │ │ │ │ │ ├── PsDurationReader.java │ │ │ │ │ │ ├── PsExtractor.java │ │ │ │ │ │ ├── SectionPayloadReader.java │ │ │ │ │ │ ├── SectionReader.java │ │ │ │ │ │ ├── SeiReader.java │ │ │ │ │ │ ├── SpliceInfoSectionReader.java │ │ │ │ │ │ ├── TsBinarySearchSeeker.java │ │ │ │ │ │ ├── TsDurationReader.java │ │ │ │ │ │ ├── TsExtractor.java │ │ │ │ │ │ ├── TsPayloadReader.java │ │ │ │ │ │ ├── TsUtil.java │ │ │ │ │ │ └── UserDataReader.java │ │ │ │ │ └── wav/ │ │ │ │ │ ├── WavExtractor.java │ │ │ │ │ ├── WavHeader.java │ │ │ │ │ └── WavHeaderReader.java │ │ │ │ ├── mediacodec/ │ │ │ │ │ ├── MediaCodecInfo.java │ │ │ │ │ ├── MediaCodecRenderer.java │ │ │ │ │ ├── MediaCodecSelector.java │ │ │ │ │ ├── MediaCodecUtil.java │ │ │ │ │ └── MediaFormatUtil.java │ │ │ │ ├── metadata/ │ │ │ │ │ ├── Metadata.java │ │ │ │ │ ├── MetadataDecoder.java │ │ │ │ │ ├── MetadataDecoderFactory.java │ │ │ │ │ ├── MetadataInputBuffer.java │ │ │ │ │ ├── MetadataOutput.java │ │ │ │ │ ├── MetadataRenderer.java │ │ │ │ │ ├── emsg/ │ │ │ │ │ │ ├── EventMessage.java │ │ │ │ │ │ ├── EventMessageDecoder.java │ │ │ │ │ │ └── EventMessageEncoder.java │ │ │ │ │ ├── flac/ │ │ │ │ │ │ ├── PictureFrame.java │ │ │ │ │ │ └── VorbisComment.java │ │ │ │ │ ├── icy/ │ │ │ │ │ │ ├── IcyDecoder.java │ │ │ │ │ │ ├── IcyHeaders.java │ │ │ │ │ │ └── IcyInfo.java │ │ │ │ │ ├── id3/ │ │ │ │ │ │ ├── ApicFrame.java │ │ │ │ │ │ ├── BinaryFrame.java │ │ │ │ │ │ ├── ChapterFrame.java │ │ │ │ │ │ ├── ChapterTocFrame.java │ │ │ │ │ │ ├── CommentFrame.java │ │ │ │ │ │ ├── GeobFrame.java │ │ │ │ │ │ ├── Id3Decoder.java │ │ │ │ │ │ ├── Id3Frame.java │ │ │ │ │ │ ├── InternalFrame.java │ │ │ │ │ │ ├── MlltFrame.java │ │ │ │ │ │ ├── PrivFrame.java │ │ │ │ │ │ ├── TextInformationFrame.java │ │ │ │ │ │ └── UrlLinkFrame.java │ │ │ │ │ └── scte35/ │ │ │ │ │ ├── PrivateCommand.java │ │ │ │ │ ├── SpliceCommand.java │ │ │ │ │ ├── SpliceInfoDecoder.java │ │ │ │ │ ├── SpliceInsertCommand.java │ │ │ │ │ ├── SpliceNullCommand.java │ │ │ │ │ ├── SpliceScheduleCommand.java │ │ │ │ │ └── TimeSignalCommand.java │ │ │ │ ├── offline/ │ │ │ │ │ ├── ActionFile.java │ │ │ │ │ ├── ActionFileUpgradeUtil.java │ │ │ │ │ ├── DefaultDownloadIndex.java │ │ │ │ │ ├── DefaultDownloaderFactory.java │ │ │ │ │ ├── Download.java │ │ │ │ │ ├── DownloadCursor.java │ │ │ │ │ ├── DownloadException.java │ │ │ │ │ ├── DownloadHelper.java │ │ │ │ │ ├── DownloadIndex.java │ │ │ │ │ ├── DownloadManager.java │ │ │ │ │ ├── DownloadProgress.java │ │ │ │ │ ├── DownloadRequest.java │ │ │ │ │ ├── DownloadService.java │ │ │ │ │ ├── Downloader.java │ │ │ │ │ ├── DownloaderConstructorHelper.java │ │ │ │ │ ├── DownloaderFactory.java │ │ │ │ │ ├── FilterableManifest.java │ │ │ │ │ ├── FilteringManifestParser.java │ │ │ │ │ ├── ProgressiveDownloader.java │ │ │ │ │ ├── SegmentDownloader.java │ │ │ │ │ ├── StreamKey.java │ │ │ │ │ └── WritableDownloadIndex.java │ │ │ │ ├── scheduler/ │ │ │ │ │ ├── PlatformScheduler.java │ │ │ │ │ ├── Requirements.java │ │ │ │ │ ├── RequirementsWatcher.java │ │ │ │ │ └── Scheduler.java │ │ │ │ ├── source/ │ │ │ │ │ ├── AbstractConcatenatedTimeline.java │ │ │ │ │ ├── AdaptiveMediaSourceEventListener.java │ │ │ │ │ ├── BaseMediaSource.java │ │ │ │ │ ├── BehindLiveWindowException.java │ │ │ │ │ ├── ClippingMediaPeriod.java │ │ │ │ │ ├── ClippingMediaSource.java │ │ │ │ │ ├── CompositeMediaSource.java │ │ │ │ │ ├── CompositeSequenceableLoader.java │ │ │ │ │ ├── CompositeSequenceableLoaderFactory.java │ │ │ │ │ ├── ConcatenatingMediaSource.java │ │ │ │ │ ├── DefaultCompositeSequenceableLoaderFactory.java │ │ │ │ │ ├── DefaultMediaSourceEventListener.java │ │ │ │ │ ├── DeferredMediaPeriod.java │ │ │ │ │ ├── DynamicConcatenatingMediaSource.java │ │ │ │ │ ├── EmptySampleStream.java │ │ │ │ │ ├── ExtractorMediaSource.java │ │ │ │ │ ├── ForwardingTimeline.java │ │ │ │ │ ├── IcyDataSource.java │ │ │ │ │ ├── LoopingMediaSource.java │ │ │ │ │ ├── MediaPeriod.java │ │ │ │ │ ├── MediaSource.java │ │ │ │ │ ├── MediaSourceEventListener.java │ │ │ │ │ ├── MergingMediaPeriod.java │ │ │ │ │ ├── MergingMediaSource.java │ │ │ │ │ ├── ProgressiveMediaPeriod.java │ │ │ │ │ ├── ProgressiveMediaSource.java │ │ │ │ │ ├── SampleMetadataQueue.java │ │ │ │ │ ├── SampleQueue.java │ │ │ │ │ ├── SampleStream.java │ │ │ │ │ ├── SequenceableLoader.java │ │ │ │ │ ├── ShuffleOrder.java │ │ │ │ │ ├── SilenceMediaSource.java │ │ │ │ │ ├── SinglePeriodTimeline.java │ │ │ │ │ ├── SingleSampleMediaPeriod.java │ │ │ │ │ ├── SingleSampleMediaSource.java │ │ │ │ │ ├── TrackGroup.java │ │ │ │ │ ├── TrackGroupArray.java │ │ │ │ │ ├── UnrecognizedInputFormatException.java │ │ │ │ │ ├── ads/ │ │ │ │ │ │ ├── AdPlaybackState.java │ │ │ │ │ │ ├── AdsLoader.java │ │ │ │ │ │ ├── AdsMediaSource.java │ │ │ │ │ │ └── SinglePeriodAdTimeline.java │ │ │ │ │ └── chunk/ │ │ │ │ │ ├── BaseMediaChunk.java │ │ │ │ │ ├── BaseMediaChunkIterator.java │ │ │ │ │ ├── BaseMediaChunkOutput.java │ │ │ │ │ ├── Chunk.java │ │ │ │ │ ├── ChunkExtractorWrapper.java │ │ │ │ │ ├── ChunkHolder.java │ │ │ │ │ ├── ChunkSampleStream.java │ │ │ │ │ ├── ChunkSource.java │ │ │ │ │ ├── ContainerMediaChunk.java │ │ │ │ │ ├── DataChunk.java │ │ │ │ │ ├── InitializationChunk.java │ │ │ │ │ ├── MediaChunk.java │ │ │ │ │ ├── MediaChunkIterator.java │ │ │ │ │ ├── MediaChunkListIterator.java │ │ │ │ │ └── SingleSampleMediaChunk.java │ │ │ │ ├── text/ │ │ │ │ │ ├── CaptionStyleCompat.java │ │ │ │ │ ├── Cue.java │ │ │ │ │ ├── SimpleSubtitleDecoder.java │ │ │ │ │ ├── SimpleSubtitleOutputBuffer.java │ │ │ │ │ ├── Subtitle.java │ │ │ │ │ ├── SubtitleDecoder.java │ │ │ │ │ ├── SubtitleDecoderException.java │ │ │ │ │ ├── SubtitleDecoderFactory.java │ │ │ │ │ ├── SubtitleInputBuffer.java │ │ │ │ │ ├── SubtitleOutputBuffer.java │ │ │ │ │ ├── TextOutput.java │ │ │ │ │ ├── TextRenderer.java │ │ │ │ │ ├── cea/ │ │ │ │ │ │ ├── Cea608Decoder.java │ │ │ │ │ │ ├── Cea708Cue.java │ │ │ │ │ │ ├── Cea708Decoder.java │ │ │ │ │ │ ├── Cea708InitializationData.java │ │ │ │ │ │ ├── CeaDecoder.java │ │ │ │ │ │ ├── CeaSubtitle.java │ │ │ │ │ │ └── CeaUtil.java │ │ │ │ │ ├── dvb/ │ │ │ │ │ │ ├── DvbDecoder.java │ │ │ │ │ │ ├── DvbParser.java │ │ │ │ │ │ └── DvbSubtitle.java │ │ │ │ │ ├── pgs/ │ │ │ │ │ │ ├── PgsDecoder.java │ │ │ │ │ │ └── PgsSubtitle.java │ │ │ │ │ ├── ssa/ │ │ │ │ │ │ ├── SsaDecoder.java │ │ │ │ │ │ └── SsaSubtitle.java │ │ │ │ │ ├── subrip/ │ │ │ │ │ │ ├── SubripDecoder.java │ │ │ │ │ │ └── SubripSubtitle.java │ │ │ │ │ ├── ttml/ │ │ │ │ │ │ ├── TtmlDecoder.java │ │ │ │ │ │ ├── TtmlNode.java │ │ │ │ │ │ ├── TtmlRegion.java │ │ │ │ │ │ ├── TtmlRenderUtil.java │ │ │ │ │ │ ├── TtmlStyle.java │ │ │ │ │ │ └── TtmlSubtitle.java │ │ │ │ │ ├── tx3g/ │ │ │ │ │ │ ├── Tx3gDecoder.java │ │ │ │ │ │ └── Tx3gSubtitle.java │ │ │ │ │ └── webvtt/ │ │ │ │ │ ├── CssParser.java │ │ │ │ │ ├── Mp4WebvttDecoder.java │ │ │ │ │ ├── Mp4WebvttSubtitle.java │ │ │ │ │ ├── WebvttCssStyle.java │ │ │ │ │ ├── WebvttCue.java │ │ │ │ │ ├── WebvttCueParser.java │ │ │ │ │ ├── WebvttDecoder.java │ │ │ │ │ ├── WebvttParserUtil.java │ │ │ │ │ └── WebvttSubtitle.java │ │ │ │ ├── trackselection/ │ │ │ │ │ ├── AdaptiveTrackSelection.java │ │ │ │ │ ├── BaseTrackSelection.java │ │ │ │ │ ├── BufferSizeAdaptationBuilder.java │ │ │ │ │ ├── DefaultTrackSelector.java │ │ │ │ │ ├── FixedTrackSelection.java │ │ │ │ │ ├── MappingTrackSelector.java │ │ │ │ │ ├── RandomTrackSelection.java │ │ │ │ │ ├── TrackBitrateEstimator.java │ │ │ │ │ ├── TrackSelection.java │ │ │ │ │ ├── TrackSelectionArray.java │ │ │ │ │ ├── TrackSelectionParameters.java │ │ │ │ │ ├── TrackSelectionUtil.java │ │ │ │ │ ├── TrackSelector.java │ │ │ │ │ ├── TrackSelectorResult.java │ │ │ │ │ └── WindowedTrackBitrateEstimator.java │ │ │ │ ├── upstream/ │ │ │ │ │ ├── Allocation.java │ │ │ │ │ ├── Allocator.java │ │ │ │ │ ├── AssetDataSource.java │ │ │ │ │ ├── BandwidthMeter.java │ │ │ │ │ ├── BaseDataSource.java │ │ │ │ │ ├── ByteArrayDataSink.java │ │ │ │ │ ├── ByteArrayDataSource.java │ │ │ │ │ ├── ContentDataSource.java │ │ │ │ │ ├── DataSchemeDataSource.java │ │ │ │ │ ├── DataSink.java │ │ │ │ │ ├── DataSource.java │ │ │ │ │ ├── DataSourceException.java │ │ │ │ │ ├── DataSourceInputStream.java │ │ │ │ │ ├── DataSpec.java │ │ │ │ │ ├── DefaultAllocator.java │ │ │ │ │ ├── DefaultBandwidthMeter.java │ │ │ │ │ ├── DefaultDataSource.java │ │ │ │ │ ├── DefaultDataSourceFactory.java │ │ │ │ │ ├── DefaultHttpDataSource.java │ │ │ │ │ ├── DefaultHttpDataSourceFactory.java │ │ │ │ │ ├── DefaultLoadErrorHandlingPolicy.java │ │ │ │ │ ├── DummyDataSource.java │ │ │ │ │ ├── FileDataSource.java │ │ │ │ │ ├── FileDataSourceFactory.java │ │ │ │ │ ├── HttpDataSource.java │ │ │ │ │ ├── LoadErrorHandlingPolicy.java │ │ │ │ │ ├── Loader.java │ │ │ │ │ ├── LoaderErrorThrower.java │ │ │ │ │ ├── ParsingLoadable.java │ │ │ │ │ ├── PriorityDataSource.java │ │ │ │ │ ├── PriorityDataSourceFactory.java │ │ │ │ │ ├── RawResourceDataSource.java │ │ │ │ │ ├── ResolvingDataSource.java │ │ │ │ │ ├── StatsDataSource.java │ │ │ │ │ ├── TeeDataSource.java │ │ │ │ │ ├── TransferListener.java │ │ │ │ │ ├── UdpDataSource.java │ │ │ │ │ ├── cache/ │ │ │ │ │ │ ├── Cache.java │ │ │ │ │ │ ├── CacheDataSink.java │ │ │ │ │ │ ├── CacheDataSinkFactory.java │ │ │ │ │ │ ├── CacheDataSource.java │ │ │ │ │ │ ├── CacheDataSourceFactory.java │ │ │ │ │ │ ├── CacheEvictor.java │ │ │ │ │ │ ├── CacheFileMetadata.java │ │ │ │ │ │ ├── CacheFileMetadataIndex.java │ │ │ │ │ │ ├── CacheKeyFactory.java │ │ │ │ │ │ ├── CacheSpan.java │ │ │ │ │ │ ├── CacheUtil.java │ │ │ │ │ │ ├── CachedContent.java │ │ │ │ │ │ ├── CachedContentIndex.java │ │ │ │ │ │ ├── CachedRegionTracker.java │ │ │ │ │ │ ├── ContentMetadata.java │ │ │ │ │ │ ├── ContentMetadataMutations.java │ │ │ │ │ │ ├── DefaultContentMetadata.java │ │ │ │ │ │ ├── LeastRecentlyUsedCacheEvictor.java │ │ │ │ │ │ ├── NoOpCacheEvictor.java │ │ │ │ │ │ ├── SimpleCache.java │ │ │ │ │ │ └── SimpleCacheSpan.java │ │ │ │ │ └── crypto/ │ │ │ │ │ ├── AesCipherDataSink.java │ │ │ │ │ ├── AesCipherDataSource.java │ │ │ │ │ ├── AesFlushingCipher.java │ │ │ │ │ └── CryptoUtil.java │ │ │ │ ├── util/ │ │ │ │ │ ├── AmazonQuirks.java │ │ │ │ │ ├── Assertions.java │ │ │ │ │ ├── AtomicFile.java │ │ │ │ │ ├── Clock.java │ │ │ │ │ ├── CodecSpecificDataUtil.java │ │ │ │ │ ├── ColorParser.java │ │ │ │ │ ├── ConditionVariable.java │ │ │ │ │ ├── EGLSurfaceTexture.java │ │ │ │ │ ├── ErrorMessageProvider.java │ │ │ │ │ ├── EventDispatcher.java │ │ │ │ │ ├── EventLogger.java │ │ │ │ │ ├── FlacStreamMetadata.java │ │ │ │ │ ├── GlUtil.java │ │ │ │ │ ├── HandlerWrapper.java │ │ │ │ │ ├── LibraryLoader.java │ │ │ │ │ ├── Log.java │ │ │ │ │ ├── Logger.java │ │ │ │ │ ├── LongArray.java │ │ │ │ │ ├── MediaClock.java │ │ │ │ │ ├── MimeTypes.java │ │ │ │ │ ├── NalUnitUtil.java │ │ │ │ │ ├── NotificationUtil.java │ │ │ │ │ ├── ParsableBitArray.java │ │ │ │ │ ├── ParsableByteArray.java │ │ │ │ │ ├── ParsableNalUnitBitArray.java │ │ │ │ │ ├── Predicate.java │ │ │ │ │ ├── PriorityTaskManager.java │ │ │ │ │ ├── RepeatModeUtil.java │ │ │ │ │ ├── ReusableBufferedOutputStream.java │ │ │ │ │ ├── SlidingPercentile.java │ │ │ │ │ ├── StandaloneMediaClock.java │ │ │ │ │ ├── SystemClock.java │ │ │ │ │ ├── SystemHandlerWrapper.java │ │ │ │ │ ├── TimedValueQueue.java │ │ │ │ │ ├── TimestampAdjuster.java │ │ │ │ │ ├── TraceUtil.java │ │ │ │ │ ├── UriUtil.java │ │ │ │ │ ├── Util.java │ │ │ │ │ └── XmlPullParserUtil.java │ │ │ │ └── video/ │ │ │ │ ├── AvcConfig.java │ │ │ │ ├── ColorInfo.java │ │ │ │ ├── DolbyVisionConfig.java │ │ │ │ ├── DummySurface.java │ │ │ │ ├── HevcConfig.java │ │ │ │ ├── MediaCodecVideoRenderer.java │ │ │ │ ├── VideoFrameMetadataListener.java │ │ │ │ ├── VideoFrameReleaseTimeHelper.java │ │ │ │ ├── VideoListener.java │ │ │ │ ├── VideoRendererEventListener.java │ │ │ │ └── spherical/ │ │ │ │ ├── CameraMotionListener.java │ │ │ │ ├── CameraMotionRenderer.java │ │ │ │ ├── FrameRotationQueue.java │ │ │ │ ├── Projection.java │ │ │ │ └── ProjectionDecoder.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── assets/ │ │ │ │ ├── amr/ │ │ │ │ │ ├── sample_nb.amr │ │ │ │ │ ├── sample_nb.amr.0.dump │ │ │ │ │ ├── sample_nb_cbr.amr │ │ │ │ │ ├── sample_nb_cbr.amr.0.dump │ │ │ │ │ ├── sample_nb_cbr.amr.1.dump │ │ │ │ │ ├── sample_nb_cbr.amr.2.dump │ │ │ │ │ ├── sample_nb_cbr.amr.3.dump │ │ │ │ │ ├── sample_nb_cbr.amr.unklen.dump │ │ │ │ │ ├── sample_wb.amr │ │ │ │ │ ├── sample_wb.amr.0.dump │ │ │ │ │ ├── sample_wb_cbr.amr │ │ │ │ │ ├── sample_wb_cbr.amr.0.dump │ │ │ │ │ ├── sample_wb_cbr.amr.1.dump │ │ │ │ │ ├── sample_wb_cbr.amr.2.dump │ │ │ │ │ ├── sample_wb_cbr.amr.3.dump │ │ │ │ │ └── sample_wb_cbr.amr.unklen.dump │ │ │ │ ├── download-actions/ │ │ │ │ │ ├── dash-download-v0 │ │ │ │ │ ├── dash-remove-v0 │ │ │ │ │ ├── hls-download-v0 │ │ │ │ │ ├── hls-download-v1 │ │ │ │ │ ├── hls-remove-v0 │ │ │ │ │ ├── hls-remove-v1 │ │ │ │ │ ├── progressive-download-v0 │ │ │ │ │ ├── progressive-remove-v0 │ │ │ │ │ ├── ss-download-v0 │ │ │ │ │ ├── ss-download-v1 │ │ │ │ │ ├── ss-remove-v0 │ │ │ │ │ └── ss-remove-v1 │ │ │ │ ├── flv/ │ │ │ │ │ ├── sample.flv │ │ │ │ │ └── sample.flv.0.dump │ │ │ │ ├── mkv/ │ │ │ │ │ ├── sample.mkv │ │ │ │ │ ├── sample.mkv.0.dump │ │ │ │ │ ├── sample.mkv.1.dump │ │ │ │ │ ├── sample.mkv.2.dump │ │ │ │ │ ├── sample.mkv.3.dump │ │ │ │ │ ├── subsample_encrypted_altref.webm │ │ │ │ │ ├── subsample_encrypted_altref.webm.0.dump │ │ │ │ │ ├── subsample_encrypted_noaltref.webm │ │ │ │ │ └── subsample_encrypted_noaltref.webm.0.dump │ │ │ │ ├── mp3/ │ │ │ │ │ ├── bear.mp3.0.dump │ │ │ │ │ ├── bear.mp3.1.dump │ │ │ │ │ ├── bear.mp3.2.dump │ │ │ │ │ ├── bear.mp3.3.dump │ │ │ │ │ ├── play-trimmed.mp3.0.dump │ │ │ │ │ ├── play-trimmed.mp3.1.dump │ │ │ │ │ ├── play-trimmed.mp3.2.dump │ │ │ │ │ ├── play-trimmed.mp3.3.dump │ │ │ │ │ └── play-trimmed.mp3.unklen.dump │ │ │ │ ├── mp4/ │ │ │ │ │ ├── sample.mp4.0.dump │ │ │ │ │ ├── sample.mp4.1.dump │ │ │ │ │ ├── sample.mp4.2.dump │ │ │ │ │ ├── sample.mp4.3.dump │ │ │ │ │ ├── sample_fragmented.mp4.0.dump │ │ │ │ │ ├── sample_fragmented_seekable.mp4.0.dump │ │ │ │ │ ├── sample_fragmented_seekable.mp4.1.dump │ │ │ │ │ ├── sample_fragmented_seekable.mp4.2.dump │ │ │ │ │ ├── sample_fragmented_seekable.mp4.3.dump │ │ │ │ │ └── sample_fragmented_sei.mp4.0.dump │ │ │ │ ├── offline/ │ │ │ │ │ ├── action_file_for_download_index_upgrade.exi │ │ │ │ │ ├── action_file_incomplete_header.exi │ │ │ │ │ ├── action_file_no_data.exi │ │ │ │ │ ├── action_file_one_action.exi │ │ │ │ │ ├── action_file_two_actions.exi │ │ │ │ │ ├── action_file_unsupported_version.exi │ │ │ │ │ └── action_file_zero_actions.exi │ │ │ │ ├── ogg/ │ │ │ │ │ ├── bear.opus │ │ │ │ │ ├── bear.opus.0.dump │ │ │ │ │ ├── bear.opus.1.dump │ │ │ │ │ ├── bear.opus.2.dump │ │ │ │ │ ├── bear.opus.3.dump │ │ │ │ │ ├── bear.opus.unklen.dump │ │ │ │ │ ├── bear_flac.ogg │ │ │ │ │ ├── bear_flac.ogg.0.dump │ │ │ │ │ ├── bear_flac.ogg.1.dump │ │ │ │ │ ├── bear_flac.ogg.2.dump │ │ │ │ │ ├── bear_flac.ogg.3.dump │ │ │ │ │ ├── bear_flac.ogg.unklen.dump │ │ │ │ │ ├── bear_flac_noseektable.ogg │ │ │ │ │ ├── bear_flac_noseektable.ogg.0.dump │ │ │ │ │ ├── bear_flac_noseektable.ogg.1.dump │ │ │ │ │ ├── bear_flac_noseektable.ogg.2.dump │ │ │ │ │ ├── bear_flac_noseektable.ogg.3.dump │ │ │ │ │ ├── bear_flac_noseektable.ogg.unklen.dump │ │ │ │ │ ├── bear_vorbis.ogg │ │ │ │ │ ├── bear_vorbis.ogg.0.dump │ │ │ │ │ ├── bear_vorbis.ogg.1.dump │ │ │ │ │ ├── bear_vorbis.ogg.2.dump │ │ │ │ │ ├── bear_vorbis.ogg.3.dump │ │ │ │ │ └── bear_vorbis.ogg.unklen.dump │ │ │ │ ├── rawcc/ │ │ │ │ │ ├── sample.rawcc │ │ │ │ │ └── sample.rawcc.0.dump │ │ │ │ ├── ssa/ │ │ │ │ │ ├── empty │ │ │ │ │ ├── invalid_timecodes │ │ │ │ │ ├── no_end_timecodes │ │ │ │ │ ├── typical │ │ │ │ │ ├── typical_dialogue │ │ │ │ │ ├── typical_format │ │ │ │ │ └── typical_header │ │ │ │ ├── subrip/ │ │ │ │ │ ├── empty │ │ │ │ │ ├── no_end_timecodes │ │ │ │ │ ├── typical │ │ │ │ │ ├── typical_extra_blank_line │ │ │ │ │ ├── typical_missing_sequence │ │ │ │ │ ├── typical_missing_timecode │ │ │ │ │ ├── typical_negative_timestamps │ │ │ │ │ ├── typical_unexpected_end │ │ │ │ │ ├── typical_with_byte_order_mark │ │ │ │ │ └── typical_with_tags │ │ │ │ ├── ts/ │ │ │ │ │ ├── bbb_2500ms.ts │ │ │ │ │ ├── elephants_dream.mpg │ │ │ │ │ ├── sample.ac3 │ │ │ │ │ ├── sample.ac3.0.dump │ │ │ │ │ ├── sample.ac4 │ │ │ │ │ ├── sample.ac4.0.dump │ │ │ │ │ ├── sample.adts │ │ │ │ │ ├── sample.adts.0.dump │ │ │ │ │ ├── sample.eac3 │ │ │ │ │ ├── sample.eac3.0.dump │ │ │ │ │ ├── sample.ps │ │ │ │ │ ├── sample.ps.0.dump │ │ │ │ │ ├── sample.ps.1.dump │ │ │ │ │ ├── sample.ps.2.dump │ │ │ │ │ ├── sample.ps.3.dump │ │ │ │ │ ├── sample.ps.unklen.dump │ │ │ │ │ ├── sample.ts │ │ │ │ │ ├── sample.ts.0.dump │ │ │ │ │ ├── sample.ts.1.dump │ │ │ │ │ ├── sample.ts.2.dump │ │ │ │ │ ├── sample.ts.3.dump │ │ │ │ │ ├── sample.ts.unklen.dump │ │ │ │ │ ├── sample_cbs.adts │ │ │ │ │ ├── sample_cbs.adts.0.dump │ │ │ │ │ ├── sample_cbs.adts.1.dump │ │ │ │ │ ├── sample_cbs.adts.2.dump │ │ │ │ │ ├── sample_cbs.adts.3.dump │ │ │ │ │ ├── sample_cbs.adts.unklen.dump │ │ │ │ │ └── sample_with_sdt.ts │ │ │ │ ├── ttml/ │ │ │ │ │ ├── bitmap_percentage_region.xml │ │ │ │ │ ├── bitmap_pixel_region.xml │ │ │ │ │ ├── bitmap_unsupported_region.xml │ │ │ │ │ ├── chain_multiple_styles.xml │ │ │ │ │ ├── font_size.xml │ │ │ │ │ ├── font_size_empty.xml │ │ │ │ │ ├── font_size_invalid.xml │ │ │ │ │ ├── font_size_no_unit.xml │ │ │ │ │ ├── frame_rate.xml │ │ │ │ │ ├── inherit_and_override_style.xml │ │ │ │ │ ├── inherit_global_and_parent.xml │ │ │ │ │ ├── inherit_multiple_styles.xml │ │ │ │ │ ├── inherit_style.xml │ │ │ │ │ ├── inline_style_attributes.xml │ │ │ │ │ ├── multiple_regions.xml │ │ │ │ │ └── no_underline_linethrough.xml │ │ │ │ ├── tx3g/ │ │ │ │ │ ├── initialization │ │ │ │ │ ├── initialization_all_defaults │ │ │ │ │ ├── no_subtitle │ │ │ │ │ ├── sample_just_text │ │ │ │ │ ├── sample_utf16_be_no_styl │ │ │ │ │ ├── sample_utf16_le_no_styl │ │ │ │ │ ├── sample_with_multiple_styl │ │ │ │ │ ├── sample_with_other_extension │ │ │ │ │ ├── sample_with_styl │ │ │ │ │ ├── sample_with_styl_all_defaults │ │ │ │ │ └── sample_with_tbox │ │ │ │ ├── wav/ │ │ │ │ │ ├── sample.wav.0.dump │ │ │ │ │ ├── sample.wav.1.dump │ │ │ │ │ ├── sample.wav.2.dump │ │ │ │ │ └── sample.wav.3.dump │ │ │ │ ├── webm/ │ │ │ │ │ └── vorbis_codec_private │ │ │ │ └── webvtt/ │ │ │ │ ├── empty │ │ │ │ ├── typical │ │ │ │ ├── typical_with_bad_timestamps │ │ │ │ ├── typical_with_comments │ │ │ │ ├── typical_with_identifiers │ │ │ │ ├── with_bad_cue_header │ │ │ │ ├── with_bom │ │ │ │ ├── with_css_complex_selectors │ │ │ │ ├── with_css_styles │ │ │ │ ├── with_positioning │ │ │ │ └── with_tags │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ ├── CTest.java │ │ │ ├── DefaultLoadControlTest.java │ │ │ ├── DefaultMediaClockTest.java │ │ │ ├── ExoPlayerTest.java │ │ │ ├── FormatTest.java │ │ │ ├── MediaPeriodQueueTest.java │ │ │ ├── TimelineTest.java │ │ │ ├── analytics/ │ │ │ │ └── AnalyticsCollectorTest.java │ │ │ ├── audio/ │ │ │ │ ├── Ac3UtilTest.java │ │ │ │ ├── AudioFocusManagerTest.java │ │ │ │ ├── DefaultAudioSinkTest.java │ │ │ │ ├── SilenceSkippingAudioProcessorTest.java │ │ │ │ ├── SimpleDecoderAudioRendererTest.java │ │ │ │ └── SonicAudioProcessorTest.java │ │ │ ├── database/ │ │ │ │ └── VersionTableTest.java │ │ │ ├── drm/ │ │ │ │ ├── ClearKeyUtilTest.java │ │ │ │ ├── DrmInitDataTest.java │ │ │ │ └── OfflineLicenseHelperTest.java │ │ │ ├── extractor/ │ │ │ │ ├── ConstantBitrateSeekMapTest.java │ │ │ │ ├── DefaultExtractorInputTest.java │ │ │ │ ├── DefaultExtractorsFactoryTest.java │ │ │ │ ├── ExtractorTest.java │ │ │ │ ├── Id3PeekerTest.java │ │ │ │ ├── amr/ │ │ │ │ │ ├── AmrExtractorSeekTest.java │ │ │ │ │ └── AmrExtractorTest.java │ │ │ │ ├── flv/ │ │ │ │ │ └── FlvExtractorTest.java │ │ │ │ ├── mkv/ │ │ │ │ │ ├── DefaultEbmlReaderTest.java │ │ │ │ │ ├── MatroskaExtractorTest.java │ │ │ │ │ └── VarintReaderTest.java │ │ │ │ ├── mp3/ │ │ │ │ │ ├── Mp3ExtractorTest.java │ │ │ │ │ └── XingSeekerTest.java │ │ │ │ ├── mp4/ │ │ │ │ │ ├── AtomParsersTest.java │ │ │ │ │ ├── FragmentedMp4ExtractorTest.java │ │ │ │ │ ├── MdtaMetadataEntryTest.java │ │ │ │ │ ├── Mp4ExtractorTest.java │ │ │ │ │ └── PsshAtomUtilTest.java │ │ │ │ ├── ogg/ │ │ │ │ │ ├── DefaultOggSeekerTest.java │ │ │ │ │ ├── OggExtractorTest.java │ │ │ │ │ ├── OggPacketTest.java │ │ │ │ │ ├── OggPageHeaderTest.java │ │ │ │ │ ├── OggTestFile.java │ │ │ │ │ ├── VorbisBitArrayTest.java │ │ │ │ │ ├── VorbisReaderTest.java │ │ │ │ │ └── VorbisUtilTest.java │ │ │ │ ├── rawcc/ │ │ │ │ │ └── RawCcExtractorTest.java │ │ │ │ ├── ts/ │ │ │ │ │ ├── Ac3ExtractorTest.java │ │ │ │ │ ├── Ac4ExtractorTest.java │ │ │ │ │ ├── AdtsExtractorSeekTest.java │ │ │ │ │ ├── AdtsExtractorTest.java │ │ │ │ │ ├── AdtsReaderTest.java │ │ │ │ │ ├── PsDurationReaderTest.java │ │ │ │ │ ├── PsExtractorSeekTest.java │ │ │ │ │ ├── PsExtractorTest.java │ │ │ │ │ ├── SectionReaderTest.java │ │ │ │ │ ├── TsDurationReaderTest.java │ │ │ │ │ ├── TsExtractorSeekTest.java │ │ │ │ │ └── TsExtractorTest.java │ │ │ │ └── wav/ │ │ │ │ └── WavExtractorTest.java │ │ │ ├── metadata/ │ │ │ │ ├── MetadataRendererTest.java │ │ │ │ ├── emsg/ │ │ │ │ │ ├── EventMessageDecoderTest.java │ │ │ │ │ ├── EventMessageEncoderTest.java │ │ │ │ │ └── EventMessageTest.java │ │ │ │ ├── flac/ │ │ │ │ │ ├── PictureFrameTest.java │ │ │ │ │ └── VorbisCommentTest.java │ │ │ │ ├── icy/ │ │ │ │ │ ├── IcyDecoderTest.java │ │ │ │ │ ├── IcyHeadersTest.java │ │ │ │ │ └── IcyInfoTest.java │ │ │ │ ├── id3/ │ │ │ │ │ ├── ChapterFrameTest.java │ │ │ │ │ ├── ChapterTocFrameTest.java │ │ │ │ │ ├── Id3DecoderTest.java │ │ │ │ │ └── MlltFrameTest.java │ │ │ │ └── scte35/ │ │ │ │ └── SpliceInfoDecoderTest.java │ │ │ ├── offline/ │ │ │ │ ├── ActionFileTest.java │ │ │ │ ├── ActionFileUpgradeUtilTest.java │ │ │ │ ├── DefaultDownloadIndexTest.java │ │ │ │ ├── DefaultDownloaderFactoryTest.java │ │ │ │ ├── DownloadBuilder.java │ │ │ │ ├── DownloadHelperTest.java │ │ │ │ ├── DownloadManagerTest.java │ │ │ │ ├── DownloadRequestTest.java │ │ │ │ └── StreamKeyTest.java │ │ │ ├── source/ │ │ │ │ ├── ClippingMediaSourceTest.java │ │ │ │ ├── CompositeSequenceableLoaderTest.java │ │ │ │ ├── ConcatenatingMediaSourceTest.java │ │ │ │ ├── LoopingMediaSourceTest.java │ │ │ │ ├── MergingMediaSourceTest.java │ │ │ │ ├── SampleQueueTest.java │ │ │ │ ├── ShuffleOrderTest.java │ │ │ │ ├── SinglePeriodTimelineTest.java │ │ │ │ ├── TrackGroupArrayTest.java │ │ │ │ ├── TrackGroupTest.java │ │ │ │ ├── ads/ │ │ │ │ │ └── AdPlaybackStateTest.java │ │ │ │ └── chunk/ │ │ │ │ └── MediaChunkListIteratorTest.java │ │ │ ├── text/ │ │ │ │ ├── ssa/ │ │ │ │ │ └── SsaDecoderTest.java │ │ │ │ ├── subrip/ │ │ │ │ │ └── SubripDecoderTest.java │ │ │ │ ├── ttml/ │ │ │ │ │ ├── TtmlDecoderTest.java │ │ │ │ │ ├── TtmlRenderUtilTest.java │ │ │ │ │ └── TtmlStyleTest.java │ │ │ │ ├── tx3g/ │ │ │ │ │ └── Tx3gDecoderTest.java │ │ │ │ └── webvtt/ │ │ │ │ ├── CssParserTest.java │ │ │ │ ├── Mp4WebvttDecoderTest.java │ │ │ │ ├── WebvttCueParserTest.java │ │ │ │ ├── WebvttDecoderTest.java │ │ │ │ └── WebvttSubtitleTest.java │ │ │ ├── trackselection/ │ │ │ │ ├── AdaptiveTrackSelectionTest.java │ │ │ │ ├── BufferSizeAdaptiveTrackSelectionTest.java │ │ │ │ ├── DefaultTrackSelectorTest.java │ │ │ │ ├── MappingTrackSelectorTest.java │ │ │ │ ├── TrackSelectionUtilTest.java │ │ │ │ ├── TrackSelectorTest.java │ │ │ │ └── WindowedTrackBitrateEstimatorTest.java │ │ │ ├── upstream/ │ │ │ │ ├── AssetDataSourceTest.java │ │ │ │ ├── BaseDataSourceTest.java │ │ │ │ ├── ByteArrayDataSourceTest.java │ │ │ │ ├── DataSchemeDataSourceTest.java │ │ │ │ ├── DataSourceAsserts.java │ │ │ │ ├── DataSourceInputStreamTest.java │ │ │ │ ├── DataSpecTest.java │ │ │ │ ├── DefaultBandwidthMeterTest.java │ │ │ │ ├── DefaultLoadErrorHandlingPolicyTest.java │ │ │ │ ├── cache/ │ │ │ │ │ ├── CacheDataSourceTest.java │ │ │ │ │ ├── CacheDataSourceTest2.java │ │ │ │ │ ├── CacheUtilTest.java │ │ │ │ │ ├── CachedContentIndexTest.java │ │ │ │ │ ├── CachedRegionTrackerTest.java │ │ │ │ │ ├── DefaultContentMetadataTest.java │ │ │ │ │ ├── LeastRecentlyUsedCacheEvictorTest.java │ │ │ │ │ ├── SimpleCacheSpanTest.java │ │ │ │ │ └── SimpleCacheTest.java │ │ │ │ └── crypto/ │ │ │ │ └── AesFlushingCipherTest.java │ │ │ ├── util/ │ │ │ │ ├── AtomicFileTest.java │ │ │ │ ├── ColorParserTest.java │ │ │ │ ├── FlacStreamMetadataTest.java │ │ │ │ ├── MimeTypesTest.java │ │ │ │ ├── NalUnitUtilTest.java │ │ │ │ ├── ParsableBitArrayTest.java │ │ │ │ ├── ParsableByteArrayTest.java │ │ │ │ ├── ParsableNalUnitBitArrayTest.java │ │ │ │ ├── ReusableBufferedOutputStreamTest.java │ │ │ │ ├── TimedValueQueueTest.java │ │ │ │ ├── UriUtilTest.java │ │ │ │ └── UtilTest.java │ │ │ └── video/ │ │ │ └── spherical/ │ │ │ ├── FrameRotationQueueTest.java │ │ │ ├── ProjectionDecoderTest.java │ │ │ └── ProjectionTest.java │ │ ├── dash/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── source/ │ │ │ │ └── dash/ │ │ │ │ ├── DashChunkSource.java │ │ │ │ ├── DashManifestStaleException.java │ │ │ │ ├── DashMediaPeriod.java │ │ │ │ ├── DashMediaSource.java │ │ │ │ ├── DashSegmentIndex.java │ │ │ │ ├── DashUtil.java │ │ │ │ ├── DashWrappingSegmentIndex.java │ │ │ │ ├── DefaultDashChunkSource.java │ │ │ │ ├── EventSampleStream.java │ │ │ │ ├── PlayerEmsgHandler.java │ │ │ │ ├── manifest/ │ │ │ │ │ ├── AdaptationSet.java │ │ │ │ │ ├── DashManifest.java │ │ │ │ │ ├── DashManifestParser.java │ │ │ │ │ ├── DashManifestParser2.java │ │ │ │ │ ├── Descriptor.java │ │ │ │ │ ├── EventStream.java │ │ │ │ │ ├── Period.java │ │ │ │ │ ├── ProgramInformation.java │ │ │ │ │ ├── RangedUri.java │ │ │ │ │ ├── Representation.java │ │ │ │ │ ├── SegmentBase.java │ │ │ │ │ ├── SingleSegmentIndex.java │ │ │ │ │ ├── UrlTemplate.java │ │ │ │ │ └── UtcTimingElement.java │ │ │ │ └── offline/ │ │ │ │ └── DashDownloader.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── assets/ │ │ │ │ ├── sample_mpd │ │ │ │ ├── sample_mpd_event_stream │ │ │ │ ├── sample_mpd_labels │ │ │ │ ├── sample_mpd_segment_template │ │ │ │ └── sample_mpd_unknown_mime_type │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── source/ │ │ │ └── dash/ │ │ │ ├── DashMediaPeriodTest.java │ │ │ ├── DashMediaSourceTest.java │ │ │ ├── DashUtilTest.java │ │ │ ├── EventSampleStreamTest.java │ │ │ ├── manifest/ │ │ │ │ ├── DashManifestParserTest.java │ │ │ │ ├── DashManifestTest.java │ │ │ │ ├── RangedUriTest.java │ │ │ │ └── UrlTemplateTest.java │ │ │ └── offline/ │ │ │ ├── DashDownloadTestData.java │ │ │ ├── DashDownloaderTest.java │ │ │ ├── DownloadHelperTest.java │ │ │ ├── DownloadManagerDashTest.java │ │ │ └── DownloadServiceDashTest.java │ │ ├── hls/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── source/ │ │ │ │ └── hls/ │ │ │ │ ├── Aes128DataSource.java │ │ │ │ ├── DefaultHlsDataSourceFactory.java │ │ │ │ ├── DefaultHlsExtractorFactory.java │ │ │ │ ├── HlsChunkSource.java │ │ │ │ ├── HlsDataSourceFactory.java │ │ │ │ ├── HlsExtractorFactory.java │ │ │ │ ├── HlsManifest.java │ │ │ │ ├── HlsMediaChunk.java │ │ │ │ ├── HlsMediaPeriod.java │ │ │ │ ├── HlsMediaSource.java │ │ │ │ ├── HlsMetadataType.java │ │ │ │ ├── HlsSampleStream.java │ │ │ │ ├── HlsSampleStreamWrapper.java │ │ │ │ ├── HlsTrackMetadataEntry.java │ │ │ │ ├── SampleQueueMappingException.java │ │ │ │ ├── TimestampAdjusterProvider.java │ │ │ │ ├── WebvttExtractor.java │ │ │ │ ├── offline/ │ │ │ │ │ └── HlsDownloader.java │ │ │ │ └── playlist/ │ │ │ │ ├── DefaultHlsPlaylistParserFactory.java │ │ │ │ ├── DefaultHlsPlaylistTracker.java │ │ │ │ ├── FilteringHlsPlaylistParserFactory.java │ │ │ │ ├── HlsMasterPlaylist.java │ │ │ │ ├── HlsMediaPlaylist.java │ │ │ │ ├── HlsPlaylist.java │ │ │ │ ├── HlsPlaylistParser.java │ │ │ │ ├── HlsPlaylistParserFactory.java │ │ │ │ └── HlsPlaylistTracker.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── source/ │ │ │ └── hls/ │ │ │ ├── Aes128DataSourceTest.java │ │ │ ├── HlsMediaPeriodTest.java │ │ │ ├── WebvttExtractorTest.java │ │ │ ├── offline/ │ │ │ │ ├── DownloadHelperTest.java │ │ │ │ ├── HlsDownloadTestData.java │ │ │ │ └── HlsDownloaderTest.java │ │ │ └── playlist/ │ │ │ ├── HlsMasterPlaylistParserTest.java │ │ │ └── HlsMediaPlaylistParserTest.java │ │ ├── sabr/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── source/ │ │ │ │ └── sabr/ │ │ │ │ ├── DefaultSabrChunkSource.java │ │ │ │ ├── EventSampleStream.java │ │ │ │ ├── PlayerEmsgHandler.java │ │ │ │ ├── SabrChunkSource.java │ │ │ │ ├── SabrMediaPeriod.java │ │ │ │ ├── SabrMediaSource.java │ │ │ │ ├── SabrSegmentIndex.java │ │ │ │ ├── SabrWrappingSegmentIndex.java │ │ │ │ ├── manifest/ │ │ │ │ │ ├── AdaptationSet.java │ │ │ │ │ ├── EventStream.java │ │ │ │ │ ├── Period.java │ │ │ │ │ ├── RangedUri.java │ │ │ │ │ ├── Representation.java │ │ │ │ │ ├── SabrManifest.java │ │ │ │ │ ├── SabrManifestParser.java │ │ │ │ │ ├── SegmentBase.java │ │ │ │ │ ├── SingleSegmentIndex.java │ │ │ │ │ └── UrlTemplate.java │ │ │ │ └── parser/ │ │ │ │ ├── SabrProcessor.java │ │ │ │ ├── SabrStream.java │ │ │ │ ├── adapter/ │ │ │ │ │ ├── SabrFragmentedMp4Adapter.java │ │ │ │ │ └── SabrMatroskaAdapter.java │ │ │ │ ├── exceptions/ │ │ │ │ │ ├── MediaSegmentMismatchError.java │ │ │ │ │ ├── PoTokenError.java │ │ │ │ │ ├── SabrStreamConsumedError.java │ │ │ │ │ └── SabrStreamError.java │ │ │ │ ├── frames/ │ │ │ │ │ ├── AACFrameExtractor.java │ │ │ │ │ ├── AVCFrameExtractor.java │ │ │ │ │ ├── BaseFrameExtractor.java │ │ │ │ │ ├── FrameExtractor.java │ │ │ │ │ ├── OpusFrameExtractor.java │ │ │ │ │ ├── VP9FrameExtractor.java │ │ │ │ │ └── VorbisFrameExtractor.java │ │ │ │ ├── misc/ │ │ │ │ │ ├── EnabledTrackTypes.java │ │ │ │ │ ├── SabrExtractorInput.java │ │ │ │ │ └── Utils.java │ │ │ │ ├── models/ │ │ │ │ │ ├── AudioSelector.java │ │ │ │ │ ├── CaptionSelector.java │ │ │ │ │ ├── ConsumedRange.java │ │ │ │ │ ├── FormatSelector.java │ │ │ │ │ ├── Segment.java │ │ │ │ │ ├── SelectedFormat.java │ │ │ │ │ └── VideoSelector.java │ │ │ │ ├── parts/ │ │ │ │ │ ├── FormatInitializedSabrPart.java │ │ │ │ │ ├── MediaSeekSabrPart.java │ │ │ │ │ ├── MediaSegmentDataSabrPart.java │ │ │ │ │ ├── MediaSegmentEndSabrPart.java │ │ │ │ │ ├── MediaSegmentInitSabrPart.java │ │ │ │ │ ├── PoTokenStatusSabrPart.java │ │ │ │ │ ├── RefreshPlayerResponseSabrPart.java │ │ │ │ │ └── SabrPart.java │ │ │ │ ├── results/ │ │ │ │ │ ├── ProcessFormatInitializationMetadataResult.java │ │ │ │ │ ├── ProcessLiveMetadataResult.java │ │ │ │ │ ├── ProcessMediaEndResult.java │ │ │ │ │ ├── ProcessMediaHeaderResult.java │ │ │ │ │ ├── ProcessMediaResult.java │ │ │ │ │ ├── ProcessSabrSeekResult.java │ │ │ │ │ └── ProcessStreamProtectionStatusResult.java │ │ │ │ └── ump/ │ │ │ │ ├── UMPDecoder.java │ │ │ │ ├── UMPEncoder.java │ │ │ │ ├── UMPInputStream.java │ │ │ │ ├── UMPPart.java │ │ │ │ └── UMPPartId.java │ │ │ └── proto/ │ │ │ └── sabr/ │ │ │ ├── misc/ │ │ │ │ └── common.proto │ │ │ ├── other/ │ │ │ │ └── ump_part_id.proto │ │ │ └── videostreaming/ │ │ │ ├── buffered_range.proto │ │ │ ├── client_abr_state.proto │ │ │ ├── crypto_params.proto │ │ │ ├── format_initialization_metadata.proto │ │ │ ├── innertube_request.proto │ │ │ ├── live_metadata.proto │ │ │ ├── media_capabilities.proto │ │ │ ├── media_header.proto │ │ │ ├── next_request_policy.proto │ │ │ ├── onesie_header.proto │ │ │ ├── onesie_header_type.proto │ │ │ ├── onesie_innertube_request.proto │ │ │ ├── onesie_innertube_response.proto │ │ │ ├── onesie_proxy_status.proto │ │ │ ├── onesie_request.proto │ │ │ ├── playback_cookie.proto │ │ │ ├── reload_player_response.proto │ │ │ ├── sabr_context_sending_policy.proto │ │ │ ├── sabr_context_update.proto │ │ │ ├── sabr_error.proto │ │ │ ├── sabr_redirect.proto │ │ │ ├── sabr_seek.proto │ │ │ ├── stream_protection_status.proto │ │ │ ├── streamer_context.proto │ │ │ ├── time_range.proto │ │ │ └── video_playback_abr_request.proto │ │ ├── smoothstreaming/ │ │ │ ├── README.md │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── source/ │ │ │ │ └── smoothstreaming/ │ │ │ │ ├── DefaultSsChunkSource.java │ │ │ │ ├── SsChunkSource.java │ │ │ │ ├── SsMediaPeriod.java │ │ │ │ ├── SsMediaSource.java │ │ │ │ ├── manifest/ │ │ │ │ │ ├── SsManifest.java │ │ │ │ │ ├── SsManifestParser.java │ │ │ │ │ └── SsUtil.java │ │ │ │ └── offline/ │ │ │ │ └── SsDownloader.java │ │ │ └── test/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── assets/ │ │ │ │ ├── sample_ismc_1 │ │ │ │ └── sample_ismc_2 │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── source/ │ │ │ └── smoothstreaming/ │ │ │ ├── SsMediaPeriodTest.java │ │ │ ├── SsTestUtils.java │ │ │ ├── manifest/ │ │ │ │ ├── SsManifestParserTest.java │ │ │ │ └── SsManifestTest.java │ │ │ └── offline/ │ │ │ ├── DownloadHelperTest.java │ │ │ └── SsDownloaderTest.java │ │ └── ui/ │ │ ├── README.md │ │ ├── build.gradle │ │ └── src/ │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── ui/ │ │ │ │ ├── AspectRatioFrameLayout.java │ │ │ │ ├── DebugTextViewHelper.java │ │ │ │ ├── DefaultTimeBar.java │ │ │ │ ├── DefaultTrackNameProvider.java │ │ │ │ ├── DownloadNotificationHelper.java │ │ │ │ ├── DownloadNotificationUtil.java │ │ │ │ ├── PlaybackControlView.java │ │ │ │ ├── PlayerControlView.java │ │ │ │ ├── PlayerNotificationManager.java │ │ │ │ ├── PlayerView.java │ │ │ │ ├── SimpleExoPlayerView.java │ │ │ │ ├── SubtitlePainter.java │ │ │ │ ├── SubtitleView.java │ │ │ │ ├── TimeBar.java │ │ │ │ ├── TrackNameProvider.java │ │ │ │ ├── TrackSelectionDialogBuilder.java │ │ │ │ ├── TrackSelectionView.java │ │ │ │ └── spherical/ │ │ │ │ ├── CanvasRenderer.java │ │ │ │ ├── GlViewGroup.java │ │ │ │ ├── OrientationListener.java │ │ │ │ ├── PointerRenderer.java │ │ │ │ ├── ProjectionRenderer.java │ │ │ │ ├── SceneRenderer.java │ │ │ │ ├── SingleTapListener.java │ │ │ │ ├── SphericalSurfaceView.java │ │ │ │ └── TouchTracker.java │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ └── exo_edit_mode_logo.xml │ │ │ ├── drawable-anydpi-v21/ │ │ │ │ ├── exo_controls_fullscreen_enter.xml │ │ │ │ ├── exo_controls_fullscreen_exit.xml │ │ │ │ ├── exo_controls_repeat_all.xml │ │ │ │ ├── exo_controls_repeat_off.xml │ │ │ │ ├── exo_controls_repeat_one.xml │ │ │ │ ├── exo_controls_shuffle_off.xml │ │ │ │ ├── exo_controls_shuffle_on.xml │ │ │ │ ├── exo_icon_fastforward.xml │ │ │ │ ├── exo_icon_next.xml │ │ │ │ ├── exo_icon_pause.xml │ │ │ │ ├── exo_icon_play.xml │ │ │ │ ├── exo_icon_previous.xml │ │ │ │ ├── exo_icon_rewind.xml │ │ │ │ └── exo_icon_stop.xml │ │ │ ├── layout/ │ │ │ │ ├── exo_list_divider.xml │ │ │ │ ├── exo_playback_control_view.xml │ │ │ │ ├── exo_player_control_view.xml │ │ │ │ ├── exo_player_view.xml │ │ │ │ ├── exo_simple_player_view.xml │ │ │ │ └── exo_track_selection_dialog.xml │ │ │ ├── values/ │ │ │ │ ├── attrs.xml │ │ │ │ ├── constants.xml │ │ │ │ ├── drawables.xml │ │ │ │ ├── ids.xml │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ │ ├── values-af/ │ │ │ │ └── strings.xml │ │ │ ├── values-am/ │ │ │ │ └── strings.xml │ │ │ ├── values-ar/ │ │ │ │ └── strings.xml │ │ │ ├── values-az/ │ │ │ │ └── strings.xml │ │ │ ├── values-b+sr+Latn/ │ │ │ │ └── strings.xml │ │ │ ├── values-be/ │ │ │ │ └── strings.xml │ │ │ ├── values-bg/ │ │ │ │ └── strings.xml │ │ │ ├── values-bn/ │ │ │ │ └── strings.xml │ │ │ ├── values-bs/ │ │ │ │ └── strings.xml │ │ │ ├── values-ca/ │ │ │ │ └── strings.xml │ │ │ ├── values-cs/ │ │ │ │ └── strings.xml │ │ │ ├── values-da/ │ │ │ │ └── strings.xml │ │ │ ├── values-de/ │ │ │ │ └── strings.xml │ │ │ ├── values-el/ │ │ │ │ └── strings.xml │ │ │ ├── values-en-rAU/ │ │ │ │ └── strings.xml │ │ │ ├── values-en-rGB/ │ │ │ │ └── strings.xml │ │ │ ├── values-en-rIN/ │ │ │ │ └── strings.xml │ │ │ ├── values-es/ │ │ │ │ └── strings.xml │ │ │ ├── values-es-rUS/ │ │ │ │ └── strings.xml │ │ │ ├── values-et/ │ │ │ │ └── strings.xml │ │ │ ├── values-eu/ │ │ │ │ └── strings.xml │ │ │ ├── values-fa/ │ │ │ │ └── strings.xml │ │ │ ├── values-fi/ │ │ │ │ └── strings.xml │ │ │ ├── values-fr/ │ │ │ │ └── strings.xml │ │ │ ├── values-fr-rCA/ │ │ │ │ └── strings.xml │ │ │ ├── values-gl/ │ │ │ │ └── strings.xml │ │ │ ├── values-gu/ │ │ │ │ └── strings.xml │ │ │ ├── values-hi/ │ │ │ │ └── strings.xml │ │ │ ├── values-hr/ │ │ │ │ └── strings.xml │ │ │ ├── values-hu/ │ │ │ │ └── strings.xml │ │ │ ├── values-hy/ │ │ │ │ └── strings.xml │ │ │ ├── values-in/ │ │ │ │ └── strings.xml │ │ │ ├── values-is/ │ │ │ │ └── strings.xml │ │ │ ├── values-it/ │ │ │ │ └── strings.xml │ │ │ ├── values-iw/ │ │ │ │ └── strings.xml │ │ │ ├── values-ja/ │ │ │ │ └── strings.xml │ │ │ ├── values-ka/ │ │ │ │ └── strings.xml │ │ │ ├── values-kk/ │ │ │ │ └── strings.xml │ │ │ ├── values-km/ │ │ │ │ └── strings.xml │ │ │ ├── values-kn/ │ │ │ │ └── strings.xml │ │ │ ├── values-ko/ │ │ │ │ └── strings.xml │ │ │ ├── values-ky/ │ │ │ │ └── strings.xml │ │ │ ├── values-lo/ │ │ │ │ └── strings.xml │ │ │ ├── values-lt/ │ │ │ │ └── strings.xml │ │ │ ├── values-lv/ │ │ │ │ └── strings.xml │ │ │ ├── values-mk/ │ │ │ │ └── strings.xml │ │ │ ├── values-ml/ │ │ │ │ └── strings.xml │ │ │ ├── values-mn/ │ │ │ │ └── strings.xml │ │ │ ├── values-mr/ │ │ │ │ └── strings.xml │ │ │ ├── values-ms/ │ │ │ │ └── strings.xml │ │ │ ├── values-my/ │ │ │ │ └── strings.xml │ │ │ ├── values-nb/ │ │ │ │ └── strings.xml │ │ │ ├── values-ne/ │ │ │ │ └── strings.xml │ │ │ ├── values-nl/ │ │ │ │ └── strings.xml │ │ │ ├── values-pa/ │ │ │ │ └── strings.xml │ │ │ ├── values-pl/ │ │ │ │ └── strings.xml │ │ │ ├── values-pt/ │ │ │ │ └── strings.xml │ │ │ ├── values-pt-rPT/ │ │ │ │ └── strings.xml │ │ │ ├── values-ro/ │ │ │ │ └── strings.xml │ │ │ ├── values-ru/ │ │ │ │ └── strings.xml │ │ │ ├── values-si/ │ │ │ │ └── strings.xml │ │ │ ├── values-sk/ │ │ │ │ └── strings.xml │ │ │ ├── values-sl/ │ │ │ │ └── strings.xml │ │ │ ├── values-sq/ │ │ │ │ └── strings.xml │ │ │ ├── values-sr/ │ │ │ │ └── strings.xml │ │ │ ├── values-sv/ │ │ │ │ └── strings.xml │ │ │ ├── values-sw/ │ │ │ │ └── strings.xml │ │ │ ├── values-ta/ │ │ │ │ └── strings.xml │ │ │ ├── values-te/ │ │ │ │ └── strings.xml │ │ │ ├── values-th/ │ │ │ │ └── strings.xml │ │ │ ├── values-tl/ │ │ │ │ └── strings.xml │ │ │ ├── values-tr/ │ │ │ │ └── strings.xml │ │ │ ├── values-uk/ │ │ │ │ └── strings.xml │ │ │ ├── values-ur/ │ │ │ │ └── strings.xml │ │ │ ├── values-uz/ │ │ │ │ └── strings.xml │ │ │ ├── values-vi/ │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rCN/ │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rHK/ │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rTW/ │ │ │ │ └── strings.xml │ │ │ └── values-zu/ │ │ │ └── strings.xml │ │ └── test/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── google/ │ │ └── android/ │ │ └── exoplayer2/ │ │ └── ui/ │ │ └── spherical/ │ │ ├── CanvasRendererTest.java │ │ └── TouchTrackerTest.java │ ├── playbacktests/ │ │ ├── build.gradle │ │ └── src/ │ │ ├── androidTest/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── com/ │ │ │ └── google/ │ │ │ └── android/ │ │ │ └── exoplayer2/ │ │ │ └── playbacktests/ │ │ │ └── gts/ │ │ │ ├── CommonEncryptionDrmTest.java │ │ │ ├── DashDownloadTest.java │ │ │ ├── DashStreamingTest.java │ │ │ ├── DashTestData.java │ │ │ ├── DashTestRunner.java │ │ │ ├── DashWidevineOfflineTest.java │ │ │ └── EnumerateDecodersTest.java │ │ └── main/ │ │ └── AndroidManifest.xml │ ├── publish.gradle │ ├── settings.gradle │ ├── testutils/ │ │ ├── build.gradle │ │ └── src/ │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── java/ │ │ │ │ └── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── exoplayer2/ │ │ │ │ └── testutil/ │ │ │ │ ├── Action.java │ │ │ │ ├── ActionSchedule.java │ │ │ │ ├── AutoAdvancingFakeClock.java │ │ │ │ ├── DebugRenderersFactory.java │ │ │ │ ├── DecoderCountersUtil.java │ │ │ │ ├── DummyMainThread.java │ │ │ │ ├── Dumper.java │ │ │ │ ├── ExoHostedTest.java │ │ │ │ ├── ExoPlayerTestRunner.java │ │ │ │ ├── ExtractorAsserts.java │ │ │ │ ├── FakeAdaptiveDataSet.java │ │ │ │ ├── FakeAdaptiveMediaPeriod.java │ │ │ │ ├── FakeAdaptiveMediaSource.java │ │ │ │ ├── FakeChunkSource.java │ │ │ │ ├── FakeClock.java │ │ │ │ ├── FakeDataSet.java │ │ │ │ ├── FakeDataSource.java │ │ │ │ ├── FakeExtractorInput.java │ │ │ │ ├── FakeExtractorOutput.java │ │ │ │ ├── FakeMediaPeriod.java │ │ │ │ ├── FakeMediaSource.java │ │ │ │ ├── FakeRenderer.java │ │ │ │ ├── FakeSampleStream.java │ │ │ │ ├── FakeTimeline.java │ │ │ │ ├── FakeTrackOutput.java │ │ │ │ ├── HostActivity.java │ │ │ │ ├── LogcatMetricsLogger.java │ │ │ │ ├── MetricsLogger.java │ │ │ │ └── TestUtil.java │ │ │ └── res/ │ │ │ └── layout/ │ │ │ └── exo_testutils_host_activity.xml │ │ └── test/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── google/ │ │ └── android/ │ │ └── exoplayer2/ │ │ └── testutil/ │ │ ├── FakeAdaptiveDataSetTest.java │ │ ├── FakeClockTest.java │ │ ├── FakeDataSetTest.java │ │ └── FakeDataSourceTest.java │ └── testutils_robolectric/ │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── google/ │ └── android/ │ └── exoplayer2/ │ └── testutil/ │ ├── CacheAsserts.java │ ├── DefaultRenderersFactoryAsserts.java │ ├── FakeMediaChunk.java │ ├── FakeMediaChunkIterator.java │ ├── FakeMediaClockRenderer.java │ ├── FakeShuffleOrder.java │ ├── FakeTrackSelection.java │ ├── FakeTrackSelector.java │ ├── MediaPeriodAsserts.java │ ├── MediaSourceTestRunner.java │ ├── OggTestData.java │ ├── RobolectricUtil.java │ ├── StubExoPlayer.java │ ├── TestDownloadManagerListener.java │ └── TimelineAsserts.java ├── fastlane/ │ └── metadata/ │ └── android/ │ └── en-US/ │ ├── full_description.txt │ └── short_description.txt ├── filepicker-lib/ │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── arte/ │ │ └── programar/ │ │ └── materialfile/ │ │ ├── MaterialFilePicker.java │ │ ├── filter/ │ │ │ ├── CompositeFilter.java │ │ │ ├── FileFilter.java │ │ │ ├── HiddenFilter.java │ │ │ └── PatternFilter.java │ │ ├── ui/ │ │ │ ├── DirectoryAdapter.java │ │ │ ├── DirectoryFragment.java │ │ │ ├── FilePickerActivity.java │ │ │ ├── OnItemClickListener.java │ │ │ └── ThrottleClickListener.java │ │ ├── utils/ │ │ │ ├── FileComparator.java │ │ │ ├── FileTypeUtils.java │ │ │ └── FileUtils.java │ │ └── widget/ │ │ └── EmptyRecyclerView.java │ └── res/ │ ├── drawable/ │ │ ├── bg_clickable.xml │ │ ├── ic_app_apk.xml │ │ ├── ic_app_certificate.xml │ │ ├── ic_app_compress.xml │ │ ├── ic_app_database.xml │ │ ├── ic_app_directory.xml │ │ ├── ic_app_document.xml │ │ ├── ic_app_drawing.xml │ │ ├── ic_app_file.xml │ │ ├── ic_app_image.xml │ │ ├── ic_app_json.xml │ │ ├── ic_app_music.xml │ │ ├── ic_app_pdf.xml │ │ ├── ic_app_presentation.xml │ │ ├── ic_app_spreadsheet.xml │ │ ├── ic_app_video.xml │ │ └── ic_close.xml │ ├── drawable-night/ │ │ └── ic_close.xml │ ├── drawable-v21/ │ │ └── bg_clickable.xml │ ├── layout/ │ │ ├── activity_file_picker.xml │ │ ├── fragment_directory.xml │ │ └── item_file.xml │ ├── values/ │ │ ├── attr.xml │ │ ├── colors.xml │ │ ├── dimen.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-cs/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-night/ │ │ ├── colors.xml │ │ └── styles.xml │ ├── values-night-v21/ │ │ └── styles.xml │ ├── values-night-v23/ │ │ └── styles.xml │ ├── values-night-v27/ │ │ └── styles.xml │ ├── values-night-v29/ │ │ └── styles.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sk/ │ │ └── strings.xml │ ├── values-v19/ │ │ └── styles.xml │ ├── values-v21/ │ │ └── styles.xml │ ├── values-v23/ │ │ └── styles.xml │ ├── values-v27/ │ │ └── styles.xml │ ├── values-v29/ │ │ └── styles.xml │ └── values-zh/ │ └── strings.xml ├── fragment-1.1.0/ │ ├── .gitignore │ ├── LICENSE │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── androidx/ │ └── fragment/ │ └── app/ │ ├── BackStackRecord.java │ ├── BackStackState.java │ ├── DialogFragment.java │ ├── Fragment.java │ ├── FragmentActivity.java │ ├── FragmentContainer.java │ ├── FragmentController.java │ ├── FragmentFactory.java │ ├── FragmentHostCallback.java │ ├── FragmentManager.java │ ├── FragmentManagerImpl.java │ ├── FragmentManagerNonConfig.java │ ├── FragmentManagerState.java │ ├── FragmentManagerViewModel.java │ ├── FragmentPagerAdapter.java │ ├── FragmentState.java │ ├── FragmentStatePagerAdapter.java │ ├── FragmentTabHost.java │ ├── FragmentTransaction.java │ ├── FragmentTransition.java │ ├── FragmentTransitionCompat21.java │ ├── FragmentTransitionImpl.java │ ├── FragmentViewLifecycleOwner.java │ ├── ListFragment.java │ └── SuperNotCalledException.java ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── leanback-1.0.0/ │ ├── .gitignore │ ├── LICENSE │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── androidx/ │ │ └── leanback/ │ │ ├── animation/ │ │ │ ├── LogAccelerateInterpolator.java │ │ │ └── LogDecelerateInterpolator.java │ │ ├── app/ │ │ │ ├── BackgroundFragment.java │ │ │ ├── BackgroundManager.java │ │ │ ├── BaseFragment.java │ │ │ ├── BaseRowFragment.java │ │ │ ├── BaseRowSupportFragment.java │ │ │ ├── BaseSupportFragment.java │ │ │ ├── BrandedFragment.java │ │ │ ├── BrandedSupportFragment.java │ │ │ ├── BrowseFragment.java │ │ │ ├── BrowseSupportFragment.java │ │ │ ├── DetailsBackgroundVideoHelper.java │ │ │ ├── DetailsFragment.java │ │ │ ├── DetailsFragmentBackgroundController.java │ │ │ ├── DetailsSupportFragment.java │ │ │ ├── DetailsSupportFragmentBackgroundController.java │ │ │ ├── ErrorFragment.java │ │ │ ├── ErrorSupportFragment.java │ │ │ ├── FragmentUtil.java │ │ │ ├── GuidedStepFragment.java │ │ │ ├── GuidedStepRootLayout.java │ │ │ ├── GuidedStepSupportFragment.java │ │ │ ├── HeadersFragment.java │ │ │ ├── HeadersSupportFragment.java │ │ │ ├── ListRowDataAdapter.java │ │ │ ├── OnboardingFragment.java │ │ │ ├── OnboardingSupportFragment.java │ │ │ ├── PermissionHelper.java │ │ │ ├── PlaybackFragment.java │ │ │ ├── PlaybackFragmentGlueHost.java │ │ │ ├── PlaybackSupportFragment.java │ │ │ ├── PlaybackSupportFragmentGlueHost.java │ │ │ ├── ProgressBarManager.java │ │ │ ├── RowsFragment.java │ │ │ ├── RowsSupportFragment.java │ │ │ ├── SearchFragment.java │ │ │ ├── SearchSupportFragment.java │ │ │ ├── VerticalGridFragment.java │ │ │ ├── VerticalGridSupportFragment.java │ │ │ ├── VideoFragment.java │ │ │ ├── VideoFragmentGlueHost.java │ │ │ ├── VideoSupportFragment.java │ │ │ ├── VideoSupportFragmentGlueHost.java │ │ │ └── package-info.java │ │ ├── database/ │ │ │ └── CursorMapper.java │ │ ├── graphics/ │ │ │ ├── BoundsRule.java │ │ │ ├── ColorFilterCache.java │ │ │ ├── ColorFilterDimmer.java │ │ │ ├── ColorOverlayDimmer.java │ │ │ ├── CompositeDrawable.java │ │ │ └── FitWidthBitmapDrawable.java │ │ ├── media/ │ │ │ ├── MediaControllerAdapter.java │ │ │ ├── MediaControllerGlue.java │ │ │ ├── MediaPlayerAdapter.java │ │ │ ├── MediaPlayerGlue.java │ │ │ ├── PlaybackBannerControlGlue.java │ │ │ ├── PlaybackBaseControlGlue.java │ │ │ ├── PlaybackControlGlue.java │ │ │ ├── PlaybackGlue.java │ │ │ ├── PlaybackGlueHost.java │ │ │ ├── PlaybackTransportControlGlue.java │ │ │ ├── PlayerAdapter.java │ │ │ └── SurfaceHolderGlueHost.java │ │ ├── package-info.java │ │ ├── system/ │ │ │ └── Settings.java │ │ ├── transition/ │ │ │ ├── CustomChangeBounds.java │ │ │ ├── FadeAndShortSlide.java │ │ │ ├── LeanbackTransitionHelper.java │ │ │ ├── ParallaxTransition.java │ │ │ ├── Scale.java │ │ │ ├── SlideKitkat.java │ │ │ ├── SlideNoPropagation.java │ │ │ ├── TransitionEpicenterCallback.java │ │ │ ├── TransitionHelper.java │ │ │ ├── TransitionListener.java │ │ │ └── TranslationAnimationCreator.java │ │ ├── util/ │ │ │ ├── MathUtil.java │ │ │ └── StateMachine.java │ │ └── widget/ │ │ ├── AbstractDetailsDescriptionPresenter.java │ │ ├── AbstractMediaItemPresenter.java │ │ ├── AbstractMediaListHeaderPresenter.java │ │ ├── Action.java │ │ ├── ActionPresenterSelector.java │ │ ├── ArrayObjectAdapter.java │ │ ├── BackgroundHelper.java │ │ ├── BaseCardView.java │ │ ├── BaseGridView.java │ │ ├── BaseOnItemViewClickedListener.java │ │ ├── BaseOnItemViewSelectedListener.java │ │ ├── BrowseFrameLayout.java │ │ ├── BrowseRowsFrameLayout.java │ │ ├── CheckableImageView.java │ │ ├── ClassPresenterSelector.java │ │ ├── ControlBar.java │ │ ├── ControlBarPresenter.java │ │ ├── ControlButtonPresenterSelector.java │ │ ├── CursorObjectAdapter.java │ │ ├── DetailsOverviewLogoPresenter.java │ │ ├── DetailsOverviewRow.java │ │ ├── DetailsOverviewRowPresenter.java │ │ ├── DetailsOverviewSharedElementHelper.java │ │ ├── DetailsParallax.java │ │ ├── DetailsParallaxDrawable.java │ │ ├── DiffCallback.java │ │ ├── DividerPresenter.java │ │ ├── DividerRow.java │ │ ├── FacetProvider.java │ │ ├── FacetProviderAdapter.java │ │ ├── FocusHighlight.java │ │ ├── FocusHighlightHandler.java │ │ ├── FocusHighlightHelper.java │ │ ├── ForegroundHelper.java │ │ ├── FragmentAnimationProvider.java │ │ ├── FullWidthDetailsOverviewRowPresenter.java │ │ ├── FullWidthDetailsOverviewSharedElementHelper.java │ │ ├── Grid.java │ │ ├── GridLayoutManager.java │ │ ├── GuidanceStylingRelativeLayout.java │ │ ├── GuidanceStylist.java │ │ ├── GuidedAction.java │ │ ├── GuidedActionAdapter.java │ │ ├── GuidedActionAdapterGroup.java │ │ ├── GuidedActionAutofillSupport.java │ │ ├── GuidedActionDiffCallback.java │ │ ├── GuidedActionEditText.java │ │ ├── GuidedActionItemContainer.java │ │ ├── GuidedActionsRelativeLayout.java │ │ ├── GuidedActionsStylist.java │ │ ├── GuidedDatePickerAction.java │ │ ├── HeaderItem.java │ │ ├── HorizontalGridView.java │ │ ├── HorizontalHoverCardSwitcher.java │ │ ├── ImageCardView.java │ │ ├── ImeKeyMonitor.java │ │ ├── InvisibleRowPresenter.java │ │ ├── ItemAlignment.java │ │ ├── ItemAlignmentFacet.java │ │ ├── ItemAlignmentFacetHelper.java │ │ ├── ItemBridgeAdapter.java │ │ ├── ItemBridgeAdapterShadowOverlayWrapper.java │ │ ├── ListRow.java │ │ ├── ListRowHoverCardView.java │ │ ├── ListRowPresenter.java │ │ ├── ListRowView.java │ │ ├── MediaItemActionPresenter.java │ │ ├── MediaNowPlayingView.java │ │ ├── MediaRowFocusView.java │ │ ├── MultiActionsProvider.java │ │ ├── NonOverlappingFrameLayout.java │ │ ├── NonOverlappingLinearLayout.java │ │ ├── NonOverlappingLinearLayoutWithForeground.java │ │ ├── NonOverlappingRelativeLayout.java │ │ ├── NonOverlappingView.java │ │ ├── ObjectAdapter.java │ │ ├── OnActionClickedListener.java │ │ ├── OnChildLaidOutListener.java │ │ ├── OnChildSelectedListener.java │ │ ├── OnChildViewHolderSelectedListener.java │ │ ├── OnItemViewClickedListener.java │ │ ├── OnItemViewSelectedListener.java │ │ ├── PageRow.java │ │ ├── PagingIndicator.java │ │ ├── Parallax.java │ │ ├── ParallaxEffect.java │ │ ├── ParallaxTarget.java │ │ ├── PersistentFocusWrapper.java │ │ ├── PlaybackControlsPresenter.java │ │ ├── PlaybackControlsRow.java │ │ ├── PlaybackControlsRowPresenter.java │ │ ├── PlaybackControlsRowView.java │ │ ├── PlaybackRowPresenter.java │ │ ├── PlaybackSeekDataProvider.java │ │ ├── PlaybackSeekUi.java │ │ ├── PlaybackTransportRowPresenter.java │ │ ├── PlaybackTransportRowView.java │ │ ├── Presenter.java │ │ ├── PresenterSelector.java │ │ ├── PresenterSwitcher.java │ │ ├── RecyclerViewParallax.java │ │ ├── ResizingTextView.java │ │ ├── RoundedRectHelper.java │ │ ├── RoundedRectHelperApi21.java │ │ ├── Row.java │ │ ├── RowContainerView.java │ │ ├── RowHeaderPresenter.java │ │ ├── RowHeaderView.java │ │ ├── RowPresenter.java │ │ ├── ScaleFrameLayout.java │ │ ├── SearchBar.java │ │ ├── SearchEditText.java │ │ ├── SearchOrbView.java │ │ ├── SectionRow.java │ │ ├── SeekBar.java │ │ ├── ShadowHelper.java │ │ ├── ShadowHelperApi21.java │ │ ├── ShadowOverlayContainer.java │ │ ├── ShadowOverlayHelper.java │ │ ├── SinglePresenterSelector.java │ │ ├── SingleRow.java │ │ ├── SparseArrayObjectAdapter.java │ │ ├── SpeechOrbView.java │ │ ├── SpeechRecognitionCallback.java │ │ ├── StaggeredGrid.java │ │ ├── StaggeredGridDefault.java │ │ ├── StaticShadowHelper.java │ │ ├── StreamingTextView.java │ │ ├── ThumbsBar.java │ │ ├── TitleHelper.java │ │ ├── TitleView.java │ │ ├── TitleViewAdapter.java │ │ ├── Util.java │ │ ├── VerticalGridPresenter.java │ │ ├── VerticalGridView.java │ │ ├── VideoSurfaceView.java │ │ ├── ViewHolderTask.java │ │ ├── ViewsStateBundle.java │ │ ├── Visibility.java │ │ ├── WindowAlignment.java │ │ ├── package-info.java │ │ └── picker/ │ │ ├── DatePicker.java │ │ ├── Picker.java │ │ ├── PickerColumn.java │ │ ├── PickerUtility.java │ │ └── TimePicker.java │ └── res/ │ ├── anim/ │ │ ├── lb_decelerator_2.xml │ │ └── lb_decelerator_4.xml │ ├── animator/ │ │ ├── lb_guidedactions_item_pressed.xml │ │ ├── lb_guidedactions_item_unpressed.xml │ │ ├── lb_guidedstep_slide_down.xml │ │ ├── lb_guidedstep_slide_up.xml │ │ ├── lb_onboarding_description_enter.xml │ │ ├── lb_onboarding_logo_enter.xml │ │ ├── lb_onboarding_logo_exit.xml │ │ ├── lb_onboarding_page_indicator_enter.xml │ │ ├── lb_onboarding_page_indicator_fade_in.xml │ │ ├── lb_onboarding_page_indicator_fade_out.xml │ │ ├── lb_onboarding_start_button_fade_in.xml │ │ ├── lb_onboarding_start_button_fade_out.xml │ │ ├── lb_onboarding_title_enter.xml │ │ ├── lb_playback_bg_fade_in.xml │ │ ├── lb_playback_bg_fade_out.xml │ │ ├── lb_playback_controls_fade_in.xml │ │ ├── lb_playback_controls_fade_out.xml │ │ ├── lb_playback_description_fade_in.xml │ │ ├── lb_playback_description_fade_out.xml │ │ ├── lb_playback_rows_fade_in.xml │ │ └── lb_playback_rows_fade_out.xml │ ├── animator-v21/ │ │ ├── lb_onboarding_description_enter.xml │ │ ├── lb_onboarding_logo_enter.xml │ │ ├── lb_onboarding_logo_exit.xml │ │ ├── lb_onboarding_page_indicator_enter.xml │ │ ├── lb_onboarding_title_enter.xml │ │ ├── lb_playback_bg_fade_in.xml │ │ ├── lb_playback_bg_fade_out.xml │ │ └── lb_playback_description_fade_out.xml │ ├── drawable/ │ │ ├── lb_background.xml │ │ ├── lb_card_foreground.xml │ │ ├── lb_control_button_primary.xml │ │ ├── lb_control_button_secondary.xml │ │ ├── lb_headers_right_fading.xml │ │ ├── lb_onboarding_start_button_background.xml │ │ ├── lb_playback_now_playing_bar.xml │ │ ├── lb_playback_progress_bar.xml │ │ ├── lb_search_orb.xml │ │ └── lb_speech_orb.xml │ ├── drawable-v21/ │ │ ├── lb_action_bg.xml │ │ ├── lb_card_foreground.xml │ │ ├── lb_control_button_primary.xml │ │ ├── lb_control_button_secondary.xml │ │ └── lb_selectable_item_rounded_rect.xml │ ├── layout/ │ │ ├── lb_action_1_line.xml │ │ ├── lb_action_2_lines.xml │ │ ├── lb_background_window.xml │ │ ├── lb_browse_fragment.xml │ │ ├── lb_browse_title.xml │ │ ├── lb_control_bar.xml │ │ ├── lb_control_button_primary.xml │ │ ├── lb_control_button_secondary.xml │ │ ├── lb_details_description.xml │ │ ├── lb_details_fragment.xml │ │ ├── lb_details_overview.xml │ │ ├── lb_divider.xml │ │ ├── lb_error_fragment.xml │ │ ├── lb_fullwidth_details_overview.xml │ │ ├── lb_fullwidth_details_overview_logo.xml │ │ ├── lb_guidance.xml │ │ ├── lb_guidedactions.xml │ │ ├── lb_guidedactions_datepicker_item.xml │ │ ├── lb_guidedactions_item.xml │ │ ├── lb_guidedbuttonactions.xml │ │ ├── lb_guidedstep_background.xml │ │ ├── lb_guidedstep_fragment.xml │ │ ├── lb_header.xml │ │ ├── lb_headers_fragment.xml │ │ ├── lb_image_card_view.xml │ │ ├── lb_image_card_view_themed_badge_left.xml │ │ ├── lb_image_card_view_themed_badge_right.xml │ │ ├── lb_image_card_view_themed_content.xml │ │ ├── lb_image_card_view_themed_title.xml │ │ ├── lb_list_row.xml │ │ ├── lb_list_row_hovercard.xml │ │ ├── lb_media_item_number_view_flipper.xml │ │ ├── lb_media_list_header.xml │ │ ├── lb_onboarding_fragment.xml │ │ ├── lb_picker.xml │ │ ├── lb_picker_column.xml │ │ ├── lb_picker_item.xml │ │ ├── lb_picker_separator.xml │ │ ├── lb_playback_controls.xml │ │ ├── lb_playback_controls_row.xml │ │ ├── lb_playback_fragment.xml │ │ ├── lb_playback_now_playing_bars.xml │ │ ├── lb_playback_transport_controls.xml │ │ ├── lb_playback_transport_controls_row.xml │ │ ├── lb_row_container.xml │ │ ├── lb_row_header.xml │ │ ├── lb_row_media_item.xml │ │ ├── lb_row_media_item_action.xml │ │ ├── lb_rows_fragment.xml │ │ ├── lb_search_bar.xml │ │ ├── lb_search_fragment.xml │ │ ├── lb_search_orb.xml │ │ ├── lb_section_header.xml │ │ ├── lb_shadow.xml │ │ ├── lb_speech_orb.xml │ │ ├── lb_title_view.xml │ │ ├── lb_vertical_grid.xml │ │ ├── lb_vertical_grid_fragment.xml │ │ ├── lb_video_surface.xml │ │ └── video_surface_fragment.xml │ ├── raw/ │ │ ├── lb_voice_failure.ogg │ │ ├── lb_voice_no_input.ogg │ │ ├── lb_voice_open.ogg │ │ └── lb_voice_success.ogg │ ├── transition-v19/ │ │ ├── lb_browse_headers_in.xml │ │ └── lb_browse_headers_out.xml │ ├── transition-v21/ │ │ ├── lb_browse_enter_transition.xml │ │ ├── lb_browse_entrance_transition.xml │ │ ├── lb_browse_headers_in.xml │ │ ├── lb_browse_headers_out.xml │ │ ├── lb_browse_return_transition.xml │ │ ├── lb_details_enter_transition.xml │ │ ├── lb_details_return_transition.xml │ │ ├── lb_enter_transition.xml │ │ ├── lb_guidedstep_activity_enter.xml │ │ ├── lb_guidedstep_activity_enter_bottom.xml │ │ ├── lb_return_transition.xml │ │ ├── lb_shared_element_enter_transition.xml │ │ ├── lb_shared_element_return_transition.xml │ │ ├── lb_title_in.xml │ │ ├── lb_title_out.xml │ │ ├── lb_vertical_grid_enter_transition.xml │ │ ├── lb_vertical_grid_entrance_transition.xml │ │ └── lb_vertical_grid_return_transition.xml │ ├── values/ │ │ └── values.xml │ ├── values-af/ │ │ └── values-af.xml │ ├── values-am/ │ │ └── values-am.xml │ ├── values-ar/ │ │ └── values-ar.xml │ ├── values-as/ │ │ └── values-as.xml │ ├── values-az/ │ │ └── values-az.xml │ ├── values-b+sr+Latn/ │ │ └── values-b+sr+Latn.xml │ ├── values-be/ │ │ └── values-be.xml │ ├── values-bg/ │ │ └── values-bg.xml │ ├── values-bn/ │ │ └── values-bn.xml │ ├── values-bs/ │ │ └── values-bs.xml │ ├── values-ca/ │ │ └── values-ca.xml │ ├── values-cs/ │ │ └── values-cs.xml │ ├── values-da/ │ │ └── values-da.xml │ ├── values-de/ │ │ └── values-de.xml │ ├── values-el/ │ │ └── values-el.xml │ ├── values-en-rAU/ │ │ └── values-en-rAU.xml │ ├── values-en-rCA/ │ │ └── values-en-rCA.xml │ ├── values-en-rGB/ │ │ └── values-en-rGB.xml │ ├── values-en-rIN/ │ │ └── values-en-rIN.xml │ ├── values-en-rXC/ │ │ └── values-en-rXC.xml │ ├── values-es/ │ │ └── values-es.xml │ ├── values-es-rUS/ │ │ └── values-es-rUS.xml │ ├── values-et/ │ │ └── values-et.xml │ ├── values-eu/ │ │ └── values-eu.xml │ ├── values-fa/ │ │ └── values-fa.xml │ ├── values-fi/ │ │ └── values-fi.xml │ ├── values-fr/ │ │ └── values-fr.xml │ ├── values-fr-rCA/ │ │ └── values-fr-rCA.xml │ ├── values-gl/ │ │ └── values-gl.xml │ ├── values-gu/ │ │ └── values-gu.xml │ ├── values-hi/ │ │ └── values-hi.xml │ ├── values-hr/ │ │ └── values-hr.xml │ ├── values-hu/ │ │ └── values-hu.xml │ ├── values-hy/ │ │ └── values-hy.xml │ ├── values-in/ │ │ └── values-in.xml │ ├── values-is/ │ │ └── values-is.xml │ ├── values-it/ │ │ └── values-it.xml │ ├── values-iw/ │ │ └── values-iw.xml │ ├── values-ja/ │ │ └── values-ja.xml │ ├── values-ka/ │ │ └── values-ka.xml │ ├── values-kk/ │ │ └── values-kk.xml │ ├── values-km/ │ │ └── values-km.xml │ ├── values-kn/ │ │ └── values-kn.xml │ ├── values-ko/ │ │ └── values-ko.xml │ ├── values-ky/ │ │ └── values-ky.xml │ ├── values-ldrtl-v17/ │ │ └── values-ldrtl-v17.xml │ ├── values-lo/ │ │ └── values-lo.xml │ ├── values-lt/ │ │ └── values-lt.xml │ ├── values-lv/ │ │ └── values-lv.xml │ ├── values-mk/ │ │ └── values-mk.xml │ ├── values-ml/ │ │ └── values-ml.xml │ ├── values-mn/ │ │ └── values-mn.xml │ ├── values-mr/ │ │ └── values-mr.xml │ ├── values-ms/ │ │ └── values-ms.xml │ ├── values-my/ │ │ └── values-my.xml │ ├── values-nb/ │ │ └── values-nb.xml │ ├── values-ne/ │ │ └── values-ne.xml │ ├── values-nl/ │ │ └── values-nl.xml │ ├── values-or/ │ │ └── values-or.xml │ ├── values-pa/ │ │ └── values-pa.xml │ ├── values-pl/ │ │ └── values-pl.xml │ ├── values-pt/ │ │ └── values-pt.xml │ ├── values-pt-rBR/ │ │ └── values-pt-rBR.xml │ ├── values-pt-rPT/ │ │ └── values-pt-rPT.xml │ ├── values-ro/ │ │ └── values-ro.xml │ ├── values-ru/ │ │ └── values-ru.xml │ ├── values-si/ │ │ └── values-si.xml │ ├── values-sk/ │ │ └── values-sk.xml │ ├── values-sl/ │ │ └── values-sl.xml │ ├── values-sq/ │ │ └── values-sq.xml │ ├── values-sr/ │ │ └── values-sr.xml │ ├── values-sv/ │ │ └── values-sv.xml │ ├── values-sw/ │ │ └── values-sw.xml │ ├── values-ta/ │ │ └── values-ta.xml │ ├── values-te/ │ │ └── values-te.xml │ ├── values-th/ │ │ └── values-th.xml │ ├── values-tl/ │ │ └── values-tl.xml │ ├── values-tr/ │ │ └── values-tr.xml │ ├── values-uk/ │ │ └── values-uk.xml │ ├── values-ur/ │ │ └── values-ur.xml │ ├── values-uz/ │ │ └── values-uz.xml │ ├── values-v18/ │ │ └── values-v18.xml │ ├── values-v19/ │ │ └── values-v19.xml │ ├── values-v21/ │ │ └── values-v21.xml │ ├── values-v22/ │ │ └── values-v22.xml │ ├── values-vi/ │ │ └── values-vi.xml │ ├── values-zh-rCN/ │ │ └── values-zh-rCN.xml │ ├── values-zh-rHK/ │ │ └── values-zh-rHK.xml │ ├── values-zh-rTW/ │ │ └── values-zh-rTW.xml │ └── values-zu/ │ └── values-zu.xml ├── leanbackassistant/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── liskovsoft/ │ │ │ └── leanbackassistant/ │ │ │ ├── channels/ │ │ │ │ ├── ChannelsProvider.java │ │ │ │ ├── UpdateChannelsJobService.java │ │ │ │ ├── UpdateChannelsReceiver.java │ │ │ │ ├── UpdateChannelsTask.java │ │ │ │ └── UpdateChannelsWorker.java │ │ │ ├── media/ │ │ │ │ ├── Clip.java │ │ │ │ ├── ClipService.java │ │ │ │ ├── ClipServiceCached.java │ │ │ │ ├── Playlist.java │ │ │ │ └── scheduler/ │ │ │ │ └── ClipData.java │ │ │ ├── recommendations/ │ │ │ │ ├── RecommendationBuilder.java │ │ │ │ └── RecommendationsProvider.java │ │ │ ├── search/ │ │ │ │ ├── MockDatabase.java │ │ │ │ ├── SearchableActivity.java │ │ │ │ └── VideoContentProvider.java │ │ │ └── utils/ │ │ │ └── AppUtil.java │ │ └── res/ │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ └── xml/ │ │ └── searchable.xml │ ├── stbeta/ │ │ ├── AndroidManifest.xml │ │ └── res/ │ │ ├── values/ │ │ │ └── strings.xml │ │ └── xml/ │ │ └── searchable.xml │ ├── stfdroid/ │ │ ├── AndroidManifest.xml │ │ └── res/ │ │ ├── values/ │ │ │ └── strings.xml │ │ └── xml/ │ │ └── searchable.xml │ └── ststable/ │ ├── AndroidManifest.xml │ └── res/ │ ├── values/ │ │ └── strings.xml │ └── xml/ │ └── searchable.xml ├── settings.gradle ├── slidableactivity/ │ ├── .gitignore │ ├── LICENSE.md │ ├── README.md │ ├── build.gradle │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── r0adkll/ │ │ │ └── slidr/ │ │ │ ├── ColorPanelSlideListener.java │ │ │ ├── ConfigPanelSlideListener.java │ │ │ ├── FragmentPanelSlideListener.java │ │ │ ├── Slidr.java │ │ │ ├── model/ │ │ │ │ ├── SlidrConfig.java │ │ │ │ ├── SlidrInterface.java │ │ │ │ ├── SlidrListener.java │ │ │ │ ├── SlidrListenerAdapter.java │ │ │ │ └── SlidrPosition.java │ │ │ ├── util/ │ │ │ │ └── ViewDragHelper.java │ │ │ └── widget/ │ │ │ ├── ScrimRenderer.java │ │ │ └── SliderPanel.java │ │ └── res/ │ │ └── values/ │ │ └── ids.xml │ └── test/ │ └── java/ │ └── com/ │ └── r0adkll/ │ └── slidr/ │ └── widget/ │ ├── ScrimRendererTest.java │ └── SliderPanelTest.java └── smarttubetv/ ├── build.gradle ├── google-services.json ├── multidex-keep.pro ├── proguard-rules.pro └── src/ ├── main/ │ ├── AndroidManifest.xml │ ├── assets/ │ │ └── common.properties │ ├── java/ │ │ └── com/ │ │ └── liskovsoft/ │ │ └── smartyoutubetv2/ │ │ └── tv/ │ │ ├── adapter/ │ │ │ ├── DeferredVideoGroupObjectAdapter.java │ │ │ ├── HeaderVideoGroupObjectAdapter.java │ │ │ ├── VideoGroupObjectAdapter.java │ │ │ └── vineyard/ │ │ │ ├── PaginationAdapter.java │ │ │ └── TagAdapter.java │ │ ├── launchers/ │ │ │ ├── ChannelsLauncherActivity.java │ │ │ ├── GamesLauncherActivity.java │ │ │ ├── HistoryLauncherActivity.java │ │ │ ├── HomeLauncherActivity.java │ │ │ ├── MusicLauncherActivity.java │ │ │ ├── NewsLauncherActivity.java │ │ │ ├── PlaylistsLauncherActivity.java │ │ │ ├── SearchLauncherActivity.java │ │ │ └── SubscriptionsLauncherActivity.java │ │ ├── presenter/ │ │ │ ├── ChannelCardPresenter.java │ │ │ ├── ChannelHeaderPresenter.java │ │ │ ├── CustomListRowPresenter.java │ │ │ ├── CustomVerticalGridPresenter.java │ │ │ ├── DetailsDescriptionPresenter.java │ │ │ ├── GridItemPresenter.java │ │ │ ├── IconHeaderItemPresenter.java │ │ │ ├── SearchFieldPresenter.java │ │ │ ├── SettingsCardPresenter.java │ │ │ ├── ShortsCardPresenter.java │ │ │ ├── TinyCardPresenter.java │ │ │ ├── VideoCardPresenter.java │ │ │ ├── base/ │ │ │ │ ├── LongClickPresenter.java │ │ │ │ └── OnItemLongPressedListener.java │ │ │ └── vineyard/ │ │ │ ├── IconItemPresenter.java │ │ │ ├── LoadingPresenter.java │ │ │ └── TagPresenter.java │ │ ├── ui/ │ │ │ ├── adddevice/ │ │ │ │ ├── AddDeviceActivity.java │ │ │ │ └── AddDeviceFragment.java │ │ │ ├── browse/ │ │ │ │ ├── BrowseActivity.java │ │ │ │ ├── BrowseFragment.java │ │ │ │ ├── BrowseSectionFragmentFactory.java │ │ │ │ ├── CategoryFragmentFactory.java.old │ │ │ │ ├── SectionHeaderItem.java │ │ │ │ ├── dialog/ │ │ │ │ │ └── ErrorDialogFragment.java │ │ │ │ ├── interfaces/ │ │ │ │ │ ├── Section.java │ │ │ │ │ ├── SettingsSection.java │ │ │ │ │ └── VideoSection.java │ │ │ │ ├── settings/ │ │ │ │ │ └── SettingsGridFragment.java │ │ │ │ └── video/ │ │ │ │ ├── GridFragmentHelper.java │ │ │ │ ├── MultiVideoGridFragment.java │ │ │ │ ├── MultipleRowsFragment.java │ │ │ │ ├── ShortsGridFragment.java │ │ │ │ ├── VideoGridFragment.java │ │ │ │ └── VideoRowsFragment.java │ │ │ ├── channel/ │ │ │ │ ├── ChannelActivity.java │ │ │ │ └── ChannelFragment.java │ │ │ ├── channeluploads/ │ │ │ │ ├── ChannelUploadsActivity.java │ │ │ │ └── ChannelUploadsFragment.java │ │ │ ├── common/ │ │ │ │ ├── DisplayUtils.java │ │ │ │ ├── LeanbackActivity.java │ │ │ │ ├── UriBackgroundManager.java │ │ │ │ └── keyhandler/ │ │ │ │ ├── DoubleBackManager.java │ │ │ │ ├── DoubleBackManager2.java │ │ │ │ └── LongClickManager.java │ │ │ ├── dialogs/ │ │ │ │ ├── AppDialogActivity.java │ │ │ │ ├── AppDialogFragment.java │ │ │ │ ├── AppPreferenceManager.java │ │ │ │ └── other/ │ │ │ │ ├── ChatPreference.java │ │ │ │ ├── ChatPreferenceDialogFragment.java │ │ │ │ ├── CommentsPreference.java │ │ │ │ ├── CommentsPreferenceDialogFragment.java │ │ │ │ ├── RadioListPreferenceDialogFragment.java │ │ │ │ ├── StringListPreference.java │ │ │ │ └── StringListPreferenceDialogFragment.java │ │ │ ├── main/ │ │ │ │ ├── MainApplication.java │ │ │ │ └── SplashActivity.java │ │ │ ├── mod/ │ │ │ │ ├── clickable/ │ │ │ │ │ ├── FindAddress.java │ │ │ │ │ ├── LinkifyCompat.java │ │ │ │ │ ├── PatternsCompat.java │ │ │ │ │ └── TextViewLinkHandler.java │ │ │ │ ├── fragments/ │ │ │ │ │ ├── ErrorSupportFragment.java │ │ │ │ │ ├── GridFragment.java │ │ │ │ │ └── MultiGridFragment.java │ │ │ │ └── leanback/ │ │ │ │ ├── headers/ │ │ │ │ │ └── ExtendedHeadersSupportFragment.java │ │ │ │ ├── misc/ │ │ │ │ │ ├── CustomBrowseSupportFragment.java │ │ │ │ │ ├── ProgressBarManager.java │ │ │ │ │ └── SeekBar.java │ │ │ │ ├── playerglue/ │ │ │ │ │ ├── MathUtil.java │ │ │ │ │ ├── framedrops/ │ │ │ │ │ │ ├── PlaybackBaseControlGlue.java │ │ │ │ │ │ └── PlaybackTransportControlGlue.java │ │ │ │ │ ├── seekpreview/ │ │ │ │ │ │ └── ThumbsBar.java │ │ │ │ │ ├── tooltips/ │ │ │ │ │ │ ├── ControlButtonPresenterSelector.java │ │ │ │ │ │ ├── TooltipCompatHandler.java │ │ │ │ │ │ └── TooltipPopup.java │ │ │ │ │ └── tweaks/ │ │ │ │ │ ├── ControlBar.java │ │ │ │ │ ├── ControlBarPresenter.java │ │ │ │ │ ├── MaxControlsVideoPlayerGlue.java │ │ │ │ │ ├── PlaybackControlsPresenter.java │ │ │ │ │ ├── PlaybackTransportRowPresenter.java │ │ │ │ │ └── PlaybackTransportRowView.java │ │ │ │ ├── preference/ │ │ │ │ │ ├── LeanbackListPreferenceDialogFragment.java │ │ │ │ │ ├── LeanbackPreferenceDialogFragment.java │ │ │ │ │ └── LeanbackPreferenceFragmentTransitionHelperApi21.java │ │ │ │ ├── search/ │ │ │ │ │ ├── NoScrollRowsSupportFragment.java │ │ │ │ │ └── SearchSupportFragment.java │ │ │ │ ├── transition/ │ │ │ │ │ ├── FadeAndShortSlide.java │ │ │ │ │ └── TranslationAnimationCreator.java │ │ │ │ └── widget/ │ │ │ │ └── OnActionLongClickedListener.java │ │ │ ├── playback/ │ │ │ │ ├── PlaybackActivity.java │ │ │ │ ├── PlaybackFragment.java │ │ │ │ ├── actions/ │ │ │ │ │ ├── AFRAction.java │ │ │ │ │ ├── ActionHelpers.java │ │ │ │ │ ├── ChannelAction.java │ │ │ │ │ ├── ChatAction.java │ │ │ │ │ ├── ClosedCaptioningAction.java │ │ │ │ │ ├── ContentBlockAction.java │ │ │ │ │ ├── FlipAction.java │ │ │ │ │ ├── HighQualityAction.java │ │ │ │ │ ├── PaddingAction.java │ │ │ │ │ ├── PipAction.java │ │ │ │ │ ├── PlaybackModeAction.java │ │ │ │ │ ├── PlaybackQueueAction.java │ │ │ │ │ ├── PlaylistAddAction.java │ │ │ │ │ ├── RotateAction.java │ │ │ │ │ ├── ScreenDimmingAction.java │ │ │ │ │ ├── SearchAction.java │ │ │ │ │ ├── SeekIntervalAction.java │ │ │ │ │ ├── ShareAction.java │ │ │ │ │ ├── SoundOffAction.java │ │ │ │ │ ├── SubscribeAction.java │ │ │ │ │ ├── ThumbsDownAction.java │ │ │ │ │ ├── ThumbsUpAction.java │ │ │ │ │ ├── TwoStateAction.java │ │ │ │ │ ├── VideoInfoAction.java │ │ │ │ │ ├── VideoSpeedAction.java │ │ │ │ │ ├── VideoStatsAction.java │ │ │ │ │ └── VideoZoomAction.java │ │ │ │ ├── mod/ │ │ │ │ │ ├── EventsOverridePlaybackFragment.java │ │ │ │ │ ├── SeekModePlaybackFragment.java │ │ │ │ │ └── surface/ │ │ │ │ │ ├── SurfacePlaybackFragment.java │ │ │ │ │ ├── SurfacePlaybackFragmentGlueHost.java │ │ │ │ │ ├── SurfaceViewWrapper.java │ │ │ │ │ ├── SurfaceWrapper.java │ │ │ │ │ ├── TextureViewSurfaceHolder.java │ │ │ │ │ └── TextureViewWrapper.java │ │ │ │ ├── other/ │ │ │ │ │ ├── BackboneQueueNavigator.java │ │ │ │ │ └── VideoPlayerGlue.java │ │ │ │ └── previewtimebar/ │ │ │ │ ├── GlideThumbnailTransformation.java │ │ │ │ ├── StoryboardManager.java │ │ │ │ └── StoryboardSeekDataProvider.java │ │ │ ├── search/ │ │ │ │ └── tags/ │ │ │ │ ├── SearchTagsActivity.java │ │ │ │ ├── SearchTagsFragment.java │ │ │ │ └── vineyard/ │ │ │ │ └── SearchTagsFragmentBase.java │ │ │ ├── signin/ │ │ │ │ ├── SignInActivity.java │ │ │ │ └── SignInFragment.java │ │ │ ├── webbrowser/ │ │ │ │ ├── WebBrowserActivity.java │ │ │ │ └── WebBrowserFragment.java │ │ │ └── widgets/ │ │ │ ├── browse/ │ │ │ │ └── NavigateTitleView.java │ │ │ ├── chat/ │ │ │ │ ├── ChatItemAuthor.java │ │ │ │ ├── ChatItemMessage.java │ │ │ │ └── LiveChatView.java │ │ │ ├── complexcardview/ │ │ │ │ ├── ComplexImageCardView.java │ │ │ │ └── ComplexImageView.java │ │ │ ├── embedplayer/ │ │ │ │ └── EmbedPlayerView.java │ │ │ ├── focus/ │ │ │ │ └── FocusFixBrowseFrameLayout.java │ │ │ ├── layout/ │ │ │ │ ├── TouchHorizontalGridView.java │ │ │ │ └── TouchVerticalGridView.java │ │ │ ├── marqueetextview/ │ │ │ │ ├── HeaderMarqueeTextView.java │ │ │ │ ├── MarqueeTextView.java │ │ │ │ └── TitleMarqueeTextView.java │ │ │ ├── marqueetextviewcompat/ │ │ │ │ ├── HeaderMarqueeTextViewCompat.java │ │ │ │ ├── MarqueeTextViewCompat.java │ │ │ │ └── TitleMarqueeTextViewCompat.java │ │ │ ├── search/ │ │ │ │ ├── LongClickSearchOrbView.java │ │ │ │ └── SearchSettingsOrbView.java │ │ │ ├── speedmarquee/ │ │ │ │ └── SpeedMarquee.java │ │ │ ├── styled/ │ │ │ │ └── CardProgressBar.java │ │ │ ├── test/ │ │ │ │ └── TestVerticalGridView.java │ │ │ ├── time/ │ │ │ │ ├── DateTimeView.java │ │ │ │ └── EndingTimeView.java │ │ │ └── vineyard/ │ │ │ ├── IconCardView.java │ │ │ ├── LoadingCardView.java │ │ │ ├── TagCardView.java │ │ │ └── videoview/ │ │ │ ├── LoopingVideoView.java │ │ │ ├── PreviewCardView.java │ │ │ └── VideoCardView.java │ │ └── util/ │ │ ├── GlideCachingModule.java │ │ ├── LongPressHandler.java │ │ ├── ViewUtil.java │ │ └── vineyard/ │ │ ├── NetworkUtil.java │ │ └── ToastFactory.java │ └── res/ │ ├── anim/ │ │ └── scroll_animation.xml │ ├── drawable/ │ │ ├── lb_search_orb.xml │ │ ├── player_background_controls.xml │ │ ├── player_background_controls_new.xml │ │ ├── player_background_suggestions.xml │ │ ├── progress_bar_grey.xml │ │ ├── progress_bar_red.xml │ │ ├── progress_bar_semi_red.xml │ │ ├── rounded_search_bg.xml │ │ ├── search_bar_cursor.xml │ │ ├── text_bg.xml │ │ ├── tooltip_frame_dark_mod.xml │ │ ├── tooltip_frame_light_mod.xml │ │ └── transparent_dialog_item_bg.xml │ ├── drawable-xhdpi/ │ │ └── default_background_gradient.xml │ ├── layout/ │ │ ├── abc_tooltip.xml │ │ ├── activity_playback.xml │ │ ├── channel_card.xml │ │ ├── channel_card_old.xml │ │ ├── channel_header.xml │ │ ├── chat_preference_fragment.xml │ │ ├── dialog_list_preference_item_multi.xml │ │ ├── fragment_app_settings.xml │ │ ├── fragment_channel.xml │ │ ├── fragment_channel_uploads.xml │ │ ├── fragment_grid.xml │ │ ├── fragment_main.xml │ │ ├── fragment_multi_grid.xml │ │ ├── fragment_playback.xml │ │ ├── fragment_search_tags.xml │ │ ├── fragment_signin.xml │ │ ├── fragment_webbrowser.xml │ │ ├── guidedstep_second_guidance.xml │ │ ├── icon_header_item.xml │ │ ├── lb_browse_title.xml │ │ ├── lb_control_bar.xml │ │ ├── lb_image_card_view.xml │ │ ├── lb_image_card_view_themed_content.xml │ │ ├── lb_image_card_view_themed_title.xml │ │ ├── lb_playback_fragment.xml │ │ ├── lb_playback_transport_controls_row.xml │ │ ├── lb_search_bar.xml │ │ ├── lb_title_view.xml │ │ ├── lb_title_view_logo.xml │ │ ├── lb_vertical_grid.xml │ │ ├── lb_vertical_grid1.xml │ │ ├── lb_vertical_grid2.xml │ │ ├── lb_video_card_view.xml │ │ ├── lb_video_surface.xml │ │ ├── lb_video_texture.xml │ │ ├── leanback_list_preference_fragment.xml │ │ ├── leanback_preference_fragment.xml │ │ ├── search_field.xml │ │ ├── settings_card.xml │ │ ├── text_badge_image_view.xml │ │ ├── text_badge_image_view_red.xml │ │ ├── view_loading_card.xml │ │ ├── view_options_item.xml │ │ ├── view_tag_card.xml │ │ ├── webbrowser.xml │ │ └── widget_preview_card.xml │ ├── layout-ldrtl/ │ │ └── lb_title_view.xml │ ├── raw/ │ │ └── keep.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── marqueetextviewcompat.xml │ │ ├── speedmarquee.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ └── themes_old.xml │ └── xml/ │ ├── settings.xml │ └── whisperplay.xml ├── stbeta/ │ └── res/ │ └── values/ │ └── strings.xml ├── stfdroid/ │ └── res/ │ └── values/ │ └── strings.xml └── ststable/ └── res/ └── values/ └── strings.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # yuliskov patreon: smarttube open_collective: # smarttubeapp ko_fi: # smarttube tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # smarttube issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/1-bug_report.yml ================================================ name: Bug Report description: Create a bug report to help us improve title: "[BUG]: " labels: [bug] body: - type: markdown attributes: value: | Thank you for helping to make SmartTube better by reporting a bug. Please fill in as much information as possible about your bug. - type: checkboxes id: checklist attributes: label: "Checklist" options: - label: "I made sure that there are *no existing issues* - [open](https://github.com/yuliskov/SmartTube/issues) or [closed](https://github.com/yuliskov/SmartTube/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to." required: true - label: "I have read the [FAQ](https://github.com/yuliskov/SmartTube#faq) and my problem isn't listed." required: true - label: "I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise." required: true - label: "This issue contains only one bug." required: true - type: input id: app-version attributes: label: Affected version description: "In which SmartTube version did you encounter the bug?" placeholder: "x.xx.x - Can be seen in the app from the 'About' section in Settings" validations: required: true - type: dropdown id: device-type attributes: label: Device Type description: Is it Smart TV/Box or Phone options: - "Smart TV/Box" - "Phone/Tablet" validations: required: true - type: input id: device-os-info attributes: label: Affected Android description: | With what operating system (+ version) did you encounter the bug? placeholder: "Example: Android TV 10" validations: required: true - type: textarea id: steps-to-reproduce attributes: label: Steps to reproduce the bug description: | What did you do for the bug to show up? If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug. placeholder: | 1. Go to '...' 2. Press on '....' validations: required: true - type: textarea id: actual-behavior attributes: label: Actual behavior description: | Tell us what happens with the steps given above. - type: textarea id: additional-information attributes: label: Additional information description: | Any other information you'd like to include, for instance that * Device (NVIDIA Shield, Xiaomi Mi Box, etc) * screenshot * your cat disabled your network connection * ... ================================================ FILE: .github/ISSUE_TEMPLATE/2-feature-request.yml ================================================ name: Feature Request description: Create a feature request for SmartTube title: '[Feature Request]: ' labels: ['enhancement'] body: - type: markdown attributes: value: Thanks for taking the time to file a feature request! Please fill out this form as completely as possible. - type: textarea attributes: label: Describe the feature you'd like to request description: A clear and concise description of what you want and what your use case is. validations: required: true - type: textarea attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. validations: required: true - type: textarea attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. validations: required: true - type: textarea id: additional-information attributes: label: Additional information description: | Any other information you'd like to include, for instance * Relevant issues * Mockup images * The feature would make your cat happy * ... ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Telegram group (international) url: http://t.me/SmartTubeEN about: "The international group is in **English only**. But don't worry if your English is not perfect, we have a friendly international community." - name: Telegram group (RU/UA) url: http://t.me/SmartTubeUA about: "Group for Russian and Ukrainian speakers." ================================================ FILE: .github/workflows/CI.yml ================================================ name: Build Debug APK on: push: branches: [ "master" ] workflow_dispatch: jobs: build: runs-on: ubuntu-latest env: HAS_SIGNING_KEY: ${{ secrets.SIGNING_KEY != '' }} HAS_VT_KEY: ${{ secrets.VIRUS_TOTAL_API_KEY != '' }} steps: - name: Checkout Code uses: actions/checkout@v6 with: submodules: recursive - name: Set up JDK 17 uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' cache: 'gradle' - name: Update Version Name id: get_version run: | # Keep the original code to make the nightly and original builds interchangeable sed -i "s/versionName \"\(.*\)\"/versionName \"\1-nightly-${{ github.run_number }}\"/" smarttubetv/build.gradle echo "VERSION_NAME=$(grep "versionName" smarttubetv/build.gradle | head -n 1 | awk -F'"' '{print $2}')" >> $GITHUB_OUTPUT - name: Configure Build Signing if: ${{ env.HAS_SIGNING_KEY == 'true' }} run: | echo "storePassword=${{ secrets.KEY_STORE_PASSWORD }}" > keystore.properties echo "keyAlias=${{ secrets.ALIAS }}" >> keystore.properties echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> keystore.properties echo "storeFile=${{ github.workspace }}/key.jks" >> keystore.properties echo "${{ secrets.SIGNING_KEY }}" | base64 --decode > ${{ github.workspace }}/key.jks - name: Grant Gradle permissions run: chmod +x gradlew - name: Lint with Gradle run: ./gradlew lintStbetaRelease - name: Upload Lint Report uses: actions/upload-artifact@v7 if: always() with: name: lint-report path: '**/build/reports/lint-results-*.html' if-no-files-found: warn - name: Build with Gradle run: ./gradlew clean assembleStbetaRelease - name: VirusTotal Scan if: ${{ env.HAS_VT_KEY == 'true' }} id: vt uses: crazy-max/ghaction-virustotal@v5 with: vt_api_key: ${{ secrets.VIRUS_TOTAL_API_KEY }} files: | ./smarttubetv/build/outputs/apk/stbeta/release/*.apk request_rate: 4 - name: VirusTotal Summary if: steps.vt.outcome == 'success' run: | echo "Waiting 150s for VirusTotal engines to report..." sleep 150 echo "### Security Scan Results" >> $GITHUB_STEP_SUMMARY echo "| Artifact Name | VirusTotal Status | Detailed Report |" >> $GITHUB_STEP_SUMMARY echo "| :--- | :--- | :--- |" >> $GITHUB_STEP_SUMMARY for apk in ./smarttubetv/build/outputs/apk/stbeta/release/*.apk; do filename=$(basename "$apk") sha256=$(sha256sum "$apk" | awk '{print $1}') # Construct the dynamic badge URL using the hash badge_url="https://badges.cssnr.com/vt/id/$sha256?start=green&end=red&n=8" vt_link="https://www.virustotal.com/gui/file/$sha256" echo "| $filename | [![$filename]($badge_url)]($vt_link) | [View Report]($vt_link) |" >> $GITHUB_STEP_SUMMARY done - name: Upload ARM64 APK uses: actions/upload-artifact@v7 with: name: SmartTube_${{ steps.get_version.outputs.VERSION_NAME }}_arm64 path: ./smarttubetv/build/outputs/apk/stbeta/release/*_arm64-v8a.apk if-no-files-found: error - name: Upload ARMv7 APK uses: actions/upload-artifact@v7 with: name: SmartTube_${{ steps.get_version.outputs.VERSION_NAME }}_armeabi-v7a path: ./smarttubetv/build/outputs/apk/stbeta/release/*_armeabi-v7a.apk if-no-files-found: error - name: Upload Universal APK uses: actions/upload-artifact@v7 with: name: SmartTube_${{ steps.get_version.outputs.VERSION_NAME }}_universal path: ./smarttubetv/build/outputs/apk/stbeta/release/*_universal.apk if-no-files-found: error - name: Upload x86 APK uses: actions/upload-artifact@v7 with: name: SmartTube_${{ steps.get_version.outputs.VERSION_NAME }}_x86 path: ./smarttubetv/build/outputs/apk/stbeta/release/*_x86.apk if-no-files-found: error ================================================ FILE: .github/workflows/cleanup.yml ================================================ name: Cleanup old workflow runs on: schedule: - cron: '0 3 * * *' workflow_dispatch: jobs: cleanup: runs-on: ubuntu-latest if: github.event.repository.fork == false permissions: actions: write steps: - uses: actions/github-script@v8 with: script: | const KEEP = 0; const workflowNames = ["VirusTotal Scan", "Cleanup old workflow runs"]; // Get workflow ID const workflows = await github.rest.actions.listRepoWorkflows({ owner: context.repo.owner, repo: context.repo.repo }); for (const name of workflowNames) { const wf = workflows.data.workflows.find(w => w.name === name); if (!wf) { core.info(`Workflow "${name}" not found, skipping`); continue; } // List runs const runs = await github.rest.actions.listWorkflowRuns({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: wf.id, per_page: 100 }); const toDelete = runs.data.workflow_runs.slice(KEEP); core.info(`Deleting ${toDelete.length} old runs`); for (const run of toDelete) { try { await github.rest.actions.deleteWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, run_id: run.id }); } catch (e) { core.info(`Skip run ${run.id}: ${e.message}`); } } } ================================================ FILE: .github/workflows/stale.yml ================================================ name: "Close stale issues" on: workflow_dispatch: branches: - master jobs: stale: runs-on: ubuntu-latest permissions: contents: read actions: write issues: write steps: - uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 3000 # This may result in rate limiting, could we reduce and run in batches? days-before-stale: 420 # 1st Jan 2025, as of 25th Feb 2026 days-before-close: 0 ignore-updates: true stale-issue-message: '' close-issue-message: | **Due to a high volume of stale issues, all issues older than January 1st 2025 are being closed automatically.** Please feel free to resubmit your issue if you believe it has not been appropriately dealt with, and tag this issue in the "Additional Information" section. ================================================ FILE: .github/workflows/virustotal_scan.yml ================================================ name: VirusTotal Scan on: release: types: [published] workflow_dispatch: inputs: release_tag: description: 'Release tag to scan' required: true jobs: virustotal_scan: permissions: contents: write runs-on: ubuntu-latest env: HAS_VT_KEY: ${{ secrets.VIRUS_TOTAL_API_KEY != '' }} steps: - name: Set tag variable run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "TAG_NAME=${{ github.event.inputs.release_tag }}" >> $GITHUB_ENV else echo "TAG_NAME=${{ github.event.release.tag_name }}" >> $GITHUB_ENV fi - name: Set report marker variable run: | echo -e "MARKER=\t\t\t" >> $GITHUB_ENV - name: Checkout code uses: actions/checkout@v6 - name: Download Release Assets env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p release_assets gh release download "$TAG_NAME" --dir release_assets --pattern "*.apk" - name: VirusTotal Scan if: ${{ env.HAS_VT_KEY == 'true' }} id: vt uses: crazy-max/ghaction-virustotal@v5 with: vt_api_key: ${{ secrets.VIRUS_TOTAL_API_KEY }} files: | release_assets/*.apk request_rate: 4 - name: Generate Custom Badge Report if: steps.vt.outcome == 'success' run: | echo "Waiting 150s for VirusTotal engines to report..." sleep 150 echo -e "$MARKER\n## 🛡️ VirusTotal Analysis" > vt_report.txt echo "" >> vt_report.txt echo "| Build Variant | VirusTotal Status | Detailed Report |" >> vt_report.txt echo "| :--- | :--- | :--- |" >> vt_report.txt for file in release_assets/*.apk; do [ -e "$file" ] || continue filename=$(basename "$file") sha256=$(sha256sum "$file" | awk '{print $1}') vt_link="https://www.virustotal.com/gui/file/$sha256/detection" badge_url="https://badges.cssnr.com/vt/id/$sha256?start=green&end=red&n=8" echo "Purging badge cache for $filename..." curl -s -X POST $badge_url asset_link="https://github.com/${{ github.repository }}/releases/download/${{ env.TAG_NAME }}/$filename" echo "| [$filename]($asset_link) | [![]($badge_url&v=$(date +%s))]($vt_link) | [View Report]($vt_link) |" >> vt_report.txt done - name: Update Release Notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release view "$TAG_NAME" --json body -q .body > current_notes.txt || echo "" > current_notes.txt # If the header exists, delete from that line to the end if grep -q "$MARKER" current_notes.txt; then echo "Previous report found. Cleaning up..." sed -i "/$MARKER/,\$d" current_notes.txt fi { cat current_notes.txt cat vt_report.txt } > final_notes.txt gh release edit "$TAG_NAME" --notes-file final_notes.txt ================================================ FILE: .gitignore ================================================ # built application files *.apk *.ap_ # files for the dex VM *.dex # Java class files *.class # generated files bin/ gen/ # Local configuration file (sdk path, etc) local.properties # Windows thumbnail db Thumbs.db # OSX files .DS_Store # Eclipse project files .classpath .project # Android Studio .idea *.iml #.idea/workspace.xml - remove # and delete .idea if it better suit your needs. .gradle .google build/ # my other stuff /smartyoutubetv2/release /smarttube/release /smarttubetv/release # gradle 6 fix /smarttube/output /smarttubetv/output /smarttubemobile/release # google-services.json *.hprof log.txt android.keystore android-upd2.keystore build.log notes.txt out.txt out1.txt deps.txt logcat.txt /releases /files /misc /other /TODO.txt tmp/ *_bak* *_tmp *.bak* *.tmp *.7z .DS_Store /captures .externalNativeBuild fabric.properties *BAK.java hs_err_pid*.log ================================================ FILE: .gitmodules ================================================ [submodule "SharedModules"] path = SharedModules url = https://github.com/yuliskov/SharedModules [submodule "MediaServiceCore"] path = MediaServiceCore url = https://github.com/yuliskov/MediaServiceCore ================================================ FILE: .reuse/dep5 ================================================ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: SmartTube Source: https://github.com/yuliskov/SmartTube Files: * Copyright: 2020-present yuliskov License: MIT License: MIT Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: . The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. . THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020-present yuliskov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Get it on F-Droid

# Important announcement about the app My development environment was infected by unknown malicious software, as a result of which a few builds may have been affected. Once the issue was detected, I secured everything with a full disk wipe, restored a clean setup, and now all builds are scanned with VirusTotal. The F-Droid version will also be verified before release. Public keys may have been compromised, which is why I am sharing this issue. You can download the new version and the new public key below, and instructions for restoring backups are provided. No extra actions are required since the app uses **one-time connection codes**. These codes have very limited permissions (for example, they cannot change your password). Still, you can revoke them if you want full peace of mind. # How to revoke access: 1. Open [myaccount.google.com/security](https://myaccount.google.com/security) 2. Find **“Your connections to third-party apps & services”** 3. Tap **“See all connection”** and locate **YouTube TV** or **Google Drive** 4. Select the app → **“Remove access”** Please keep built-in security features enabled to stay protected. # SmartTube ![The app screenshot](./images/browse_home.png) SmartTube is a free and open-source advanced media player for Android TVs and TV boxes. It allows you to play content from various public sources. ### ✅ Features - No ads - SponsorBlock integration - Adjustable playback speed - 8K resolution support - 60fps playback - HDR compatibility - View live chat - Customizable buttons - Does not require Google Services - Helpful international community ### ❌ Limitations - Not supported on phones and tablets - Comment functionality is unstable - Voice search and casting performance may be inferior to official apps, depending on your device Give it a try! **Do you have any question?** Ctrl+F or ⌘F this readme first! [**Installation**](#installation) | [**Official Site**](https://smarttubeapp.github.io) | [**Donation**](#donation) | [**FAQ**](#faq) | [Support / Chat](#support) | [Build](#build) | [Translate the app](https://jtbrinkmann.de/tools/android-strings.xml-translator.html) | [Changelog](https://t.me/s/SmartTubeNewsEN) | [Liability](#liability) ## Device support > [!IMPORTANT] > Starting in October 2025 new Amazon FireTV devices no longer run Android under the hood. SmartTube will **not** be compatibile with the Fire Stick 4k Select and newer devices which run Amazon's own VegaOS. ![Device support image](images/new/compatibility.png) * **Supported:** all Android TVs and TV boxes (incl. All FireTV devices released before Oct. 2025, NVIDIA Shield & Chromecast with Google TV), even older ones with Android 4.3 (Kitkat). * **Not supported:** Smartphones, non-Android platforms like Samsung Tizen, LG webOS, Apple TV, etc. ## Installation > [video of the installation](images/new/zPV0imF.mp4) (note: download url changed to `kutt.to/stn_beta` or `kutt.to/stn_stable`) **Do not** download SmartTube from any **app store**, APK websites or blogs; these were uploaded by other people and may contain malware or ads. SmartTube is not officially published on any app store. Sadly, the Google PlayStore does not allow ad-free Youtube apps using unofficial APIs. There is a **beta release** (recommended) and a **stable release**. Beta gets new features and bugfixes faster than the stable release. You can use either of the following methods to install the app: - (**Easiest**) Install [Downloader by AFTVnews](https://www.aftvnews.com/downloader/) on your Android TV, open it and enter `kutt.to/stn_beta` or `kutt.to/stn_stable`, then read, understand and confirm the security prompts. (You can also enter [**79015**](https://aftv.news/79015) (for beta) or [**28544**](https://aftv.news/28544) (for stable), but this requires an extra step to install the AFTVnews Downloader browser addon if you haven't already.) - Install a file transfer app on your Android TV, download the APK on your phone or computer and transfer it to your TV (e.g. [_Send Files to TV_](https://sendfilestotv.app/) from the Google Play Store / Amazon AppStore) - Download the APK onto a USB stick, put the USB stick into your TV and use a file manager app from the Google Play Store / Amazon AppStore (e.g. [_FX File Explorer_](https://play.google.com/store/apps/details?id=nextapp.fx) or [_X-plore_](https://play.google.com/store/apps/details?id=com.lonelycatgames.Xplore)). Android's preinstalled file manager does not work! Do **not** get the ad-infested _FileCommander_. - If you are an advanced user, you can install it using ADB. [guide](https://fossbytes.com/side-load-apps-android-tv/#h-how-to-sideload-apps-on-your-android-tv-using-adb) | [alternative guide](https://www.aftvnews.com/sideload/) **Troubleshooting:** See device specific notes below. If installation fails, either your **disk space is full** or the APK file didn't download correctly; clear up space and try downloading again. If the app installed, but crashes when opening, make sure to install it to internal memory, not to an SD card / external storage. **The app has a built-in updater** with changelog. You can also find all releases and the **changelog** on the [Telegram channel @SmartTubeNewsEN](https://t.me/s/SmartTubeNewsEN) (readable without account) or on [Github](https://github.com/yuliskov/SmartTube/releases/). > latest [**beta download**](https://github.com/yuliskov/SmartTube/releases/download/latest/smarttube_beta.apk) > > latest [stable download](https://github.com/yuliskov/SmartTube/releases/download/latest/smarttube_stable.apk) ### Installation (Chromecast with Google TV) On **Chromecast with Google TV**, installation of apps is blocked by default, so an extra step is required: > **4.1. Enable Developer Options** > > On your Chromecast, open the side menu and go to _Settings > System > About_. Scroll down to the _Android TV OS build_ section and click that repeatedly. A toast message will appear, explaining that you are a few steps away from being a developer. Continue clicking until you trigger it. > > > **4.2. Turn on the "unknown sources" setting** > > Go back to the main _Settings_ page and select _Apps > Security & Restrictions > Unknown sources_. Turn on the toggle for \[_Downloader by AFTVnews_ or\] whichever file browser you decided to use [...]. > > [[source & picture guide](https://www.androidpolice.com/2021/02/07/how-to-sideload-any-apk-on-the-chromecast-with-android-tv/#install-the-apk)] After this, you can follow the [general installation guide](#installation) above. ### Installation (Xiaomi devices with Chinese firmware) Xiaomi's **Chinese firmware** might block the installation **of the beta version**. The international firmware is not affected. Solutions: 1. Use SmartTube's **stable version** instead (**recommended**) 2. Use the international firmware for your device 3. (if your device is from 2020 or before) You can do a factory reset and then install SmartTube beta before doing any system updates. You can then safely update your system, SmartTube should continue working. ### Updating The app has a built-in updater. You only need to follow the installation procedure **once**. A few seconds after launching SmartTube, it will notify you if there is any update and also show a changelog. You can disable automatic update checks or manually update in the settings under "about". If the installation fails, either your **disk space is full** or the update didn't download correctly; clear up space and try updating again (_Settings > About > Check for updates_). ## Compatibility SmartTube requires Android 4.3 or above. It does not work on non-Android devices (incl. LG or Samsung TVs). On unsupported TVs, you can use a TV stick or TV box. Though this app technically runs on smartphones and tablets, it is not optimized for such and offers no official support! It has been successfully tested on TVs, TV boxes and TV sticks that are based on Android, including: - Android TVs & Google TVs (e.g. Philips, Sony) - Chromecast with Google TV & TVs with _Chromecast built-in_ - Amazon FireTV stick (all generations) - NVIDIA Shield - TV boxes running Android (many cheap chinese no-name boxes) - Xiaomi Mi Box ## Features ### Adblocking SmartTube does not show any ad banners, preroll ads or ad intermissions. It not just tries to prevent them, it is literally programmed to be completely **unable** to display any ads, so YouTube cannot slip anything in. This also means you cannot allow ads or whitelist channels. Some YouTube channels include sponsored messages in their videos, these can also be skipped, see [SponsorBlock](#SponsorBlock) below. ### SponsorBlock SmartTube includes a SponsorBlock integration. From the [SponsorBlock website](https://sponsor.ajay.app/): > SponsorBlock is an open-source crowdsourced browser extension and open API for **skipping sponsor segments** in YouTube videos. [...] the extension automatically skips sponsors **it knows about** using a privacy preserving query system. It also supports skipping **other categories**, such as intros, outros and reminders to subscribe [and non-music parts in music videos]. You can select which categories you want to skip in the settings. Unlike the browser addon, in SmartTube you cannot submit new segments (TVs and TV remotes aren't great devices for such precise operations). Note that SponsorBlock is a free and voluntary project based on user submissions, so don't expect it to 100% work every time. Sometimes, sponsor segments are not yet submitted to the database, sometimes the SponsorBlock servers are offline/overloaded. ### Casting To cast videos from your phone (or other devices), you must link that device to your TV. Unlike the original YouTube app, SmartTube does not automatically show up when you are in the same wifi network. How to link your smartphone and TV: 1. open SmartTube and go to settings 2. go to "Remote control" (2nd option) 3. open your YouTube app on your phone, go to settings > General > watch on TV 4. click on _connect using TV-code_ and enter the code from your TV [**Screenshot guide**](https://t.me/SmartTubeEN/8514) Due to technical limitations, you need to open the app on the TV before casting; SmartTube cannot automatically wake up the TV. ### Picture-in-Picture (PiP) SmartTube supports playing videos in PiP mode. This needs to be enabled under _Settings > General > Background playback > Picture in picture_. The video will go into PiP mode when you press home while playing a video, and also when you press _back_ if enabled in _Settings > General > Background playback (activation)_. ### Adjust Speed You can adjust the playback speed pressing the speed-indicator icon (gauge) in the top row of the player. This is remembered across videos. Some speeds may case frame drops, this is a known issue. ### Voice Search To enable global voice search, an additional app must be installed alongside SmartTube. This _bridge app_ can intercept the System's attempts to open the original YouTube app and open SmartTube instead. For this to work, you must uninstall the original YouTube app. We know this sucks, but you can always reinstall it if you change your mind. The _bridge app_ will not show up in your launcher and you cannot launch it directly; it is only used internally by the system's voice search. On some devices, you need to explicitly say "Youtube" when searching (e.g. say "youtube cute cats" instead of just "cute cats"). **On Amazon Fire TV**: 1. Uninstall the original YouTube app (no root required) 2. Download and install the Amazon Bridge SmartTube app: https://kutt.to/stn_bridge_amazon (e.g. via _Downloader by AFTVnews_) **On Google Chromecast with Google TV**: 1. Uninstall the original YouTube app (no root required) 2. Download and install the ATV Bridge SmartTube app: https://kutt.to/stn_bridge_atv (e.g. via _Downloader by AFTVnews_) **On all other Android devices**, sadly root is required to enable this: 1. Root your device (search for a guide for your specific device) 2. Uninstall the official YouTube app using root (`adb shell pm uninstall com.google.android.youtube.tv`) 3. Download and install the ATV Bridge SmartTube app: https://kutt.to/stn_bridge_atv (e.g. via _Downloader by AFTVnews_) ## Donation If you want to support my developments you are welcome to buy me a cup of coffee :) - [**Patreon (Visa, Mastercard, PayPal)**](https://www.patreon.com/smarttube) - **PayPal**: firsthash@gmail.com - **BTC**: 1JAT5VVWarVBkpVbNDn8UA8HXNdrukuBSx - **LTC**: ltc1qgc24eq9jl9cq78qnd5jpqhemkajg9vudwyd8pw - **ETH**: 0xe455E21a085ae195a097cd4F456051A9916A5064 - **ETC**: 0x209eCd33Fa61fA92167595eB3Aea92EE1905c815 - **TRX**: TJNPY794aSGZf3WGHTna2VCWm2G5Yua7E8 - **USDT (TRC20)**: TJNPY794aSGZf3WGHTna2VCWm2G5Yua7E8 - **USDT (BEP20)**: 0x64B28da787BE6ac5889D276A5638d4f077840eC5 - **USDT (ERC20)**: 0xe455e21a085ae195a097cd4f456051a9916a5064 - **TON**: UQAc9zgnnzwS8yb5wxAu5CB0RddmjPBjWI-n46oQ7XfCQrgI - **XMR**: 48QsMjqfkeW54vkgKyRnjodtYxdmLk6HXfTWPSZoaFPEDpoHDwFUciGCe1QC9VAeGrgGw4PKNAksX9RW7myFqYJQDN5cHGT ## Support **Please check the [FAQ](#faq) first!** Also at least have a short look at the recent chat history. You can report in our Telegram group or via [issue tracker on Github](https://github.com/yuliskov/SmartTube/issues) (account required). - **Telegram group (international)**: [@SmartTubeEN](http://t.me/SmartTubeEN) - **Discord group (international)**: [SmartTube Official](https://discord.gg/Wt8HDDej5z) - **Telegram group (RU/UA)**: [@SmartTubeUA](http://t.me/SmartTubeUA) - **Email**: firsthash@gmail.com The international group is in **English only**. But don't worry if your English is not perfect, we have a friendly international community. ## Team SmartTube is developed single-handedly; there is no larger team or company behind this. This is an open source, hobby project. Several others have helped with translations, some of which can be seen on [Github](https://github.com/yuliskov/SmartTube/graphs/contributors), some have sent their translations directly to Yurii. There are also helpful people in the support chat. ## Build **NOTE: OpenJDK 14 or older (!) is required. Newer JDK could cause app crash!** To build and install debug version, run these commands: ``` git clone https://github.com/yuliskov/SmartTube.git cd SmartTube git submodule update --init adb connect gradlew clean installStstableDebug ``` ## Video codecs Video codecs are the algorithms used for video compression. ### Which codec to choose / overview | | recommendation | hardware support | compression, bitrate\* | quality | |:--------:|:---------------------------------------------|:---------------------------- |:----------------------:|:-------:| | **AV01** aka. AV1 | best choice, **if your device supports** | first devices started coming in **2020** | **best** (e.g. 1.6 Mbps) | same | | **VP9** | **best choice on most devices** | most devices **since 2015** | **better** (e.g. 2.1 Mbps) | same | | AVC | only for old or slow hardware | **all** devices | good (e.g. 2.7 Mbps) | same | \* Examples taken from the video-only track at 1080p @ 25fps for this video: [Dua Lipa - New Rules (Official Music Video)](https://youtube.com/watch?v=k2qgadSvNyU) At the same resolution, a **lower bitrate is better!** YouTube explicitly targets the **same quality** regardless of the codec. Older codecs have a higher bitrate only because they are less efficient. On Youtube, you **do not** get better quality by simply choosing a higher bitrate. Newer codecs have a better compression = lower bitrate = use less bandwidth = save the environment. This is a feature, not a bug. You should use the newest codec that works smoothly on your device, not the least efficient one. AVC usually has the highest bitrate. This is bad, not good. ### Which quality to choose? Currently, there is no automatic mode based on your bandwidth. But you can configure a default video preset yourself under settings \> video player \> video presets. The first option ("none") will remember your last selection within the video player. Any other preset is used initially for each video; if the selected profile is not available, the next best available option is used. You can still override the profile on each video individually within the player. To decide the optional resolution / video quality for you, you need to consider a few limiting factors: - Your bandwidth (choose only up to the bitrate that your bandwidth can handle; you can do a speedtest using [fast.com](https://fast.com) by Netflix) - Your TV's display resolution (the quality **might slightly** improve, if you select the next higher resolution, e.g. 1080p on a 720p display; but don't expect a big difference) - Your TV's capabilities (e.g. HDR, 60fps) Generally 60fps is an improvement, but if you personally don't notice (or mind) the difference, you can save bandwidth (and the environment) by not choosing 60fps. ### HDR HDR works only **if your hardware supports it**. It's a complicated mess: - Your TV must support it - If you use a TV box, that TV box **and** your TV cable **and** the TV must support HDR - Yes, there truly are different HDMI cable versions with different HDR-support, it's complicated - some devices (like the **NVIDIA Shield**) generally support HDR, but **not** the specific HDR format that is used on YouTube :cry: If HDR videos look looked dim or washed out, then check [this article](https://www.wired.com/story/hdr-too-dark-how-to-fix-it/). **If HDR is not working**, it's probably not this app's fault. You might need to search on the web for "HDR" and your device name for any help. ## Liability We take no responsibility for the use of our tool, or external instances provided by third parties. We strongly recommend you abide by the valid official regulations in your country. Furthermore, we refuse liability for any inappropriate use of Invidious, such as illegal downloading. This tool is provided to you in the spirit of free, open software. You may view the LICENSE in which this software is provided to you [here](./LICENSE). > 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. ## FAQ ### Q: Videos buffer a LOT A: Try to switch to encrypted DNS like NextDNS. You can set-up such DNS either automatically or manually. To automatic set-up you can use the [Intra apk at fdroid](https://f-droid.org/en/packages/app.intra/) and the ["AutoStart - No root"](https://play.google.com/store/apps/details?id=com.autostart) apk to make it autolaunch after every TV restart. For manual set-up [use this guide](https://www.reddit.com/r/MiBox/s/7esEVGtAAa). ### Q: There is no result for the search that I say (Android 11) A: They're some reports that the latest update for "Google app for Android TV" could cause this bug. Deleting the update should fix the problem. ### Q: AV01 does not play / Why is VP9 slow on my device? A: Because AV01 is very new, **most** TVs and TV boxes **do not** offer hardware support and **cannot** play AV01 **at all**. If your device has hardware support for a codec, videos using that codec should play smoothly. High resolutions might also be slow in VP9 on cheap TV boxes that don't officially support 4k. Your device probably can play VP9 videos even without hardware support, however this requires a powerful CPU to run smoothly. Fixing AV01 without hardware support is technically possible, but currently not planned and probably not efficient enough. ### Q: Can you make SmartTube look like the original app? A: Compared to SmartTube's UI, Stock Youtube and YT Kids are far ahead. However, we'd need someone who's skilled and willing to dedicate enough time and energy into making it. And into maintaining it longterm (incl. new features, bug fixes). All of this for free. If you are / got someone like that, please help. Not to mention that SmartTube follows Google's official template & recommendations for Android TV apps. It's Google's fault that the template is somewhat ugly. 😂 ### Q: Can the search page be improved? A: It can be, but it takes someone to do it, similar to the above FAQ-entry. SmartTube is following Google's officially recommended design/template for TV apps and is using the official, preinstalled Android TV keyboard. Sadly, Google did a really bad job regarding the search page and keyboard. Maybe a future SmartTube update can add an embedded keyboard, similar to the original YouTube or other major Android TV apps. Maybe it can improve the looks to be as good or better than in the official YouTube app. But for now, it is the way it is due to lack of time and due to Google's official recommendations being bad. ### Q: Can I install this on a Samsung Tizen TV / LG webOS TV / Roku / iOS / toaster? A: No, this only works on **Android** devices. If you look at an Android TV's product page, it usually says clearly that it's based on Android. The app **cannot** easily be ported over to other plattforms and we have no plans to even try. **Please do not ask**. Instead, you can connect a separate TV stick or TV box to your TV. ### Q: Can I install this on a smartphone? / Can you add portrait mode? / Scrolling doesn't work. A: **Big No**. This app is **not** for smartphones, we offer **zero support** for that. You **can cast** videos **from** your smartphone to a TV / TV box running SmartTube, though. Just use the official YouTube app or [ReVanced](https://github.com/ReVanced), see [the casting section](#casting) for more information. **There will not be a phone version.** You can use [ReVanced](https://github.com/ReVanced), [Pure Tuber](https://play.google.com/store/apps/details?id=free.tube.premium.advanced.tuber), [NewPipe](https://newpipe.schabi.org), or [NewPipe x SponsorBlock](https://github.com/polymorphicshade/NewPipe#newpipe-x-sponsorblock) instead. Please go to their respective support chats for help. ### Q: Can I install this on a tablet / car screen / smartphone with docking station? Yes... maybe.. Requirements: - It is an Android device - It has a large screen - It has a TV remote, controller, or keyboard **Touch input is not supported.** Mouse/touchpad scrolling neither. You cannot properly use SmartTube with only touch or mouse input. Some users reported great success (incl. on a [car entertainment system](https://t.me/SmartTubeEN/6060)). **Please share your success stories with us.** ### Q: I get "unknown codec" / "can't download video" errors A: please wait 5 seconds for the video to play. If that doesn't help, press the play button. Some users reported, that this issue only appears when they have a USB audio device attached or if their disk storage is full. ### Q: I get "the video profile is not supported" A. Press the "HQ"-button in the bottom-left, select _video formats_ and select anything other than AV01. AV01 is **not supported** on most devices (apparently including yours), so select VP9 instead. See [the section on video codecs](#Video-codecs) for more information. ### Q: I get "video unavailable" when watching unlisted videos / my own videos A: Right, that's currently a bug. ### Q: It doesn't show up on my casting list A: Please read the [Casting](#casting) section. ### Q: I get an error saying "Sign in to confirm you're not a bot" A: Your IP address range might be temporaily/permanently blocked by YouTube from watching videos if you not signed in to your account. ### Q: The video is buffering a lot A: The issue might not be specific to SmartTube, as other unofficial YouTube apps also report this issue. It seems uncommon nowadays, but was very present in the 2nd quarter of 2021. Some users or devices seem to be more affected then others. The official YouTube app & website are apparently only rarely affected. The root cause of the issue is currently unclear, but it appears to be a server-side thing on YouTube's end. Possibly, YouTube is discriminating 3rd party apps. For now, try to see if it helps to: - Reduce the resolution (or chance it back) - Change the video format to AVC - Increase the buffer in the settings - Hit the back button and try playing the video again ### Q: The debug information says my display is 1080p, but I have a 4k/UHD display! A: Do not worry, **the debug information is incorrect.** SmartTube works fine even above 1080p and you should be able to see that, when you play a video in 4k or UHD. Also do not worry if it says "720p" and you have a 1080p display. ### Q: Why does it not autoselect highest quality? A: **It does** (by default). If you set a _video profile_ under settings, that acts as a maximum for automatic selection. Check if you configured a video profile, you can unset it by choosing "none". **Please do not confuse quality with bitrate**. See [the section on video codecs](#Video-codecs) for more information. ### Q: Can I set a (maximum) resolution by default? A: SmartTube automatically select the highest available quality for your video, up to a maximum resolution that you can set in the settings under "video profile". If available, SmartTube will pick the selected video profile, or otherwise the next best one available will be used. You can still always change the video profile while watching videos. ### Q: Can it set the resolution to "auto", depending on my available bandwidth? A: This is planned, but not available yet (sorry 🙇‍♀️). However, you can set a maximum resolution to something that should work for your bandwidth. See above for details. ### Q: Why does it skip video segments? A: SmartTube has a feature called **SponsorBlock**. You can select categories should be skipped, if any. See the [SponsorBlock section](#sponsorblock) for more details. ### Q: How to start the next video automatically / stop after every video? A: You can switch between different autoplay-modes using the loop-button 🔁 [![screenshot showing the loop-button](images/new/V3GHGvWprmdE1w.jpg)](https://t.me/SmartTubeEN/24953) ### Q: How to remove recommended videos (e.g. news) that are unrelated to me? A: Recommended videos are defined by YouTube and not by the app, we cannot change the algorithm. They are based on your country, which you can change in the settings. If you are logged in, they are based on your watch history, user profile data, and whatever else Google might use. If you are not logged in, you are like in "incognito mode", so your watch history does not influence your recommendations. Maybe a future version will add optional user profiling without logging in. ### Q: Does HDR work? A: Yes, HDR works **if your hardware** supports it. The **NVIDIA Shield** does not. See [the section on HDR](#HDR) for more information. ### Q: Why do some updates say "don't update if satisfied with the current version" in the changelog? A: These updates change a lot of code, trying to fix bugs that only affect a few users/devices. Only the affected users should update. For anyone else, there is nothing to gain from updating; however there is the chance of causing new bugs. Do not worry if you updated anyways. ### Q: When playing at other speeds, frames are skipped! A: We currently cannot fix this, sorry. ### Q: What is AFR? A: "Auto Frame Rate". It adjusts the refresh rate of your TV to match the content you're watching. It can slightly improve the smoothness, but the difference is very small; most people barely notice it. It does not work well on every hardware. If you don't know what it does and don't want to test it out yourself, you can safely keep it off. **Recommendation:** You can turn it on to see if it works on your device; if it causes issues (or if you don't care to test), turn it **off**. ### Q: Should I choose high or low buffer? A: The higher your buffer, the more of a video will be preloaded ahead of your current position. A low buffer might minimally reduce your bandwidth usage, if you often close videos before they end. A high buffer can smooth out network issues and prevent the video from pausing to buffer. A higher buffer increases RAM usage, however this shouldn't be an issue. **Recommendation: high**. ### Q: Can I retain the buffer when seeking back? A: No, when you seek back (e.g. jump back 5 seconds), SmartTube will have to rebuffer. This might be improved in a future update. ### Q: My device freezes when watching YouTube A: That's a firmware or Android issue. If you are using a custom rom, maybe that rom is buggy. Because this issue is nearly impossible for the developer to debug, we cannot help you, sorry. You can try the usual workarounds: rebooting, clearing cache, reinstalling the app, or factory resetting the device. ### Q: Can I download videos? A: Not with SmartTube ### Q: Can updates be installed automatically? A: No, this is technically not possible. Only the preinstalled app manager (usually Google PlayStore, Amazon AppStore, etc) has the required permission. All other apps, incl. SmartTube can only show open installation prompt. A workaround using root would be possible, but hasn't been implemented yet. ### Q: Can I whitelist ads on some channels? A: No, this is not possible. SmartTube does not have any code to display ads. Adding this functionality would actually take time and effort, which is instead spent on adding useful features and fixing bugs. ================================================ FILE: build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. apply from: gradle.ext.sharedModulesConstants // NOT working // Running 'gradle wrapper' will generate gradlew wrapper { gradleVersion = gradleVersion distributionType = Wrapper.DistributionType.BIN } buildscript { apply from: gradle.ext.sharedModulesConstants repositories { google() mavenCentral() // IntelliJ 'Read timed out' (error 403) // https://stackoverflow.com/questions/74258160/is-jcenter-down-permanently-31-oct //jcenter() } dependencies { classpath 'com.android.tools.build:gradle:7.4.2' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + kotlinVersion // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { google() mavenCentral() maven { url 'https://jitpack.io' } //flatDir { dirs 'libs' } // include duktape-my to all subprojects // IntelliJ 'Read timed out' (error 403) // https://stackoverflow.com/questions/74258160/is-jcenter-down-permanently-31-oct //jcenter() } configurations.all { // WorkManager conflict resolution resolutionStrategy.force 'androidx.lifecycle:lifecycle-livedata-core:' + liveDataVersion resolutionStrategy.force 'androidx.lifecycle:lifecycle-livedata:' + liveDataVersion resolutionStrategy.force 'androidx.lifecycle:lifecycle-runtime:' + liveDataVersion // Force Android 4 compatible okHttp version resolutionStrategy.force 'com.squareup.okhttp3:okhttp:' + okhttpVersion // Downgrade kotlin version for WorkManager resolutionStrategy.force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:' + kotlinVersion resolutionStrategy.force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + kotlinVersion resolutionStrategy.force 'org.jetbrains.kotlin:kotlin-stdlib:' + kotlinVersion resolutionStrategy.force 'org.jetbrains.kotlinx:kotlinx-coroutines-android:' + kotlinxVersion resolutionStrategy.force 'androidx.core:core-ktx:' + kotlinCoreXVersion resolutionStrategy.force 'androidx.core:core:' + coreXVersion resolutionStrategy.force 'androidx.annotation:annotation:' + annotationXVersion // Downgrade Cronet version for cronet-okhttp resolutionStrategy.force 'org.chromium.net:cronet-api:' + cronetApiVersion resolutionStrategy.force 'androidx.multidex:multidex:' + multiDexVersion // Replace with modded library (leanback-1.0.0) exclude group: 'androidx.leanback', module: 'leanback' // Replace with modded library (fragment-1.1.0) exclude group: 'androidx.fragment', module: 'fragment' // Don't work! Replace with modded library (leanback-1.0.0) // resolutionStrategy.dependencySubstitution { // // substitute remote dependency with local module // substitute module('androidx.leanback:leanback') using project(':leanback-1.0.0') // } } } ================================================ FILE: chatkit/.gitignore ================================================ /build ================================================ FILE: chatkit/LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: chatkit/build.gradle ================================================ apply from: gradle.ext.sharedModulesConstants apply plugin: 'com.android.library' android { compileSdkVersion project.properties.compileSdkVersion buildToolsVersion project.properties.buildToolsVersion defaultConfig { minSdkVersion project.properties.minSdkVersion targetSdkVersion project.properties.targetSdkVersion versionCode 1 versionName '0.4.1' consumerProguardFiles 'proguard.txt' } android { lintOptions { abortOnError false } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':sharedutils') implementation 'androidx.appcompat:appcompat:' + appCompatXVersion implementation 'com.google.android.material:material:' + materialVersion implementation "com.google.android.flexbox:flexbox:" + flexboxVersion implementation 'androidx.recyclerview:recyclerview:' + recyclerviewXVersion } ================================================ FILE: chatkit/proguard.txt ================================================ # ViewHolder constructors are resolved by reflection -keepclassmembers class * extends com.stfalcon.chatkit.commons.ViewHolder { public (android.view.View); } ================================================ FILE: chatkit/src/main/AndroidManifest.xml ================================================ ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/DebouncedOnClickListener.java ================================================ package com.stfalcon.chatkit.commons; import android.os.SystemClock; import android.view.View; import java.util.Map; import java.util.WeakHashMap; /** * A Debounced OnClickListener * Rejects clicks that are too close together in time. * This class is safe to use as an OnClickListener for multiple views, and will debounce each one separately. */ public abstract class DebouncedOnClickListener implements View.OnClickListener { private final long minimumIntervalMillis; private final Map lastClickMap; /** * Implement this in your subclass instead of onClick * * @param v The view that was clicked */ public abstract void onDebouncedClick(View v); /** * The one and only constructor * * @param minimumIntervalMillis The minimum allowed time between clicks - any click sooner than this after a previous click will be rejected */ public DebouncedOnClickListener(long minimumIntervalMillis) { this.minimumIntervalMillis = minimumIntervalMillis; this.lastClickMap = new WeakHashMap<>(); } @Override public void onClick(View clickedView) { Long previousClickTimestamp = lastClickMap.get(clickedView); long currentTimestamp = SystemClock.uptimeMillis(); if (previousClickTimestamp == null || Math.abs(currentTimestamp - previousClickTimestamp) > minimumIntervalMillis) { onDebouncedClick(clickedView); lastClickMap.put(clickedView, currentTimestamp); } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/ImageLoader.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.commons; import android.widget.ImageView; import androidx.annotation.Nullable; /** * Callback for implementing images loading in message list */ public interface ImageLoader { void loadImage(ImageView imageView, @Nullable String url, @Nullable Object payload); } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/InputTrackingRecyclerViewAdapter.java ================================================ package com.stfalcon.chatkit.commons; import android.view.KeyEvent; import android.view.View; import androidx.recyclerview.widget.RecyclerView; /** * Created by vektor on 31/05/16. */ public abstract class InputTrackingRecyclerViewAdapter extends RecyclerView.Adapter { private int mSelectedItem = 0; private RecyclerView mRecyclerView; @Override public void onAttachedToRecyclerView(final RecyclerView recyclerView) { super.onAttachedToRecyclerView(recyclerView); mRecyclerView = recyclerView; // Handle key up and key down and attempt to move selection recyclerView.setOnKeyListener(new View.OnKeyListener() { @Override public boolean onKey(View v, int keyCode, KeyEvent event) { RecyclerView.LayoutManager lm = recyclerView.getLayoutManager(); // Return false if scrolled to the bounds and allow focus to move off the list if (event.getAction() == KeyEvent.ACTION_DOWN) { if (isConfirmButton(event)) { if ((event.getFlags() & KeyEvent.FLAG_LONG_PRESS) == KeyEvent.FLAG_LONG_PRESS) { mRecyclerView.findViewHolderForAdapterPosition(mSelectedItem).itemView.performLongClick(); } else { event.startTracking(); } return true; } else { if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { return tryMoveSelection(lm, 1); } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) { return tryMoveSelection(lm, -1); } } } else if (event.getAction() == KeyEvent.ACTION_UP && isConfirmButton(event) && ((event.getFlags() & KeyEvent.FLAG_LONG_PRESS) != KeyEvent.FLAG_LONG_PRESS)) { mRecyclerView.findViewHolderForAdapterPosition(mSelectedItem).itemView.performClick(); return true; } return false; } }); } private boolean tryMoveSelection(RecyclerView.LayoutManager lm, int direction) { int nextSelectItem = mSelectedItem + direction; // If still within valid bounds, move the selection, notify to redraw, and scroll if (nextSelectItem >= 0 && nextSelectItem < getItemCount()) { notifyItemChanged(mSelectedItem); mSelectedItem = nextSelectItem; notifyItemChanged(mSelectedItem); //lm.scrollToPosition(mSelectedItem); mRecyclerView.smoothScrollToPosition(mSelectedItem); return true; } return false; } public int getSelectedItem() { return mSelectedItem; } public void setSelectedItem(int selectedItem) { mSelectedItem = selectedItem; } public RecyclerView getRecyclerView() { return mRecyclerView; } @Override public void onBindViewHolder(VH holder, int position) { onBindViewHolder(holder, position); } public static boolean isConfirmButton(KeyEvent event) { switch (event.getKeyCode()) { case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_BUTTON_A: return true; default: return false; } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/Style.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.commons; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.drawable.Drawable; import android.os.Build.VERSION; import android.util.AttributeSet; import android.util.TypedValue; import androidx.annotation.AttrRes; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; import androidx.annotation.DrawableRes; import androidx.core.content.ContextCompat; import com.stfalcon.chatkit.R; /** * Base class for chat component styles */ public abstract class Style { protected Context context; protected Resources resources; protected AttributeSet attrs; protected Style(Context context, AttributeSet attrs) { this.context = context; this.resources = context.getResources(); this.attrs = attrs; } protected final int getSystemAccentColor() { return getSystemColor(VERSION.SDK_INT >= 21 ? android.R.attr.colorAccent : R.attr.colorAccent); } protected final int getSystemPrimaryColor() { return getSystemColor(VERSION.SDK_INT >= 21 ? android.R.attr.colorPrimary : R.attr.colorPrimary); } protected final int getSystemPrimaryDarkColor() { return getSystemColor(VERSION.SDK_INT >= 21 ? android.R.attr.colorPrimaryDark : R.attr.colorPrimaryDark); } protected final int getSystemPrimaryTextColor() { return getSystemColor(android.R.attr.textColorPrimary); } protected final int getSystemHintColor() { return getSystemColor(android.R.attr.textColorHint); } protected final int getSystemColor(@AttrRes int attr) { TypedValue typedValue = new TypedValue(); TypedArray a = context.obtainStyledAttributes(typedValue.data, new int[]{attr}); // MOD: Invisible link fix on old devices (provide the default color) int color = a.getColor(0, getColor(R.color.dark_red)); a.recycle(); return color; } protected final int getDimension(@DimenRes int dimen) { return resources.getDimensionPixelSize(dimen); } protected final int getColor(@ColorRes int color) { return ContextCompat.getColor(context, color); } protected final Drawable getDrawable(@DrawableRes int drawable) { return ContextCompat.getDrawable(context, drawable); } protected final Drawable getVectorDrawable(@DrawableRes int drawable) { return ContextCompat.getDrawable(context, drawable); } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/ViewHolder.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.commons; import android.view.View; import androidx.recyclerview.widget.RecyclerView; /** * Base ViewHolder */ public abstract class ViewHolder extends RecyclerView.ViewHolder { public abstract void onBind(DATA data); public ViewHolder(View itemView) { super(itemView); } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/models/IDialog.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.commons.models; import java.util.List; /** * For implementing by real dialog model */ public interface IDialog { String getId(); String getDialogPhoto(); String getDialogName(); List getUsers(); MESSAGE getLastMessage(); void setLastMessage(MESSAGE message); int getUnreadCount(); } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/models/IMessage.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.commons.models; import java.util.Date; /** * For implementing by real message model */ public interface IMessage { /** * Returns message identifier * * @return the message id */ String getId(); /** * Returns message text * * @return the message text */ CharSequence getText(); /** * Returns message author. See the {@link IUser} for more details * * @return the message author */ IUser getUser(); /** * Returns message creation date * * @return the message creation date */ Date getCreatedAt(); static boolean checkMessage(IMessage message) { return message != null && message.getId() != null && message.getUser() != null && message.getUser().getId() != null && message.getText() != null; } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/models/IUser.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.commons.models; /** * For implementing by real user model */ public interface IUser { /** * Returns the user's id * * @return the user's id */ String getId(); /** * Returns the user's name * * @return the user's name */ String getName(); /** * Returns the user's avatar image url * * @return the user's avatar image url */ String getAvatar(); } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/models/MessageContentType.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.commons.models; import androidx.annotation.Nullable; import com.stfalcon.chatkit.messages.MessageHolders; /* * Created by troy379 on 28.03.17. */ /** * Interface used to mark messages as custom content types. For its representation see {@link MessageHolders} */ public interface MessageContentType extends IMessage { /** * Default media type for image message. */ interface Image extends IMessage { @Nullable String getImageUrl(); } // other default types will be here } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/widgets/FocusFixRelativeLayout.java ================================================ package com.stfalcon.chatkit.commons.widgets; import android.content.Context; import android.util.AttributeSet; import android.widget.RelativeLayout; /** * https://stackoverflow.com/questions/34277425/recyclerview-items-lose-focus */ public class FocusFixRelativeLayout extends RelativeLayout { public FocusFixRelativeLayout(Context context) { super(context); } public FocusFixRelativeLayout(Context context, AttributeSet attrs) { super(context, attrs); } public FocusFixRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @SuppressWarnings("NewApi") public FocusFixRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override public void clearFocus() { if (getParent() != null) { super.clearFocus(); } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/commons/widgets/WrapWidthTextView.java ================================================ package com.stfalcon.chatkit.commons.widgets; import android.content.Context; import android.text.Layout; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatTextView; /** * Alter multiline TextView behavior to wrap content exactly.
* Original discussion 1
* Original discussion 2
*/ public class WrapWidthTextView extends AppCompatTextView { public WrapWidthTextView(@NonNull Context context) { super(context); } public WrapWidthTextView(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public WrapWidthTextView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); Layout layout = getLayout(); if (layout != null) { int width = (int) Math.ceil(getMaxLineWidth(layout)) + getCompoundPaddingLeft() + getCompoundPaddingRight(); int height = getMeasuredHeight(); setMeasuredDimension(width, height); } } private float getMaxLineWidth(Layout layout) { float max_width = 0.0f; int lines = layout.getLineCount(); for (int i = 0; i < lines; i++) { if (layout.getLineWidth(i) > max_width) { max_width = layout.getLineWidth(i); } } return max_width; } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/dialogs/DialogListStyle.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.dialogs; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Typeface; import android.util.AttributeSet; import com.stfalcon.chatkit.R; import com.stfalcon.chatkit.commons.Style; /** * Style for DialogList customization by xml attributes */ @SuppressWarnings("WeakerAccess") class DialogListStyle extends Style { private int dialogTitleTextColor; private int dialogTitleTextSize; private int dialogTitleTextStyle; private int dialogUnreadTitleTextColor; private int dialogUnreadTitleTextStyle; private int dialogMessageTextColor; private int dialogMessageTextSize; private int dialogMessageTextStyle; private int dialogUnreadMessageTextColor; private int dialogUnreadMessageTextStyle; private int dialogDateColor; private int dialogDateSize; private int dialogDateStyle; private int dialogUnreadDateColor; private int dialogUnreadDateStyle; private boolean dialogUnreadBubbleEnabled; private int dialogUnreadBubbleTextColor; private int dialogUnreadBubbleTextSize; private int dialogUnreadBubbleTextStyle; private int dialogUnreadBubbleBackgroundColor; private int dialogAvatarWidth; private int dialogAvatarHeight; private boolean dialogMessageAvatarEnabled; private int dialogMessageAvatarWidth; private int dialogMessageAvatarHeight; private boolean dialogDividerEnabled; private int dialogDividerColor; private int dialogDividerLeftPadding; private int dialogDividerRightPadding; private int dialogItemBackground; private int dialogUnreadItemBackground; static DialogListStyle parse(Context context, AttributeSet attrs) { DialogListStyle style = new DialogListStyle(context, attrs); TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.DialogsList); //Item background style.dialogItemBackground = typedArray.getColor(R.styleable.DialogsList_dialogItemBackground, style.getColor(R.color.transparent)); style.dialogUnreadItemBackground = typedArray.getColor(R.styleable.DialogsList_dialogUnreadItemBackground, style.getColor(R.color.transparent)); //Title text style.dialogTitleTextColor = typedArray.getColor(R.styleable.DialogsList_dialogTitleTextColor, style.getColor(R.color.dialog_title_text)); style.dialogTitleTextSize = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogTitleTextSize, context.getResources().getDimensionPixelSize(R.dimen.dialog_title_text_size)); style.dialogTitleTextStyle = typedArray.getInt(R.styleable.DialogsList_dialogTitleTextStyle, Typeface.NORMAL); //Title unread text style.dialogUnreadTitleTextColor = typedArray.getColor(R.styleable.DialogsList_dialogUnreadTitleTextColor, style.getColor(R.color.dialog_title_text)); style.dialogUnreadTitleTextStyle = typedArray.getInt(R.styleable.DialogsList_dialogUnreadTitleTextStyle, Typeface.NORMAL); //Message text style.dialogMessageTextColor = typedArray.getColor(R.styleable.DialogsList_dialogMessageTextColor, style.getColor(R.color.dialog_message_text)); style.dialogMessageTextSize = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogMessageTextSize, context.getResources().getDimensionPixelSize(R.dimen.dialog_message_text_size)); style.dialogMessageTextStyle = typedArray.getInt(R.styleable.DialogsList_dialogMessageTextStyle, Typeface.NORMAL); //Message unread text style.dialogUnreadMessageTextColor = typedArray.getColor(R.styleable.DialogsList_dialogUnreadMessageTextColor, style.getColor(R.color.dialog_message_text)); style.dialogUnreadMessageTextStyle = typedArray.getInt(R.styleable.DialogsList_dialogUnreadMessageTextStyle, Typeface.NORMAL); //Date text style.dialogDateColor = typedArray.getColor(R.styleable.DialogsList_dialogDateColor, style.getColor(R.color.dialog_date_text)); style.dialogDateSize = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogDateSize, context.getResources().getDimensionPixelSize(R.dimen.dialog_date_text_size)); style.dialogDateStyle = typedArray.getInt(R.styleable.DialogsList_dialogDateStyle, Typeface.NORMAL); //Date unread text style.dialogUnreadDateColor = typedArray.getColor(R.styleable.DialogsList_dialogUnreadDateColor, style.getColor(R.color.dialog_date_text)); style.dialogUnreadDateStyle = typedArray.getInt(R.styleable.DialogsList_dialogUnreadDateStyle, Typeface.NORMAL); //Unread bubble style.dialogUnreadBubbleEnabled = typedArray.getBoolean(R.styleable.DialogsList_dialogUnreadBubbleEnabled, true); style.dialogUnreadBubbleBackgroundColor = typedArray.getColor(R.styleable.DialogsList_dialogUnreadBubbleBackgroundColor, style.getColor(R.color.dialog_unread_bubble)); //Unread bubble text style.dialogUnreadBubbleTextColor = typedArray.getColor(R.styleable.DialogsList_dialogUnreadBubbleTextColor, style.getColor(R.color.dialog_unread_text)); style.dialogUnreadBubbleTextSize = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogUnreadBubbleTextSize, context.getResources().getDimensionPixelSize(R.dimen.dialog_unread_bubble_text_size)); style.dialogUnreadBubbleTextStyle = typedArray.getInt(R.styleable.DialogsList_dialogUnreadBubbleTextStyle, Typeface.NORMAL); //Avatar style.dialogAvatarWidth = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogAvatarWidth, context.getResources().getDimensionPixelSize(R.dimen.dialog_avatar_width)); style.dialogAvatarHeight = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogAvatarHeight, context.getResources().getDimensionPixelSize(R.dimen.dialog_avatar_height)); //Last message avatar style.dialogMessageAvatarEnabled = typedArray.getBoolean(R.styleable.DialogsList_dialogMessageAvatarEnabled, true); style.dialogMessageAvatarWidth = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogMessageAvatarWidth, context.getResources().getDimensionPixelSize(R.dimen.dialog_last_message_avatar_width)); style.dialogMessageAvatarHeight = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogMessageAvatarHeight, context.getResources().getDimensionPixelSize(R.dimen.dialog_last_message_avatar_height)); //Divider style.dialogDividerEnabled = typedArray.getBoolean(R.styleable.DialogsList_dialogDividerEnabled, true); style.dialogDividerColor = typedArray.getColor(R.styleable.DialogsList_dialogDividerColor, style.getColor(R.color.dialog_divider)); style.dialogDividerLeftPadding = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogDividerLeftPadding, context.getResources().getDimensionPixelSize(R.dimen.dialog_divider_margin_left)); style.dialogDividerRightPadding = typedArray.getDimensionPixelSize(R.styleable.DialogsList_dialogDividerRightPadding, context.getResources().getDimensionPixelSize(R.dimen.dialog_divider_margin_right)); typedArray.recycle(); return style; } private DialogListStyle(Context context, AttributeSet attrs) { super(context, attrs); } protected int getDialogTitleTextColor() { return dialogTitleTextColor; } protected int getDialogTitleTextSize() { return dialogTitleTextSize; } protected int getDialogTitleTextStyle() { return dialogTitleTextStyle; } protected int getDialogUnreadTitleTextColor() { return dialogUnreadTitleTextColor; } protected int getDialogUnreadTitleTextStyle() { return dialogUnreadTitleTextStyle; } protected int getDialogMessageTextColor() { return dialogMessageTextColor; } protected int getDialogMessageTextSize() { return dialogMessageTextSize; } protected int getDialogMessageTextStyle() { return dialogMessageTextStyle; } protected int getDialogUnreadMessageTextColor() { return dialogUnreadMessageTextColor; } protected int getDialogUnreadMessageTextStyle() { return dialogUnreadMessageTextStyle; } protected int getDialogDateColor() { return dialogDateColor; } protected int getDialogDateSize() { return dialogDateSize; } protected int getDialogDateStyle() { return dialogDateStyle; } protected int getDialogUnreadDateColor() { return dialogUnreadDateColor; } protected int getDialogUnreadDateStyle() { return dialogUnreadDateStyle; } protected boolean isDialogUnreadBubbleEnabled() { return dialogUnreadBubbleEnabled; } protected int getDialogUnreadBubbleTextColor() { return dialogUnreadBubbleTextColor; } protected int getDialogUnreadBubbleTextSize() { return dialogUnreadBubbleTextSize; } protected int getDialogUnreadBubbleTextStyle() { return dialogUnreadBubbleTextStyle; } protected int getDialogUnreadBubbleBackgroundColor() { return dialogUnreadBubbleBackgroundColor; } protected int getDialogAvatarWidth() { return dialogAvatarWidth; } protected int getDialogAvatarHeight() { return dialogAvatarHeight; } protected boolean isDialogDividerEnabled() { return dialogDividerEnabled; } protected int getDialogDividerColor() { return dialogDividerColor; } protected int getDialogDividerLeftPadding() { return dialogDividerLeftPadding; } protected int getDialogDividerRightPadding() { return dialogDividerRightPadding; } protected int getDialogItemBackground() { return dialogItemBackground; } protected int getDialogUnreadItemBackground() { return dialogUnreadItemBackground; } protected boolean isDialogMessageAvatarEnabled() { return dialogMessageAvatarEnabled; } protected int getDialogMessageAvatarWidth() { return dialogMessageAvatarWidth; } protected int getDialogMessageAvatarHeight() { return dialogMessageAvatarHeight; } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/dialogs/DialogsList.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.dialogs; import android.content.Context; import android.util.AttributeSet; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SimpleItemAnimator; import com.stfalcon.chatkit.commons.models.IDialog; /** * Component for displaying list of dialogs */ public class DialogsList extends RecyclerView { private DialogListStyle dialogStyle; public DialogsList(Context context) { super(context); } public DialogsList(Context context, @Nullable AttributeSet attrs) { super(context, attrs); parseStyle(context, attrs); } public DialogsList(Context context, @Nullable AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); parseStyle(context, attrs); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); LinearLayoutManager layout = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false); SimpleItemAnimator animator = new DefaultItemAnimator(); setLayoutManager(layout); setItemAnimator(animator); } /** * Don't use this method for setting your adapter, otherwise exception will by thrown. * Call {@link #setAdapter(DialogsListAdapter)} instead. */ @Override public void setAdapter(Adapter adapter) { throw new IllegalArgumentException("You can't set adapter to DialogsList. Use #setAdapter(DialogsListAdapter) instead."); } /** * Sets adapter for DialogsList * * @param adapter Adapter. Must extend DialogsListAdapter * @param

Dialog model class */ public > void setAdapter(DialogsListAdapter adapter) { setAdapter(adapter, false); } /** * Sets adapter for DialogsList * * @param adapter Adapter. Must extend DialogsListAdapter * @param reverseLayout weather to use reverse layout for layout manager. * @param Dialog model class */ public > void setAdapter(DialogsListAdapter adapter, boolean reverseLayout) { SimpleItemAnimator itemAnimator = new DefaultItemAnimator(); itemAnimator.setSupportsChangeAnimations(false); LinearLayoutManager layoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, reverseLayout); setItemAnimator(itemAnimator); setLayoutManager(layoutManager); adapter.setStyle(dialogStyle); super.setAdapter(adapter); } @SuppressWarnings("ResourceType") private void parseStyle(Context context, AttributeSet attrs) { dialogStyle = DialogListStyle.parse(context, attrs); } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/dialogs/DialogsListAdapter.java ================================================ /****************************************************************************** * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.dialogs; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.stfalcon.chatkit.R; import com.stfalcon.chatkit.commons.ImageLoader; import com.stfalcon.chatkit.commons.ViewHolder; import com.stfalcon.chatkit.commons.models.IDialog; import com.stfalcon.chatkit.commons.models.IMessage; import com.stfalcon.chatkit.utils.DateFormatter; import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; import static android.view.View.GONE; import static android.view.View.VISIBLE; /** * Adapter for {@link DialogsList} */ @SuppressWarnings("WeakerAccess") public class DialogsListAdapter

extends RecyclerView.Adapter { protected List items = new ArrayList<>(); private int itemLayoutId; private Class holderClass; private ImageLoader imageLoader; private OnDialogClickListener onDialogClickListener; private OnDialogViewClickListener onDialogViewClickListener; private OnDialogLongClickListener onLongItemClickListener; private OnDialogViewLongClickListener onDialogViewLongClickListener; private DialogListStyle dialogStyle; private DateFormatter.Formatter datesFormatter; /** * For default list item layout and view holder * * @param imageLoader image loading method */ public DialogsListAdapter(ImageLoader imageLoader) { this(R.layout.item_dialog, DialogViewHolder.class, imageLoader); } /** * For custom list item layout and default view holder * * @param itemLayoutId custom list item resource id * @param imageLoader image loading method */ public DialogsListAdapter(@LayoutRes int itemLayoutId, ImageLoader imageLoader) { this(itemLayoutId, DialogViewHolder.class, imageLoader); } /** * For custom list item layout and custom view holder * * @param itemLayoutId custom list item resource id * @param holderClass custom view holder class * @param imageLoader image loading method */ public DialogsListAdapter(@LayoutRes int itemLayoutId, Class holderClass, ImageLoader imageLoader) { this.itemLayoutId = itemLayoutId; this.holderClass = holderClass; this.imageLoader = imageLoader; } @SuppressWarnings("unchecked") @Override public void onBindViewHolder(BaseDialogViewHolder holder, int position) { holder.setImageLoader(imageLoader); holder.setOnDialogClickListener(onDialogClickListener); holder.setOnDialogViewClickListener(onDialogViewClickListener); holder.setOnLongItemClickListener(onLongItemClickListener); holder.setOnDialogViewLongClickListener(onDialogViewLongClickListener); holder.setDatesFormatter(datesFormatter); holder.onBind(items.get(position)); } @Override public BaseDialogViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View v = LayoutInflater.from(parent.getContext()).inflate(itemLayoutId, parent, false); //create view holder by reflation try { Constructor constructor = holderClass.getDeclaredConstructor(View.class); constructor.setAccessible(true); BaseDialogViewHolder baseDialogViewHolder = constructor.newInstance(v); if (baseDialogViewHolder instanceof DialogViewHolder) { ((DialogViewHolder) baseDialogViewHolder).setDialogStyle(dialogStyle); } return baseDialogViewHolder; } catch (Exception e) { e.printStackTrace(); } return null; } /** * @return size of dialogs list */ @Override public int getItemCount() { return items.size(); } /** * remove item with id * * @param id dialog i */ public void deleteById(String id) { for (int i = 0; i < items.size(); i++) { if (items.get(i).getId().equals(id)) { items.remove(i); notifyItemRemoved(i); } } } /** * Returns {@code true} if, and only if, dialogs count in adapter is non-zero. * * @return {@code true} if size is 0, otherwise {@code false} */ public boolean isEmpty() { return items.isEmpty(); } /** * clear dialogs list */ public void clear() { if (items != null) { items.clear(); } notifyDataSetChanged(); } /** * Set dialogs list * * @param items dialogs list */ public void setItems(List items) { this.items = items; notifyDataSetChanged(); } /** * Add dialogs items * * @param newItems new dialogs list */ public void addItems(List newItems) { if (newItems != null) { if (items == null) { items = new ArrayList<>(); } int curSize = items.size(); items.addAll(newItems); notifyItemRangeInserted(curSize, items.size()); } } /** * Add dialog to the end of dialogs list * * @param dialog dialog item */ public void addItem(DIALOG dialog) { items.add(dialog); notifyItemInserted(items.size() - 1); } /** * Add dialog to dialogs list * * @param dialog dialog item * @param position position in dialogs list */ public void addItem(int position, DIALOG dialog) { items.add(position, dialog); notifyItemInserted(position); } /** * Move an item * * @param fromPosition the actual position of the item * @param toPosition the new position of the item */ public void moveItem(int fromPosition, int toPosition) { DIALOG dialog = items.remove(fromPosition); items.add(toPosition, dialog); notifyItemMoved(fromPosition, toPosition); } /** * Update dialog by position in dialogs list * * @param position position in dialogs list * @param item new dialog item */ public void updateItem(int position, DIALOG item) { if (items == null) { items = new ArrayList<>(); } items.set(position, item); notifyItemChanged(position); } /** * Update dialog by dialog id * * @param item new dialog item */ public void updateItemById(DIALOG item) { if (items == null) { items = new ArrayList<>(); } for (int i = 0; i < items.size(); i++) { if (items.get(i).getId().equals(item.getId())) { items.set(i, item); notifyItemChanged(i); break; } } } /** * Upsert dialog in dialogs list or add it to then end of dialogs list * * @param item dialog item */ public void upsertItem(DIALOG item) { boolean updated = false; for (int i = 0; i < items.size(); i++) { if (items.get(i).getId().equals(item.getId())) { items.set(i, item); notifyItemChanged(i); updated = true; break; } } if (!updated) { addItem(item); } } /** * Find an item by its id * * @param id the wanted item's id * @return the found item, or null */ @Nullable public DIALOG getItemById(String id) { if (items == null) { items = new ArrayList<>(); } for (DIALOG item : items) { if (item.getId() == null && id == null) { return item; } else if (item.getId() != null && item.getId().equals(id)) { return item; } } return null; } /** * Update last message in dialog and swap item to top of list. * * @param dialogId Dialog ID * @param message New message * @return false if dialog doesn't exist. */ @SuppressWarnings("unchecked") public boolean updateDialogWithMessage(String dialogId, IMessage message) { boolean dialogExist = false; for (int i = 0; i < items.size(); i++) { if (items.get(i).getId().equals(dialogId)) { items.get(i).setLastMessage(message); notifyItemChanged(i); if (i != 0) { Collections.swap(items, i, 0); notifyItemMoved(i, 0); } dialogExist = true; break; } } return dialogExist; } /** * Sort dialog by last message date */ public void sortByLastMessageDate() { Collections.sort(items, (o1, o2) -> { if (o1.getLastMessage().getCreatedAt().after(o2.getLastMessage().getCreatedAt())) { return -1; } else if (o1.getLastMessage().getCreatedAt().before(o2.getLastMessage().getCreatedAt())) { return 1; } else return 0; }); notifyDataSetChanged(); } /** * Sort items with rules of comparator * * @param comparator Comparator */ public void sort(Comparator comparator) { Collections.sort(items, comparator); notifyDataSetChanged(); } /** * @return registered image loader */ public ImageLoader getImageLoader() { return imageLoader; } /** * Register a callback to be invoked when image need to load. * * @param imageLoader image loading method */ public void setImageLoader(ImageLoader imageLoader) { this.imageLoader = imageLoader; } /** * @return the item click callback. */ public OnDialogClickListener getOnDialogClickListener() { return onDialogClickListener; } /** * Register a callback to be invoked when item is clicked. * * @param onDialogClickListener on click item callback */ public void setOnDialogClickListener(OnDialogClickListener onDialogClickListener) { this.onDialogClickListener = onDialogClickListener; } /** * @return the view click callback. */ public OnDialogViewClickListener getOnDialogViewClickListener() { return onDialogViewClickListener; } /** * Register a callback to be invoked when dialog view is clicked. * * @param clickListener on click item callback */ public void setOnDialogViewClickListener(OnDialogViewClickListener clickListener) { this.onDialogViewClickListener = clickListener; } /** * @return on long click item callback */ public OnDialogLongClickListener getOnLongItemClickListener() { return onLongItemClickListener; } /** * Register a callback to be invoked when item is long clicked. * * @param onLongItemClickListener on long click item callback */ public void setOnDialogLongClickListener(OnDialogLongClickListener onLongItemClickListener) { this.onLongItemClickListener = onLongItemClickListener; } /** * @return on view long click callback */ public OnDialogViewLongClickListener getOnDialogViewLongClickListener() { return onDialogViewLongClickListener; } /** * Register a callback to be invoked when item view is long clicked. * * @param clickListener on long click item callback */ public void setOnDialogViewLongClickListener(OnDialogViewLongClickListener clickListener) { this.onDialogViewLongClickListener = clickListener; } /** * Sets custom {@link DateFormatter.Formatter} for text representation of last message date. */ public void setDatesFormatter(DateFormatter.Formatter datesFormatter) { this.datesFormatter = datesFormatter; } //TODO ability to set style programmatically void setStyle(DialogListStyle dialogStyle) { this.dialogStyle = dialogStyle; } /** * @return the position of a dialog in the dialogs list. */ public int getDialogPosition(DIALOG dialog) { return this.items.indexOf(dialog); } /* * LISTENERS * */ public interface OnDialogClickListener { void onDialogClick(DIALOG dialog); } public interface OnDialogViewClickListener { void onDialogViewClick(View view, DIALOG dialog); } public interface OnDialogLongClickListener { void onDialogLongClick(DIALOG dialog); } public interface OnDialogViewLongClickListener { void onDialogViewLongClick(View view, DIALOG dialog); } /* * HOLDERS * */ public abstract static class BaseDialogViewHolder extends ViewHolder { protected ImageLoader imageLoader; protected OnDialogClickListener onDialogClickListener; protected OnDialogLongClickListener onLongItemClickListener; protected OnDialogViewClickListener onDialogViewClickListener; protected OnDialogViewLongClickListener onDialogViewLongClickListener; protected DateFormatter.Formatter datesFormatter; public BaseDialogViewHolder(View itemView) { super(itemView); } void setImageLoader(ImageLoader imageLoader) { this.imageLoader = imageLoader; } protected void setOnDialogClickListener(OnDialogClickListener onDialogClickListener) { this.onDialogClickListener = onDialogClickListener; } protected void setOnDialogViewClickListener(OnDialogViewClickListener onDialogViewClickListener) { this.onDialogViewClickListener = onDialogViewClickListener; } protected void setOnLongItemClickListener(OnDialogLongClickListener onLongItemClickListener) { this.onLongItemClickListener = onLongItemClickListener; } protected void setOnDialogViewLongClickListener(OnDialogViewLongClickListener onDialogViewLongClickListener) { this.onDialogViewLongClickListener = onDialogViewLongClickListener; } public void setDatesFormatter(DateFormatter.Formatter dateHeadersFormatter) { this.datesFormatter = dateHeadersFormatter; } } public static class DialogViewHolder extends BaseDialogViewHolder { protected DialogListStyle dialogStyle; protected ViewGroup container; protected ViewGroup root; protected TextView tvName; protected TextView tvDate; protected ImageView ivAvatar; protected ImageView ivLastMessageUser; protected TextView tvLastMessage; protected TextView tvBubble; protected ViewGroup dividerContainer; protected View divider; public DialogViewHolder(View itemView) { super(itemView); root = itemView.findViewById(R.id.dialogRootLayout); container = itemView.findViewById(R.id.dialogContainer); tvName = itemView.findViewById(R.id.dialogName); tvDate = itemView.findViewById(R.id.dialogDate); tvLastMessage = itemView.findViewById(R.id.dialogLastMessage); tvBubble = itemView.findViewById(R.id.dialogUnreadBubble); ivLastMessageUser = itemView.findViewById(R.id.dialogLastMessageUserAvatar); ivAvatar = itemView.findViewById(R.id.dialogAvatar); dividerContainer = itemView.findViewById(R.id.dialogDividerContainer); divider = itemView.findViewById(R.id.dialogDivider); } private void applyStyle() { if (dialogStyle != null) { //Texts if (tvName != null) { tvName.setTextSize(TypedValue.COMPLEX_UNIT_PX, dialogStyle.getDialogTitleTextSize()); } if (tvLastMessage != null) { tvLastMessage.setTextSize(TypedValue.COMPLEX_UNIT_PX, dialogStyle.getDialogMessageTextSize()); } if (tvDate != null) { tvDate.setTextSize(TypedValue.COMPLEX_UNIT_PX, dialogStyle.getDialogDateSize()); } //Divider if (divider != null) divider.setBackgroundColor(dialogStyle.getDialogDividerColor()); if (dividerContainer != null) dividerContainer.setPadding(dialogStyle.getDialogDividerLeftPadding(), 0, dialogStyle.getDialogDividerRightPadding(), 0); //Avatar if (ivAvatar != null) { ivAvatar.getLayoutParams().width = dialogStyle.getDialogAvatarWidth(); ivAvatar.getLayoutParams().height = dialogStyle.getDialogAvatarHeight(); } //Last message user avatar if (ivLastMessageUser != null) { ivLastMessageUser.getLayoutParams().width = dialogStyle.getDialogMessageAvatarWidth(); ivLastMessageUser.getLayoutParams().height = dialogStyle.getDialogMessageAvatarHeight(); } //Unread bubble if (tvBubble != null) { GradientDrawable bgShape = (GradientDrawable) tvBubble.getBackground(); bgShape.setColor(dialogStyle.getDialogUnreadBubbleBackgroundColor()); tvBubble.setVisibility(dialogStyle.isDialogDividerEnabled() ? VISIBLE : GONE); tvBubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, dialogStyle.getDialogUnreadBubbleTextSize()); tvBubble.setTextColor(dialogStyle.getDialogUnreadBubbleTextColor()); tvBubble.setTypeface(tvBubble.getTypeface(), dialogStyle.getDialogUnreadBubbleTextStyle()); } } } private void applyDefaultStyle() { if (dialogStyle != null) { if (root != null) { root.setBackgroundColor(dialogStyle.getDialogItemBackground()); } if (tvName != null) { tvName.setTextColor(dialogStyle.getDialogTitleTextColor()); tvName.setTypeface(Typeface.DEFAULT, dialogStyle.getDialogTitleTextStyle()); } if (tvDate != null) { tvDate.setTextColor(dialogStyle.getDialogDateColor()); tvDate.setTypeface(Typeface.DEFAULT, dialogStyle.getDialogDateStyle()); } if (tvLastMessage != null) { tvLastMessage.setTextColor(dialogStyle.getDialogMessageTextColor()); tvLastMessage.setTypeface(Typeface.DEFAULT, dialogStyle.getDialogMessageTextStyle()); } } } private void applyUnreadStyle() { if (dialogStyle != null) { if (root != null) { root.setBackgroundColor(dialogStyle.getDialogUnreadItemBackground()); } if (tvName != null) { tvName.setTextColor(dialogStyle.getDialogUnreadTitleTextColor()); tvName.setTypeface(Typeface.DEFAULT, dialogStyle.getDialogUnreadTitleTextStyle()); } if (tvDate != null) { tvDate.setTextColor(dialogStyle.getDialogUnreadDateColor()); tvDate.setTypeface(Typeface.DEFAULT, dialogStyle.getDialogUnreadDateStyle()); } if (tvLastMessage != null) { tvLastMessage.setTextColor(dialogStyle.getDialogUnreadMessageTextColor()); tvLastMessage.setTypeface(Typeface.DEFAULT, dialogStyle.getDialogUnreadMessageTextStyle()); } } } @Override public void onBind(final DIALOG dialog) { if (dialog.getUnreadCount() > 0) { applyUnreadStyle(); } else { applyDefaultStyle(); } //Set Name tvName.setText(dialog.getDialogName()); //Set Date String formattedDate = null; if (dialog.getLastMessage() != null) { Date lastMessageDate = dialog.getLastMessage().getCreatedAt(); if (datesFormatter != null) formattedDate = datesFormatter.format(lastMessageDate); tvDate.setText(formattedDate == null ? getDateString(lastMessageDate) : formattedDate); } else { tvDate.setText(null); } //Set Dialog avatar if (imageLoader != null) { imageLoader.loadImage(ivAvatar, dialog.getDialogPhoto(), null); } //Set Last message user avatar with check if there is last message if (imageLoader != null && dialog.getLastMessage() != null) { imageLoader.loadImage(ivLastMessageUser, dialog.getLastMessage().getUser().getAvatar(), null); } ivLastMessageUser.setVisibility(dialogStyle.isDialogMessageAvatarEnabled() && dialog.getUsers().size() > 1 && dialog.getLastMessage() != null ? VISIBLE : GONE); //Set Last message text if (dialog.getLastMessage() != null) { tvLastMessage.setText(dialog.getLastMessage().getText()); } else { tvLastMessage.setText(null); } //Set Unread message count bubble tvBubble.setText(String.valueOf(dialog.getUnreadCount())); tvBubble.setVisibility(dialogStyle.isDialogUnreadBubbleEnabled() && dialog.getUnreadCount() > 0 ? VISIBLE : GONE); container.setOnClickListener(view -> { if (onDialogClickListener != null) { onDialogClickListener.onDialogClick(dialog); } if (onDialogViewClickListener != null) { onDialogViewClickListener.onDialogViewClick(view, dialog); } }); container.setOnLongClickListener(view -> { if (onLongItemClickListener != null) { onLongItemClickListener.onDialogLongClick(dialog); } if (onDialogViewLongClickListener != null) { onDialogViewLongClickListener.onDialogViewLongClick(view, dialog); } return onLongItemClickListener != null || onDialogViewLongClickListener != null; }); } protected String getDateString(Date date) { return DateFormatter.format(date, DateFormatter.Template.TIME); } protected DialogListStyle getDialogStyle() { return dialogStyle; } protected void setDialogStyle(DialogListStyle dialogStyle) { this.dialogStyle = dialogStyle; applyStyle(); } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/messages/MessageHolders.java ================================================ package com.stfalcon.chatkit.messages; import android.annotation.SuppressLint; import android.text.Spannable; import android.text.method.LinkMovementMethod; import android.util.SparseArray; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.core.view.ViewCompat; import com.stfalcon.chatkit.R; import com.stfalcon.chatkit.commons.ImageLoader; import com.stfalcon.chatkit.commons.ViewHolder; import com.stfalcon.chatkit.commons.models.IMessage; import com.stfalcon.chatkit.commons.models.MessageContentType; import com.stfalcon.chatkit.utils.DateFormatter; import com.stfalcon.chatkit.utils.RoundedImageView; import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Date; import java.util.List; /* * Created by troy379 on 31.03.17. */ @SuppressWarnings("WeakerAccess") public class MessageHolders { private static final short VIEW_TYPE_DATE_HEADER = 130; private static final short VIEW_TYPE_STRING_HEADER = 133; private static final short VIEW_TYPE_TEXT_MESSAGE = 131; private static final short VIEW_TYPE_IMAGE_MESSAGE = 132; private Class> dateHeaderHolder; private Class> stringHeaderHolder; private int dateHeaderLayout; private HolderConfig incomingTextConfig; private HolderConfig outcomingTextConfig; private HolderConfig incomingImageConfig; private HolderConfig outcomingImageConfig; private List customContentTypes = new ArrayList<>(); private ContentChecker contentChecker; public MessageHolders() { this.dateHeaderHolder = DefaultDateHeaderViewHolder.class; this.dateHeaderLayout = R.layout.item_date_header; this.stringHeaderHolder = DefaultStringHeaderViewHolder.class; this.incomingTextConfig = new HolderConfig<>(DefaultIncomingTextMessageViewHolder.class, R.layout.item_incoming_text_message); this.outcomingTextConfig = new HolderConfig<>(DefaultOutcomingTextMessageViewHolder.class, R.layout.item_outcoming_text_message); this.incomingImageConfig = new HolderConfig<>(DefaultIncomingImageMessageViewHolder.class, R.layout.item_incoming_image_message); this.outcomingImageConfig = new HolderConfig<>(DefaultOutcomingImageMessageViewHolder.class, R.layout.item_outcoming_image_message); } /** * Sets both of custom view holder class and layout resource for incoming text message. * * @param holder holder class. * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingTextConfig( @NonNull Class> holder, @LayoutRes int layout) { this.incomingTextConfig.holder = holder; this.incomingTextConfig.layout = layout; return this; } /** * Sets both of custom view holder class and layout resource for incoming text message. * * @param holder holder class. * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingTextConfig( @NonNull Class> holder, @LayoutRes int layout, Object payload) { this.incomingTextConfig.holder = holder; this.incomingTextConfig.layout = layout; this.incomingTextConfig.payload = payload; return this; } /** * Sets custom view holder class for incoming text message. * * @param holder holder class. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingTextHolder( @NonNull Class> holder) { this.incomingTextConfig.holder = holder; return this; } /** * Sets custom view holder class for incoming text message. * * @param holder holder class. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingTextHolder( @NonNull Class> holder, Object payload) { this.incomingTextConfig.holder = holder; this.incomingTextConfig.payload = payload; return this; } /** * Sets custom layout resource for incoming text message. * * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingTextLayout(@LayoutRes int layout) { this.incomingTextConfig.layout = layout; return this; } /** * Sets custom layout resource for incoming text message. * * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingTextLayout(@LayoutRes int layout, Object payload) { this.incomingTextConfig.layout = layout; this.incomingTextConfig.payload = payload; return this; } /** * Sets both of custom view holder class and layout resource for outcoming text message. * * @param holder holder class. * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingTextConfig( @NonNull Class> holder, @LayoutRes int layout) { this.outcomingTextConfig.holder = holder; this.outcomingTextConfig.layout = layout; return this; } /** * Sets both of custom view holder class and layout resource for outcoming text message. * * @param holder holder class. * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingTextConfig( @NonNull Class> holder, @LayoutRes int layout, Object payload) { this.outcomingTextConfig.holder = holder; this.outcomingTextConfig.layout = layout; this.outcomingTextConfig.payload = payload; return this; } /** * Sets custom view holder class for outcoming text message. * * @param holder holder class. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingTextHolder( @NonNull Class> holder) { this.outcomingTextConfig.holder = holder; return this; } /** * Sets custom view holder class for outcoming text message. * * @param holder holder class. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingTextHolder( @NonNull Class> holder, Object payload) { this.outcomingTextConfig.holder = holder; this.outcomingTextConfig.payload = payload; return this; } /** * Sets custom layout resource for outcoming text message. * * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingTextLayout(@LayoutRes int layout) { this.outcomingTextConfig.layout = layout; return this; } /** * Sets custom layout resource for outcoming text message. * * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingTextLayout(@LayoutRes int layout, Object payload) { this.outcomingTextConfig.layout = layout; this.outcomingTextConfig.payload = payload; return this; } /** * Sets both of custom view holder class and layout resource for incoming image message. * * @param holder holder class. * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingImageConfig( @NonNull Class> holder, @LayoutRes int layout) { this.incomingImageConfig.holder = holder; this.incomingImageConfig.layout = layout; return this; } /** * Sets both of custom view holder class and layout resource for incoming image message. * * @param holder holder class. * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingImageConfig( @NonNull Class> holder, @LayoutRes int layout, Object payload) { this.incomingImageConfig.holder = holder; this.incomingImageConfig.layout = layout; this.incomingImageConfig.payload = payload; return this; } /** * Sets custom view holder class for incoming image message. * * @param holder holder class. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingImageHolder( @NonNull Class> holder) { this.incomingImageConfig.holder = holder; return this; } /** * Sets custom view holder class for incoming image message. * * @param holder holder class. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingImageHolder( @NonNull Class> holder, Object payload) { this.incomingImageConfig.holder = holder; this.incomingImageConfig.payload = payload; return this; } /** * Sets custom layout resource for incoming image message. * * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingImageLayout(@LayoutRes int layout) { this.incomingImageConfig.layout = layout; return this; } /** * Sets custom layout resource for incoming image message. * * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setIncomingImageLayout(@LayoutRes int layout, Object payload) { this.incomingImageConfig.layout = layout; this.incomingImageConfig.payload = payload; return this; } /** * Sets both of custom view holder class and layout resource for outcoming image message. * * @param holder holder class. * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingImageConfig( @NonNull Class> holder, @LayoutRes int layout) { this.outcomingImageConfig.holder = holder; this.outcomingImageConfig.layout = layout; return this; } /** * Sets both of custom view holder class and layout resource for outcoming image message. * * @param holder holder class. * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingImageConfig( @NonNull Class> holder, @LayoutRes int layout, Object payload) { this.outcomingImageConfig.holder = holder; this.outcomingImageConfig.layout = layout; this.outcomingImageConfig.payload = payload; return this; } /** * Sets custom view holder class for outcoming image message. * * @param holder holder class. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingImageHolder( @NonNull Class> holder) { this.outcomingImageConfig.holder = holder; return this; } /** * Sets custom view holder class for outcoming image message. * * @param holder holder class. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingImageHolder( @NonNull Class> holder, Object payload) { this.outcomingImageConfig.holder = holder; this.outcomingImageConfig.payload = payload; return this; } /** * Sets custom layout resource for outcoming image message. * * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingImageLayout(@LayoutRes int layout) { this.outcomingImageConfig.layout = layout; return this; } /** * Sets custom layout resource for outcoming image message. * * @param layout layout resource. * @param payload custom data. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setOutcomingImageLayout(@LayoutRes int layout, Object payload) { this.outcomingImageConfig.layout = layout; this.outcomingImageConfig.payload = payload; return this; } /** * Sets both of custom view holder class and layout resource for date header. * * @param holder holder class. * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setDateHeaderConfig( @NonNull Class> holder, @LayoutRes int layout) { this.dateHeaderHolder = holder; this.dateHeaderLayout = layout; return this; } /** * Sets custom view holder class for date header. * * @param holder holder class. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setDateHeaderHolder(@NonNull Class> holder) { this.dateHeaderHolder = holder; return this; } /** * Sets custom layout reource for date header. * * @param layout layout resource. * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders setDateHeaderLayout(@LayoutRes int layout) { this.dateHeaderLayout = layout; return this; } /** * Registers custom content type (e.g. multimedia, events etc.) * * @param type unique id for content type * @param holder holder class for incoming and outcoming messages * @param incomingLayout layout resource for incoming message * @param outcomingLayout layout resource for outcoming message * @param contentChecker {@link ContentChecker} for registered type * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders registerContentType( byte type, @NonNull Class> holder, @LayoutRes int incomingLayout, @LayoutRes int outcomingLayout, @NonNull ContentChecker contentChecker) { return registerContentType(type, holder, incomingLayout, holder, outcomingLayout, contentChecker); } /** * Registers custom content type (e.g. multimedia, events etc.) * * @param type unique id for content type * @param incomingHolder holder class for incoming message * @param outcomingHolder holder class for outcoming message * @param incomingLayout layout resource for incoming message * @param outcomingLayout layout resource for outcoming message * @param contentChecker {@link ContentChecker} for registered type * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders registerContentType( byte type, @NonNull Class> incomingHolder, @LayoutRes int incomingLayout, @NonNull Class> outcomingHolder, @LayoutRes int outcomingLayout, @NonNull ContentChecker contentChecker) { if (type == 0) throw new IllegalArgumentException("content type must be greater or less than '0'!"); customContentTypes.add( new ContentTypeConfig<>(type, new HolderConfig<>(incomingHolder, incomingLayout), new HolderConfig<>(outcomingHolder, outcomingLayout))); this.contentChecker = contentChecker; return this; } /** * Registers custom content type (e.g. multimedia, events etc.) * * @param type unique id for content type * @param incomingHolder holder class for incoming message * @param outcomingHolder holder class for outcoming message * @param incomingPayload payload for incoming message * @param outcomingPayload payload for outcoming message * @param incomingLayout layout resource for incoming message * @param outcomingLayout layout resource for outcoming message * @param contentChecker {@link MessageHolders.ContentChecker} for registered type * @return {@link MessageHolders} for subsequent configuration. */ public MessageHolders registerContentType( byte type, @NonNull Class> incomingHolder, Object incomingPayload, @LayoutRes int incomingLayout, @NonNull Class> outcomingHolder, Object outcomingPayload, @LayoutRes int outcomingLayout, @NonNull MessageHolders.ContentChecker contentChecker) { if (type == 0) throw new IllegalArgumentException("content type must be greater or less than '0'!"); customContentTypes.add( new MessageHolders.ContentTypeConfig<>(type, new HolderConfig<>(incomingHolder, incomingLayout, incomingPayload), new HolderConfig<>(outcomingHolder, outcomingLayout, outcomingPayload))); this.contentChecker = contentChecker; return this; } /* * INTERFACES * */ /** * The interface, which contains logic for checking the availability of content. */ public interface ContentChecker { /** * Checks the availability of content. * * @param message current message in list. * @param type content type, for which content availability is determined. * @return weather the message has content for the current message. */ boolean hasContentFor(MESSAGE message, byte type); } /* * PRIVATE METHODS * */ protected ViewHolder getHolder(ViewGroup parent, int viewType, MessagesListStyle messagesListStyle) { switch (viewType) { case VIEW_TYPE_DATE_HEADER: return getHolder(parent, dateHeaderLayout, dateHeaderHolder, messagesListStyle, null); case VIEW_TYPE_STRING_HEADER: return getHolder(parent, dateHeaderLayout, stringHeaderHolder, messagesListStyle, null); case VIEW_TYPE_TEXT_MESSAGE: return getHolder(parent, incomingTextConfig, messagesListStyle); case -VIEW_TYPE_TEXT_MESSAGE: return getHolder(parent, outcomingTextConfig, messagesListStyle); case VIEW_TYPE_IMAGE_MESSAGE: return getHolder(parent, incomingImageConfig, messagesListStyle); case -VIEW_TYPE_IMAGE_MESSAGE: return getHolder(parent, outcomingImageConfig, messagesListStyle); default: for (ContentTypeConfig typeConfig : customContentTypes) { if (Math.abs(typeConfig.type) == Math.abs(viewType)) { if (viewType > 0) return getHolder(parent, typeConfig.incomingConfig, messagesListStyle); else return getHolder(parent, typeConfig.outcomingConfig, messagesListStyle); } } } throw new IllegalStateException("Wrong message view type. Please, report this issue on GitHub with full stacktrace in description."); } @SuppressWarnings("unchecked") protected void bind(final ViewHolder holder, final Object item, boolean isSelected, final ImageLoader imageLoader, final View.OnClickListener onMessageClickListener, final View.OnLongClickListener onMessageLongClickListener, final View.OnFocusChangeListener onMessageFocusChangeListener, final DateFormatter.Formatter dateHeadersFormatter, final SparseArray clickListenersArray) { if (item instanceof IMessage) { ((MessageHolders.BaseMessageViewHolder) holder).isSelected = isSelected; ((MessageHolders.BaseMessageViewHolder) holder).imageLoader = imageLoader; holder.itemView.setOnLongClickListener(onMessageLongClickListener); holder.itemView.setOnClickListener(onMessageClickListener); holder.itemView.setOnFocusChangeListener(onMessageFocusChangeListener); for (int i = 0; i < clickListenersArray.size(); i++) { final int key = clickListenersArray.keyAt(i); final View view = holder.itemView.findViewById(key); if (view != null) { view.setOnClickListener(v -> clickListenersArray.get(key).onMessageViewClick(view, (IMessage) item)); } } } else if (item instanceof Date) { ((MessageHolders.DefaultDateHeaderViewHolder) holder).dateHeadersFormatter = dateHeadersFormatter; } holder.onBind(item); } protected int getViewType(Object item, String senderId) { boolean isOutcoming = false; int viewType; if (item instanceof IMessage) { IMessage message = (IMessage) item; isOutcoming = message.getUser().getId().contentEquals(senderId); viewType = getContentViewType(message); } else if (item instanceof String) { viewType = VIEW_TYPE_STRING_HEADER; } else viewType = VIEW_TYPE_DATE_HEADER; return isOutcoming ? viewType * -1 : viewType; } private ViewHolder getHolder(ViewGroup parent, HolderConfig holderConfig, MessagesListStyle style) { return getHolder(parent, holderConfig.layout, holderConfig.holder, style, holderConfig.payload); } private ViewHolder getHolder(ViewGroup parent, @LayoutRes int layout, Class holderClass, MessagesListStyle style, Object payload) { View v = LayoutInflater.from(parent.getContext()).inflate(layout, parent, false); try { Constructor constructor = null; HOLDER holder; try { constructor = holderClass.getDeclaredConstructor(View.class, Object.class); constructor.setAccessible(true); holder = constructor.newInstance(v, payload); } catch (NoSuchMethodException e) { constructor = holderClass.getDeclaredConstructor(View.class); constructor.setAccessible(true); holder = constructor.newInstance(v); } if (holder instanceof DefaultMessageViewHolder && style != null) { ((DefaultMessageViewHolder) holder).applyStyle(style); } return holder; } catch (Exception e) { throw new UnsupportedOperationException("Somehow we couldn't create the ViewHolder for message. Please, report this issue on GitHub with full stacktrace in description.", e); } } @SuppressWarnings("unchecked") private short getContentViewType(IMessage message) { if (message instanceof MessageContentType.Image && ((MessageContentType.Image) message).getImageUrl() != null) { return VIEW_TYPE_IMAGE_MESSAGE; } // other default types will be here if (message instanceof MessageContentType) { for (int i = 0; i < customContentTypes.size(); i++) { ContentTypeConfig config = customContentTypes.get(i); if (contentChecker == null) { throw new IllegalArgumentException("ContentChecker cannot be null when using custom content types!"); } boolean hasContent = contentChecker.hasContentFor(message, config.type); if (hasContent) return config.type; } } return VIEW_TYPE_TEXT_MESSAGE; } /* * HOLDERS * */ /** * The base class for view holders for incoming and outcoming message. * You can extend it to create your own holder in conjuction with custom layout or even using default layout. */ public static abstract class BaseMessageViewHolder extends ViewHolder { boolean isSelected; /** * For setting custom data to ViewHolder */ protected Object payload; /** * Callback for implementing images loading in message list */ protected ImageLoader imageLoader; @Deprecated public BaseMessageViewHolder(View itemView) { super(itemView); } public BaseMessageViewHolder(View itemView, Object payload) { super(itemView); this.payload = payload; } /** * Returns whether is item selected * * @return weather is item selected. */ public boolean isSelected() { return isSelected; } /** * Returns weather is selection mode enabled * * @return weather is selection mode enabled. */ public boolean isSelectionModeEnabled() { return MessagesListAdapter.isSelectionModeEnabled; } /** * Getter for {@link #imageLoader} * * @return image loader interface. */ public ImageLoader getImageLoader() { return imageLoader; } protected void configureLinksBehavior(final TextView text) { text.setLinksClickable(false); text.setMovementMethod(new LinkMovementMethod() { @Override public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { boolean result = false; if (!MessagesListAdapter.isSelectionModeEnabled) { result = super.onTouchEvent(widget, buffer, event); } itemView.onTouchEvent(event); return result; } }); } } /** * Default view holder implementation for incoming text message */ public static class IncomingTextMessageViewHolder extends BaseIncomingMessageViewHolder { protected ViewGroup bubble; protected TextView text; // See: item_incoming_text_message.xml protected RelativeLayout wrapper; @Deprecated public IncomingTextMessageViewHolder(View itemView) { super(itemView); init(itemView); } public IncomingTextMessageViewHolder(View itemView, Object payload) { super(itemView, payload); init(itemView); } @Override public void onBind(MESSAGE message) { super.onBind(message); if (bubble != null) { bubble.setSelected(isSelected()); } if (text != null) { text.setText(message.getText()); } } @SuppressLint("WrongConstant") @Override public void applyStyle(MessagesListStyle style) { super.applyStyle(style); if (bubble != null) { bubble.setPadding(style.getIncomingDefaultBubblePaddingLeft(), style.getIncomingDefaultBubblePaddingTop(), style.getIncomingDefaultBubblePaddingRight(), style.getIncomingDefaultBubblePaddingBottom()); ViewCompat.setBackground(bubble, style.getIncomingBubbleDrawable()); } if (text != null) { text.setTextColor(style.getIncomingTextColor()); text.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getIncomingTextSize()); text.setTypeface(text.getTypeface(), style.getIncomingTextStyle()); text.setAutoLinkMask(style.getTextAutoLinkMask()); text.setLinkTextColor(style.getIncomingTextLinkColor()); // Link configurator makes textView focusable. if (style.isMessageFocusable()) { configureLinksBehavior(text); //text.setFocusable(true); //text.setFocusableInTouchMode(true); //text.setClickable(true); //text.setBackgroundResource(R.drawable.bgchange); //text.requestFocus(); wrapper.setFocusable(true); //wrapper.setFocusableInTouchMode(true); //wrapper.setClickable(true); //wrapper.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); //wrapper.requestFocus(); //wrapper.setBackgroundResource(R.drawable.bgchange); //wrapper.setOnFocusChangeListener((v, hasFocus) -> { // //text.setBackgroundResource(hasFocus ? R.color.tg_selected_bg : R.color.transparent); // bubble.setBackgroundResource(hasFocus ? R.drawable.shape_incoming_message_focused : R.drawable.shape_incoming_message); //}); } } } private void init(View itemView) { bubble = itemView.findViewById(R.id.bubble); text = itemView.findViewById(R.id.messageText); wrapper = (RelativeLayout) itemView; } } /** * Default view holder implementation for outcoming text message */ public static class OutcomingTextMessageViewHolder extends BaseOutcomingMessageViewHolder { protected ViewGroup bubble; protected TextView text; // See: item_outcoming_text_message.xml protected RelativeLayout wrapper; @Deprecated public OutcomingTextMessageViewHolder(View itemView) { super(itemView); init(itemView); } public OutcomingTextMessageViewHolder(View itemView, Object payload) { super(itemView, payload); init(itemView); } @Override public void onBind(MESSAGE message) { super.onBind(message); if (bubble != null) { bubble.setSelected(isSelected()); } if (text != null) { text.setText(message.getText()); } } @SuppressLint("WrongConstant") @Override public final void applyStyle(MessagesListStyle style) { super.applyStyle(style); if (bubble != null) { bubble.setPadding(style.getOutcomingDefaultBubblePaddingLeft(), style.getOutcomingDefaultBubblePaddingTop(), style.getOutcomingDefaultBubblePaddingRight(), style.getOutcomingDefaultBubblePaddingBottom()); ViewCompat.setBackground(bubble, style.getOutcomingBubbleDrawable()); } if (text != null) { text.setTextColor(style.getOutcomingTextColor()); text.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getOutcomingTextSize()); text.setTypeface(text.getTypeface(), style.getOutcomingTextStyle()); text.setAutoLinkMask(style.getTextAutoLinkMask()); text.setLinkTextColor(style.getOutcomingTextLinkColor()); // Link configurator makes textView focusable. if (style.isMessageFocusable()) { configureLinksBehavior(text); wrapper.setFocusable(true); wrapper.setFocusableInTouchMode(true); wrapper.setClickable(true); wrapper.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); wrapper.requestFocus(); } } } private void init(View itemView) { bubble = itemView.findViewById(R.id.bubble); text = itemView.findViewById(R.id.messageText); wrapper = (RelativeLayout) itemView; } } /** * Default view holder implementation for incoming image message */ public static class IncomingImageMessageViewHolder extends BaseIncomingMessageViewHolder { protected ImageView image; protected View imageOverlay; @Deprecated public IncomingImageMessageViewHolder(View itemView) { super(itemView); init(itemView); } public IncomingImageMessageViewHolder(View itemView, Object payload) { super(itemView, payload); init(itemView); } @Override public void onBind(MESSAGE message) { super.onBind(message); if (image != null && imageLoader != null) { imageLoader.loadImage(image, message.getImageUrl(), getPayloadForImageLoader(message)); } if (imageOverlay != null) { imageOverlay.setSelected(isSelected()); } } @SuppressLint("WrongConstant") @Override public final void applyStyle(MessagesListStyle style) { super.applyStyle(style); if (time != null) { time.setTextColor(style.getIncomingImageTimeTextColor()); time.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getIncomingImageTimeTextSize()); time.setTypeface(time.getTypeface(), style.getIncomingImageTimeTextStyle()); } if (imageOverlay != null) { ViewCompat.setBackground(imageOverlay, style.getIncomingImageOverlayDrawable()); } } /** * Override this method to have ability to pass custom data in ImageLoader for loading image(not avatar). * * @param message Message with image */ protected Object getPayloadForImageLoader(MESSAGE message) { return null; } private void init(View itemView) { image = itemView.findViewById(R.id.image); imageOverlay = itemView.findViewById(R.id.imageOverlay); if (image instanceof RoundedImageView) { ((RoundedImageView) image).setCorners( R.dimen.message_bubble_corners_radius, R.dimen.message_bubble_corners_radius, R.dimen.message_bubble_corners_radius, 0 ); } } } /** * Default view holder implementation for outcoming image message */ public static class OutcomingImageMessageViewHolder extends BaseOutcomingMessageViewHolder { protected ImageView image; protected View imageOverlay; @Deprecated public OutcomingImageMessageViewHolder(View itemView) { super(itemView); init(itemView); } public OutcomingImageMessageViewHolder(View itemView, Object payload) { super(itemView, payload); init(itemView); } @Override public void onBind(MESSAGE message) { super.onBind(message); if (image != null && imageLoader != null) { imageLoader.loadImage(image, message.getImageUrl(), getPayloadForImageLoader(message)); } if (imageOverlay != null) { imageOverlay.setSelected(isSelected()); } } @SuppressLint("WrongConstant") @Override public final void applyStyle(MessagesListStyle style) { super.applyStyle(style); if (time != null) { time.setTextColor(style.getOutcomingImageTimeTextColor()); time.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getOutcomingImageTimeTextSize()); time.setTypeface(time.getTypeface(), style.getOutcomingImageTimeTextStyle()); } if (imageOverlay != null) { ViewCompat.setBackground(imageOverlay, style.getOutcomingImageOverlayDrawable()); } } /** * Override this method to have ability to pass custom data in ImageLoader for loading image(not avatar). * * @param message Message with image */ protected Object getPayloadForImageLoader(MESSAGE message) { return null; } private void init(View itemView) { image = itemView.findViewById(R.id.image); imageOverlay = itemView.findViewById(R.id.imageOverlay); if (image instanceof RoundedImageView) { ((RoundedImageView) image).setCorners( R.dimen.message_bubble_corners_radius, R.dimen.message_bubble_corners_radius, 0, R.dimen.message_bubble_corners_radius ); } } } /** * Default view holder implementation for date header */ public static class DefaultDateHeaderViewHolder extends ViewHolder implements DefaultMessageViewHolder { protected TextView text; protected String dateFormat; protected DateFormatter.Formatter dateHeadersFormatter; public DefaultDateHeaderViewHolder(View itemView) { super(itemView); text = itemView.findViewById(R.id.messageText); } @Override public void onBind(Date date) { if (text != null) { String formattedDate = null; if (dateHeadersFormatter != null) formattedDate = dateHeadersFormatter.format(date); text.setText(formattedDate == null ? DateFormatter.format(date, dateFormat) : formattedDate); } } @SuppressLint("WrongConstant") @Override public void applyStyle(MessagesListStyle style) { if (text != null) { text.setTextColor(style.getDateHeaderTextColor()); text.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getDateHeaderTextSize()); text.setTypeface(text.getTypeface(), style.getDateHeaderTextStyle()); text.setPadding(style.getDateHeaderPadding(), style.getDateHeaderPadding(), style.getDateHeaderPadding(), style.getDateHeaderPadding()); } dateFormat = style.getDateHeaderFormat(); dateFormat = dateFormat == null ? DateFormatter.Template.STRING_DAY_MONTH_YEAR.get() : dateFormat; } } public static class DefaultStringHeaderViewHolder extends ViewHolder implements DefaultMessageViewHolder { protected TextView text; public DefaultStringHeaderViewHolder(View itemView) { super(itemView); text = itemView.findViewById(R.id.messageText); } @Override public void onBind(String message) { if (text != null) { text.setText(message); } } @SuppressLint("WrongConstant") @Override public void applyStyle(MessagesListStyle style) { if (text != null) { text.setTextColor(style.getDateHeaderTextColor()); text.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getDateHeaderTextSize()); text.setTypeface(text.getTypeface(), style.getDateHeaderTextStyle()); text.setPadding(style.getDateHeaderPadding(), style.getDateHeaderPadding(), style.getDateHeaderPadding(), style.getDateHeaderPadding()); } } } /** * Base view holder for incoming message */ public abstract static class BaseIncomingMessageViewHolder extends BaseMessageViewHolder implements DefaultMessageViewHolder { protected TextView time; protected ImageView userAvatar; @Deprecated public BaseIncomingMessageViewHolder(View itemView) { super(itemView); init(itemView); } public BaseIncomingMessageViewHolder(View itemView, Object payload) { super(itemView, payload); init(itemView); } @Override public void onBind(MESSAGE message) { if (time != null) { time.setText(DateFormatter.format(message.getCreatedAt(), DateFormatter.Template.TIME)); } if (userAvatar != null) { boolean isAvatarExists = imageLoader != null && message.getUser().getAvatar() != null && !message.getUser().getAvatar().isEmpty(); userAvatar.setVisibility(isAvatarExists ? View.VISIBLE : View.GONE); if (isAvatarExists) { imageLoader.loadImage(userAvatar, message.getUser().getAvatar(), null); } } } @SuppressLint("WrongConstant") @Override public void applyStyle(MessagesListStyle style) { if (time != null) { time.setVisibility(style.showIncomingTime() ? View.VISIBLE : View.GONE); time.setTextColor(style.getIncomingTimeTextColor()); time.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getIncomingTimeTextSize()); time.setTypeface(time.getTypeface(), style.getIncomingTimeTextStyle()); } if (userAvatar != null) { userAvatar.getLayoutParams().width = style.getIncomingAvatarWidth(); userAvatar.getLayoutParams().height = style.getIncomingAvatarHeight(); } } private void init(View itemView) { time = itemView.findViewById(R.id.messageTime); userAvatar = itemView.findViewById(R.id.messageUserAvatar); } } /** * Base view holder for outcoming message */ public abstract static class BaseOutcomingMessageViewHolder extends BaseMessageViewHolder implements DefaultMessageViewHolder { protected TextView time; @Deprecated public BaseOutcomingMessageViewHolder(View itemView) { super(itemView); init(itemView); } public BaseOutcomingMessageViewHolder(View itemView, Object payload) { super(itemView, payload); init(itemView); } @Override public void onBind(MESSAGE message) { if (time != null) { time.setText(DateFormatter.format(message.getCreatedAt(), DateFormatter.Template.TIME)); } } @SuppressLint("WrongConstant") @Override public void applyStyle(MessagesListStyle style) { if (time != null) { time.setVisibility(style.showOutcomingTime() ? View.VISIBLE : View.GONE); time.setTextColor(style.getOutcomingTimeTextColor()); time.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getOutcomingTimeTextSize()); time.setTypeface(time.getTypeface(), style.getOutcomingTimeTextStyle()); } } private void init(View itemView) { time = itemView.findViewById(R.id.messageTime); } } /* * DEFAULTS * */ interface DefaultMessageViewHolder { void applyStyle(MessagesListStyle style); } private static class DefaultIncomingTextMessageViewHolder extends IncomingTextMessageViewHolder { public DefaultIncomingTextMessageViewHolder(View itemView) { super(itemView, null); } } private static class DefaultOutcomingTextMessageViewHolder extends OutcomingTextMessageViewHolder { public DefaultOutcomingTextMessageViewHolder(View itemView) { super(itemView, null); } } private static class DefaultIncomingImageMessageViewHolder extends IncomingImageMessageViewHolder { public DefaultIncomingImageMessageViewHolder(View itemView) { super(itemView, null); } } private static class DefaultOutcomingImageMessageViewHolder extends OutcomingImageMessageViewHolder { public DefaultOutcomingImageMessageViewHolder(View itemView) { super(itemView, null); } } private static class ContentTypeConfig { private byte type; private HolderConfig incomingConfig; private HolderConfig outcomingConfig; private ContentTypeConfig( byte type, HolderConfig incomingConfig, HolderConfig outcomingConfig) { this.type = type; this.incomingConfig = incomingConfig; this.outcomingConfig = outcomingConfig; } } private static class HolderConfig { protected Class> holder; protected int layout; protected Object payload; HolderConfig(Class> holder, int layout) { this.holder = holder; this.layout = layout; } HolderConfig(Class> holder, int layout, Object payload) { this.holder = holder; this.layout = layout; this.payload = payload; } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/messages/MessageInput.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.messages; import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Editable; import android.text.TextWatcher; import android.util.AttributeSet; import android.util.TypedValue; import android.view.View; import android.widget.EditText; import android.widget.ImageButton; import android.widget.RelativeLayout; import android.widget.Space; import android.widget.TextView; import androidx.core.view.ViewCompat; import com.stfalcon.chatkit.R; import java.lang.reflect.Field; /** * Component for input outcoming messages */ @SuppressWarnings({"WeakerAccess", "unused"}) public class MessageInput extends RelativeLayout implements View.OnClickListener, TextWatcher, View.OnFocusChangeListener { protected EditText messageInput; protected ImageButton messageSendButton; protected ImageButton attachmentButton; protected Space sendButtonSpace, attachmentButtonSpace; private CharSequence input; private InputListener inputListener; private AttachmentsListener attachmentsListener; private boolean isTyping; private TypingListener typingListener; private int delayTypingStatusMillis; private Runnable typingTimerRunnable = new Runnable() { @Override public void run() { if (isTyping) { isTyping = false; if (typingListener != null) typingListener.onStopTyping(); } } }; private boolean lastFocus; public MessageInput(Context context) { super(context); init(context); } public MessageInput(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public MessageInput(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } /** * Sets callback for 'submit' button. * * @param inputListener input callback */ public void setInputListener(InputListener inputListener) { this.inputListener = inputListener; } /** * Sets callback for 'add' button. * * @param attachmentsListener input callback */ public void setAttachmentsListener(AttachmentsListener attachmentsListener) { this.attachmentsListener = attachmentsListener; } /** * Returns EditText for messages input * * @return EditText */ public EditText getInputEditText() { return messageInput; } /** * Returns `submit` button * * @return ImageButton */ public ImageButton getButton() { return messageSendButton; } @Override public void onClick(View view) { int id = view.getId(); if (id == R.id.messageSendButton) { boolean isSubmitted = onSubmit(); if (isSubmitted) { messageInput.setText(""); } removeCallbacks(typingTimerRunnable); post(typingTimerRunnable); } else if (id == R.id.attachmentButton) { onAddAttachments(); } } /** * This method is called to notify you that, within s, * the count characters beginning at start have just replaced old text that had length before */ @Override public void onTextChanged(CharSequence s, int start, int count, int after) { input = s; messageSendButton.setEnabled(input.length() > 0); if (s.length() > 0) { if (!isTyping) { isTyping = true; if (typingListener != null) typingListener.onStartTyping(); } removeCallbacks(typingTimerRunnable); postDelayed(typingTimerRunnable, delayTypingStatusMillis); } } /** * This method is called to notify you that, within s, * the count characters beginning at start are about to be replaced by new text with length after. */ @Override public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { //do nothing } /** * This method is called to notify you that, somewhere within s, the text has been changed. */ @Override public void afterTextChanged(Editable editable) { //do nothing } @Override public void onFocusChange(View v, boolean hasFocus) { if (lastFocus && !hasFocus && typingListener != null) { typingListener.onStopTyping(); } lastFocus = hasFocus; } private boolean onSubmit() { return inputListener != null && inputListener.onSubmit(input); } private void onAddAttachments() { if (attachmentsListener != null) attachmentsListener.onAddAttachments(); } private void init(Context context, AttributeSet attrs) { init(context); MessageInputStyle style = MessageInputStyle.parse(context, attrs); this.messageInput.setMaxLines(style.getInputMaxLines()); this.messageInput.setHint(style.getInputHint()); this.messageInput.setText(style.getInputText()); this.messageInput.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getInputTextSize()); this.messageInput.setTextColor(style.getInputTextColor()); this.messageInput.setHintTextColor(style.getInputHintColor()); ViewCompat.setBackground(this.messageInput, style.getInputBackground()); setCursor(style.getInputCursorDrawable()); this.attachmentButton.setVisibility(style.showAttachmentButton() ? VISIBLE : GONE); this.attachmentButton.setImageDrawable(style.getAttachmentButtonIcon()); this.attachmentButton.getLayoutParams().width = style.getAttachmentButtonWidth(); this.attachmentButton.getLayoutParams().height = style.getAttachmentButtonHeight(); ViewCompat.setBackground(this.attachmentButton, style.getAttachmentButtonBackground()); this.attachmentButtonSpace.setVisibility(style.showAttachmentButton() ? VISIBLE : GONE); this.attachmentButtonSpace.getLayoutParams().width = style.getAttachmentButtonMargin(); this.messageSendButton.setImageDrawable(style.getInputButtonIcon()); this.messageSendButton.getLayoutParams().width = style.getInputButtonWidth(); this.messageSendButton.getLayoutParams().height = style.getInputButtonHeight(); ViewCompat.setBackground(messageSendButton, style.getInputButtonBackground()); this.sendButtonSpace.getLayoutParams().width = style.getInputButtonMargin(); if (getPaddingLeft() == 0 && getPaddingRight() == 0 && getPaddingTop() == 0 && getPaddingBottom() == 0) { setPadding( style.getInputDefaultPaddingLeft(), style.getInputDefaultPaddingTop(), style.getInputDefaultPaddingRight(), style.getInputDefaultPaddingBottom() ); } this.delayTypingStatusMillis = style.getDelayTypingStatus(); } private void init(Context context) { inflate(context, R.layout.view_message_input, this); messageInput = findViewById(R.id.messageInput); messageSendButton = findViewById(R.id.messageSendButton); attachmentButton = findViewById(R.id.attachmentButton); sendButtonSpace = findViewById(R.id.sendButtonSpace); attachmentButtonSpace = findViewById(R.id.attachmentButtonSpace); messageSendButton.setOnClickListener(this); attachmentButton.setOnClickListener(this); messageInput.addTextChangedListener(this); messageInput.setText(""); messageInput.setOnFocusChangeListener(this); } private void setCursor(Drawable drawable) { if (drawable == null) return; try { final Field drawableResField = TextView.class.getDeclaredField("mCursorDrawableRes"); drawableResField.setAccessible(true); final Object drawableFieldOwner; final Class drawableFieldClass; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { drawableFieldOwner = this.messageInput; drawableFieldClass = TextView.class; } else { final Field editorField = TextView.class.getDeclaredField("mEditor"); editorField.setAccessible(true); drawableFieldOwner = editorField.get(this.messageInput); drawableFieldClass = drawableFieldOwner.getClass(); } final Field drawableField = drawableFieldClass.getDeclaredField("mCursorDrawable"); drawableField.setAccessible(true); drawableField.set(drawableFieldOwner, new Drawable[]{drawable, drawable}); } catch (Exception ignored) { } } public void setTypingListener(TypingListener typingListener) { this.typingListener = typingListener; } /** * Interface definition for a callback to be invoked when user pressed 'submit' button */ public interface InputListener { /** * Fires when user presses 'send' button. * * @param input input entered by user * @return if input text is valid, you must return {@code true} and input will be cleared, otherwise return false. */ boolean onSubmit(CharSequence input); } /** * Interface definition for a callback to be invoked when user presses 'add' button */ public interface AttachmentsListener { /** * Fires when user presses 'add' button. */ void onAddAttachments(); } /** * Interface definition for a callback to be invoked when user typing */ public interface TypingListener { /** * Fires when user presses start typing */ void onStartTyping(); /** * Fires when user presses stop typing */ void onStopTyping(); } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/messages/MessageInputStyle.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.messages; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.core.graphics.drawable.DrawableCompat; import com.stfalcon.chatkit.R; import com.stfalcon.chatkit.commons.Style; /** * Style for MessageInputStyle customization by xml attributes */ @SuppressWarnings("WeakerAccess") class MessageInputStyle extends Style { private static final int DEFAULT_MAX_LINES = 5; private static final int DEFAULT_DELAY_TYPING_STATUS = 1500; private boolean showAttachmentButton; private int attachmentButtonBackground; private int attachmentButtonDefaultBgColor; private int attachmentButtonDefaultBgPressedColor; private int attachmentButtonDefaultBgDisabledColor; private int attachmentButtonIcon; private int attachmentButtonDefaultIconColor; private int attachmentButtonDefaultIconPressedColor; private int attachmentButtonDefaultIconDisabledColor; private int attachmentButtonWidth; private int attachmentButtonHeight; private int attachmentButtonMargin; private int inputButtonBackground; private int inputButtonDefaultBgColor; private int inputButtonDefaultBgPressedColor; private int inputButtonDefaultBgDisabledColor; private int inputButtonIcon; private int inputButtonDefaultIconColor; private int inputButtonDefaultIconPressedColor; private int inputButtonDefaultIconDisabledColor; private int inputButtonWidth; private int inputButtonHeight; private int inputButtonMargin; private int inputMaxLines; private String inputHint; private String inputText; private int inputTextSize; private int inputTextColor; private int inputHintColor; private Drawable inputBackground; private Drawable inputCursorDrawable; private int inputDefaultPaddingLeft; private int inputDefaultPaddingRight; private int inputDefaultPaddingTop; private int inputDefaultPaddingBottom; private int delayTypingStatus; static MessageInputStyle parse(Context context, AttributeSet attrs) { MessageInputStyle style = new MessageInputStyle(context, attrs); TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MessageInput); style.showAttachmentButton = typedArray.getBoolean(R.styleable.MessageInput_showAttachmentButton, false); style.attachmentButtonBackground = typedArray.getResourceId(R.styleable.MessageInput_attachmentButtonBackground, -1); style.attachmentButtonDefaultBgColor = typedArray.getColor(R.styleable.MessageInput_attachmentButtonDefaultBgColor, style.getColor(R.color.white_four)); style.attachmentButtonDefaultBgPressedColor = typedArray.getColor(R.styleable.MessageInput_attachmentButtonDefaultBgPressedColor, style.getColor(R.color.white_five)); style.attachmentButtonDefaultBgDisabledColor = typedArray.getColor(R.styleable.MessageInput_attachmentButtonDefaultBgDisabledColor, style.getColor(R.color.transparent)); style.attachmentButtonIcon = typedArray.getResourceId(R.styleable.MessageInput_attachmentButtonIcon, -1); style.attachmentButtonDefaultIconColor = typedArray.getColor(R.styleable.MessageInput_attachmentButtonDefaultIconColor, style.getColor(R.color.cornflower_blue_two)); style.attachmentButtonDefaultIconPressedColor = typedArray.getColor(R.styleable.MessageInput_attachmentButtonDefaultIconPressedColor, style.getColor(R.color.cornflower_blue_two_dark)); style.attachmentButtonDefaultIconDisabledColor = typedArray.getColor(R.styleable.MessageInput_attachmentButtonDefaultIconDisabledColor, style.getColor(R.color.cornflower_blue_light_40)); style.attachmentButtonWidth = typedArray.getDimensionPixelSize(R.styleable.MessageInput_attachmentButtonWidth, style.getDimension(R.dimen.input_button_width)); style.attachmentButtonHeight = typedArray.getDimensionPixelSize(R.styleable.MessageInput_attachmentButtonHeight, style.getDimension(R.dimen.input_button_height)); style.attachmentButtonMargin = typedArray.getDimensionPixelSize(R.styleable.MessageInput_attachmentButtonMargin, style.getDimension(R.dimen.input_button_margin)); style.inputButtonBackground = typedArray.getResourceId(R.styleable.MessageInput_inputButtonBackground, -1); style.inputButtonDefaultBgColor = typedArray.getColor(R.styleable.MessageInput_inputButtonDefaultBgColor, style.getColor(R.color.cornflower_blue_two)); style.inputButtonDefaultBgPressedColor = typedArray.getColor(R.styleable.MessageInput_inputButtonDefaultBgPressedColor, style.getColor(R.color.cornflower_blue_two_dark)); style.inputButtonDefaultBgDisabledColor = typedArray.getColor(R.styleable.MessageInput_inputButtonDefaultBgDisabledColor, style.getColor(R.color.white_four)); style.inputButtonIcon = typedArray.getResourceId(R.styleable.MessageInput_inputButtonIcon, -1); style.inputButtonDefaultIconColor = typedArray.getColor(R.styleable.MessageInput_inputButtonDefaultIconColor, style.getColor(R.color.white)); style.inputButtonDefaultIconPressedColor = typedArray.getColor(R.styleable.MessageInput_inputButtonDefaultIconPressedColor, style.getColor(R.color.white)); style.inputButtonDefaultIconDisabledColor = typedArray.getColor(R.styleable.MessageInput_inputButtonDefaultIconDisabledColor, style.getColor(R.color.warm_grey)); style.inputButtonWidth = typedArray.getDimensionPixelSize(R.styleable.MessageInput_inputButtonWidth, style.getDimension(R.dimen.input_button_width)); style.inputButtonHeight = typedArray.getDimensionPixelSize(R.styleable.MessageInput_inputButtonHeight, style.getDimension(R.dimen.input_button_height)); style.inputButtonMargin = typedArray.getDimensionPixelSize(R.styleable.MessageInput_inputButtonMargin, style.getDimension(R.dimen.input_button_margin)); style.inputMaxLines = typedArray.getInt(R.styleable.MessageInput_inputMaxLines, DEFAULT_MAX_LINES); style.inputHint = typedArray.getString(R.styleable.MessageInput_inputHint); style.inputText = typedArray.getString(R.styleable.MessageInput_inputText); style.inputTextSize = typedArray.getDimensionPixelSize(R.styleable.MessageInput_inputTextSize, style.getDimension(R.dimen.input_text_size)); style.inputTextColor = typedArray.getColor(R.styleable.MessageInput_inputTextColor, style.getColor(R.color.dark_grey_two)); style.inputHintColor = typedArray.getColor(R.styleable.MessageInput_inputHintColor, style.getColor(R.color.warm_grey_three)); style.inputBackground = typedArray.getDrawable(R.styleable.MessageInput_inputBackground); style.inputCursorDrawable = typedArray.getDrawable(R.styleable.MessageInput_inputCursorDrawable); style.delayTypingStatus = typedArray.getInt(R.styleable.MessageInput_delayTypingStatus, DEFAULT_DELAY_TYPING_STATUS); typedArray.recycle(); style.inputDefaultPaddingLeft = style.getDimension(R.dimen.input_padding_left); style.inputDefaultPaddingRight = style.getDimension(R.dimen.input_padding_right); style.inputDefaultPaddingTop = style.getDimension(R.dimen.input_padding_top); style.inputDefaultPaddingBottom = style.getDimension(R.dimen.input_padding_bottom); return style; } private MessageInputStyle(Context context, AttributeSet attrs) { super(context, attrs); } private Drawable getSelector(@ColorInt int normalColor, @ColorInt int pressedColor, @ColorInt int disabledColor, @DrawableRes int shape) { Drawable drawable = DrawableCompat.wrap(getVectorDrawable(shape)).mutate(); DrawableCompat.setTintList( drawable, new ColorStateList( new int[][]{ new int[]{android.R.attr.state_enabled, -android.R.attr.state_pressed}, new int[]{android.R.attr.state_enabled, android.R.attr.state_pressed}, new int[]{-android.R.attr.state_enabled} }, new int[]{normalColor, pressedColor, disabledColor} )); return drawable; } protected boolean showAttachmentButton() { return showAttachmentButton; } protected Drawable getAttachmentButtonBackground() { if (attachmentButtonBackground == -1) { return getSelector(attachmentButtonDefaultBgColor, attachmentButtonDefaultBgPressedColor, attachmentButtonDefaultBgDisabledColor, R.drawable.mask); } else { return getDrawable(attachmentButtonBackground); } } protected Drawable getAttachmentButtonIcon() { if (attachmentButtonIcon == -1) { return getSelector(attachmentButtonDefaultIconColor, attachmentButtonDefaultIconPressedColor, attachmentButtonDefaultIconDisabledColor, R.drawable.ic_add_attachment); } else { return getDrawable(attachmentButtonIcon); } } protected int getAttachmentButtonWidth() { return attachmentButtonWidth; } protected int getAttachmentButtonHeight() { return attachmentButtonHeight; } protected int getAttachmentButtonMargin() { return attachmentButtonMargin; } protected Drawable getInputButtonBackground() { if (inputButtonBackground == -1) { return getSelector(inputButtonDefaultBgColor, inputButtonDefaultBgPressedColor, inputButtonDefaultBgDisabledColor, R.drawable.mask); } else { return getDrawable(inputButtonBackground); } } protected Drawable getInputButtonIcon() { if (inputButtonIcon == -1) { return getSelector(inputButtonDefaultIconColor, inputButtonDefaultIconPressedColor, inputButtonDefaultIconDisabledColor, R.drawable.ic_send); } else { return getDrawable(inputButtonIcon); } } protected int getInputButtonMargin() { return inputButtonMargin; } protected int getInputButtonWidth() { return inputButtonWidth; } protected int getInputButtonHeight() { return inputButtonHeight; } protected int getInputMaxLines() { return inputMaxLines; } protected String getInputHint() { return inputHint; } protected String getInputText() { return inputText; } protected int getInputTextSize() { return inputTextSize; } protected int getInputTextColor() { return inputTextColor; } protected int getInputHintColor() { return inputHintColor; } protected Drawable getInputBackground() { return inputBackground; } protected Drawable getInputCursorDrawable() { return inputCursorDrawable; } protected int getInputDefaultPaddingLeft() { return inputDefaultPaddingLeft; } protected int getInputDefaultPaddingRight() { return inputDefaultPaddingRight; } protected int getInputDefaultPaddingTop() { return inputDefaultPaddingTop; } protected int getInputDefaultPaddingBottom() { return inputDefaultPaddingBottom; } int getDelayTypingStatus() { return delayTypingStatus; } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/messages/MessagesList.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.messages; import android.content.Context; import android.util.AttributeSet; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SimpleItemAnimator; import com.stfalcon.chatkit.commons.models.IMessage; /** * Component for displaying list of messages */ public class MessagesList extends RecyclerView { private MessagesListStyle messagesListStyle; public MessagesList(Context context) { super(context); } public MessagesList(Context context, @Nullable AttributeSet attrs) { super(context, attrs); parseStyle(context, attrs); } public MessagesList(Context context, @Nullable AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); parseStyle(context, attrs); } /** * Don't use this method for setting your adapter, otherwise exception will by thrown. * Call {@link #setAdapter(MessagesListAdapter)} instead. */ @Override public void setAdapter(Adapter adapter) { throw new IllegalArgumentException("You can't set adapter to MessagesList. Use #setAdapter(MessagesListAdapter) instead."); } /** * Sets adapter for MessagesList * * @param adapter Adapter. Must extend MessagesListAdapter * @param Message model class */ public void setAdapter(MessagesListAdapter adapter) { setAdapter(adapter, true); } /** * Sets adapter for MessagesList * * @param adapter Adapter. Must extend MessagesListAdapter * @param reverseLayout weather to use reverse layout for layout manager. * @param Message model class */ public void setAdapter(MessagesListAdapter adapter, boolean reverseLayout) { SimpleItemAnimator itemAnimator = new DefaultItemAnimator(); itemAnimator.setSupportsChangeAnimations(false); LinearLayoutManager layoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, reverseLayout); // The solution for aligning items to the top instead of bottom // https://stackoverflow.com/questions/46168245/recyclerview-reverse-order //layoutManager.setStackFromEnd(true); setItemAnimator(itemAnimator); setLayoutManager(layoutManager); adapter.setLayoutManager(layoutManager); adapter.setStyle(messagesListStyle); addOnScrollListener(new RecyclerScrollMoreListener(layoutManager, adapter)); super.setAdapter(adapter); } @SuppressWarnings("ResourceType") private void parseStyle(Context context, AttributeSet attrs) { messagesListStyle = MessagesListStyle.parse(context, attrs); } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/messages/MessagesListAdapter.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.messages; import android.annotation.SuppressLint; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.text.Spannable; import android.text.method.LinkMovementMethod; import android.util.SparseArray; import android.util.TypedValue; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.LayoutRes; import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.DrawableCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.liskovsoft.sharedutils.helpers.Helpers; import com.stfalcon.chatkit.R; import com.stfalcon.chatkit.commons.DebouncedOnClickListener; import com.stfalcon.chatkit.commons.ImageLoader; import com.stfalcon.chatkit.commons.ViewHolder; import com.stfalcon.chatkit.commons.models.IMessage; import com.stfalcon.chatkit.utils.DateFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; /** * Adapter for {@link MessagesList}. */ @SuppressWarnings("WeakerAccess") public class MessagesListAdapter extends RecyclerView.Adapter> implements RecyclerScrollMoreListener.OnLoadMoreListener { protected static boolean isSelectionModeEnabled; protected List items; private MessageHolders holders; private String senderId; private int selectedItemsCount; private int maxItemsCount; private SelectionListener selectionListener; private OnLoadMoreListener loadMoreListener; private OnMessageClickListener onMessageClickListener; private OnMessageViewClickListener onMessageViewClickListener; private OnMessageViewFocusListener onMessageViewFocusListener; private OnMessageLongClickListener onMessageLongClickListener; private OnMessageViewLongClickListener onMessageViewLongClickListener; private ImageLoader imageLoader; private RecyclerView.LayoutManager layoutManager; private MessagesListStyle messagesListStyle; private DateFormatter.Formatter dateHeadersFormatter; private SparseArray viewClickListenersArray = new SparseArray<>(); private boolean isDateHeaderEnabled; private MESSAGE focusedMessage; /** * For default list item layout and view holder. * * @param senderId identifier of sender. * @param imageLoader image loading method. */ public MessagesListAdapter(String senderId, ImageLoader imageLoader) { this(senderId, new MessageHolders(), imageLoader); } /** * For default list item layout and view holder. * * @param senderId identifier of sender. * @param holders custom layouts and view holders. See {@link MessageHolders} documentation for details * @param imageLoader image loading method. */ public MessagesListAdapter(String senderId, MessageHolders holders, ImageLoader imageLoader) { this.senderId = senderId; this.holders = holders; this.imageLoader = imageLoader; this.items = new ArrayList<>(); } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return holders.getHolder(parent, viewType, messagesListStyle); } @SuppressWarnings("unchecked") @Override public void onBindViewHolder(ViewHolder holder, int position) { Wrapper wrapper = items.get(position); holders.bind(holder, wrapper.item, wrapper.isSelected, imageLoader, getMessageClickListener(wrapper), getMessageLongClickListener(wrapper), getMessageFocusChangeListener(wrapper), dateHeadersFormatter, viewClickListenersArray); // Default focus on top message if (wrapper.item == focusedMessage && holder.itemView.isFocusable()) { holder.itemView.requestFocus(); focusedMessage = null; } } @Override public int getItemCount() { return items.size(); } @Override public int getItemViewType(int position) { return holders.getViewType(items.get(position).item, senderId); } @Override public void onLoadMore(int page, int total) { if (loadMoreListener != null) { loadMoreListener.onLoadMore(page, total); } } @Override public int getMessagesCount() { int count = 0; for (Wrapper item : items) { if (item.item instanceof IMessage) { count++; } } return count; } /* * PUBLIC METHODS * */ /** * Adds message to bottom of list and scroll if needed. * * @param message message to add. * @param scroll {@code true} if need to scroll list to bottom when message added. */ public void addToStart(MESSAGE message, boolean scroll) { if (!IMessage.checkMessage(message)) { return; } removeLoadingMessageIfNeeded(); boolean isNewMessageToday = isDateHeaderEnabled && !isPreviousSameDate(0, message.getCreatedAt()); if (isNewMessageToday) { items.add(0, new Wrapper<>(message.getCreatedAt())); } Wrapper element = new Wrapper<>(message); items.add(0, element); notifyItemRangeInserted(0, isNewMessageToday ? 2 : 1); if (layoutManager != null && scroll) { layoutManager.scrollToPosition(0); } trimEnd(); } /** * Adds messages list in chronological order. Use this method to add history. * * @param messages messages from history. * @param reverse {@code true} if need to reverse messages before adding. */ public void addToEnd(List messages, boolean reverse) { if (messages == null || messages.isEmpty()) return; if (reverse) Collections.reverse(messages); removeLoadingMessageIfNeeded(); if (!items.isEmpty()) { int lastItemPosition = items.size() - 1; if (items.get(lastItemPosition).item instanceof Date) { Date lastItem = (Date) items.get(lastItemPosition).item; if (DateFormatter.isSameDay(messages.get(0).getCreatedAt(), lastItem)) { items.remove(lastItemPosition); notifyItemRemoved(lastItemPosition); } } } int oldSize = items.size(); generateDateHeaders(messages); notifyItemRangeInserted(oldSize, items.size() - oldSize); } /** * Updates message by its id. * * @param message updated message object. */ public boolean update(MESSAGE message) { return update(message.getId(), message); } /** * Updates message by old identifier (use this method if id has changed). Otherwise use {@link #update(IMessage)} * * @param oldId an identifier of message to update. * @param newMessage new message object. */ public boolean update(String oldId, MESSAGE newMessage) { int position = getMessagePositionById(oldId); if (position >= 0) { Wrapper element = new Wrapper<>(newMessage); items.set(position, element); notifyItemChanged(position); return true; } else { return false; } } /** * Moves the elements position from current to start * * @param newMessage new message object. */ public void updateAndMoveToStart(MESSAGE newMessage) { int position = getMessagePositionById(newMessage.getId()); if (position >= 0) { Wrapper element = new Wrapper<>(newMessage); items.remove(position); items.add(0, element); notifyItemMoved(position, 0); notifyItemChanged(0); } } /** * Updates message by its id if it exists, add to start if not * * @param message message object to insert or update. */ public void upsert(MESSAGE message) { if (!update(message)) { addToStart(message, false); } } /** * Updates and moves to start if message by its id exists and if specified move to start, if not * specified the item stays at current position and updated * * @param message message object to insert or update. */ public void upsert(MESSAGE message, boolean moveToStartIfUpdate) { if (moveToStartIfUpdate) { if (getMessagePositionById(message.getId()) > 0) { updateAndMoveToStart(message); } else { upsert(message); } } else { upsert(message); } } /** * Deletes message. * * @param message message to delete. */ public void delete(MESSAGE message) { deleteById(message.getId()); } /** * Deletes messages list. * * @param messages messages list to delete. */ public void delete(List messages) { boolean result = false; for (MESSAGE message : messages) { int index = getMessagePositionById(message.getId()); if (index >= 0) { items.remove(index); notifyItemRemoved(index); result = true; } } if (result) { recountDateHeaders(); } } /** * Deletes message by its identifier. * * @param id identifier of message to delete. */ public void deleteById(String id) { int index = getMessagePositionById(id); if (index >= 0) { items.remove(index); notifyItemRemoved(index); recountDateHeaders(); } } /** * Deletes messages by its identifiers. * * @param ids array of identifiers of messages to delete. */ public void deleteByIds(String[] ids) { boolean result = false; for (String id : ids) { int index = getMessagePositionById(id); if (index >= 0) { items.remove(index); notifyItemRemoved(index); result = true; } } if (result) { recountDateHeaders(); } } public void setMaxItemsCount(int maxItemsCount) { this.maxItemsCount = maxItemsCount; } public void setFocusedMessage(MESSAGE message) { this.focusedMessage = message; } /** * Returns {@code true} if, and only if, messages count in adapter is non-zero. * * @return {@code true} if size is 0, otherwise {@code false} */ public boolean isEmpty() { return items.isEmpty(); } /** * Clears the messages list. With notifyDataSetChanged */ public void clear() { clear(true); } /** * Clears the messages list. */ public void clear(boolean notifyDataSetChanged) { if (items != null) { items.clear(); if (notifyDataSetChanged) { notifyDataSetChanged(); } } } public void scrollToPosition(int position) { if (position != -1 && layoutManager != null && !items.isEmpty()) { layoutManager.scrollToPosition(position); } } public void scrollToTop() { if (layoutManager != null && !items.isEmpty()) { layoutManager.scrollToPosition(items.size() - 1); } } public void scrollToBottom() { if (layoutManager != null) { layoutManager.scrollToPosition(0); } } /** * Enables selection mode. * * @param selectionListener listener for selected items count. To get selected messages use {@link #getSelectedMessages()}. */ public void enableSelectionMode(SelectionListener selectionListener) { if (selectionListener == null) { throw new IllegalArgumentException("SelectionListener must not be null. Use `disableSelectionMode()` if you want tp disable selection mode"); } else { this.selectionListener = selectionListener; } } /** * Disables selection mode and removes {@link SelectionListener}. */ public void disableSelectionMode() { this.selectionListener = null; unselectAllItems(); } public void enableDateHeader(boolean enable) { isDateHeaderEnabled = enable; } public void enableStackFromEnd(boolean enable) { // The solution for aligning items to the top instead of bottom // https://stackoverflow.com/questions/46168245/recyclerview-reverse-order ((LinearLayoutManager) layoutManager).setStackFromEnd(enable); } public void setLoadingMessage(String message) { removeLoadingMessageIfNeeded(); if (message == null || !items.isEmpty()) { return; } items.add(new Wrapper<>(message)); notifyItemInserted(0); } private void removeLoadingMessageIfNeeded() { if (items.size() == 1 && items.get(0).item instanceof String) { items.remove(0); notifyItemRemoved(0); } } /** * Returns the list of selected messages. * * @return list of selected messages. Empty list if nothing was selected or selection mode is disabled. */ @SuppressWarnings("unchecked") public ArrayList getSelectedMessages() { ArrayList selectedMessages = new ArrayList<>(); for (Wrapper wrapper : items) { if (wrapper.item instanceof IMessage && wrapper.isSelected) { selectedMessages.add((MESSAGE) wrapper.item); } } return selectedMessages; } public ArrayList getMessages() { ArrayList messages = new ArrayList<>(); for (Wrapper wrapper : items) { if (wrapper.item instanceof IMessage) { messages.add((MESSAGE) wrapper.item); } } return messages; } /** * Returns selected messages text and do {@link #unselectAllItems()} for you. * * @param formatter The formatter that allows you to format your message model when copying. * @param reverse Change ordering when copying messages. * @return formatted text by {@link Formatter}. If it's {@code null} - {@code MESSAGE#toString()} will be used. */ public String getSelectedMessagesText(Formatter formatter, boolean reverse) { String copiedText = getSelectedText(formatter, reverse); unselectAllItems(); return copiedText; } /** * Copies text to device clipboard and returns selected messages text. Also it does {@link #unselectAllItems()} for you. * * @param context The context. * @param formatter The formatter that allows you to format your message model when copying. * @param reverse Change ordering when copying messages. * @return formatted text by {@link Formatter}. If it's {@code null} - {@code MESSAGE#toString()} will be used. */ public String copySelectedMessagesText(Context context, Formatter formatter, boolean reverse) { String copiedText = getSelectedText(formatter, reverse); copyToClipboard(context, copiedText); unselectAllItems(); return copiedText; } /** * Unselect all of the selected messages. Notifies {@link SelectionListener} with zero count. */ public void unselectAllItems() { for (int i = 0; i < items.size(); i++) { Wrapper wrapper = items.get(i); if (wrapper.isSelected) { wrapper.isSelected = false; notifyItemChanged(i); } } isSelectionModeEnabled = false; selectedItemsCount = 0; notifySelectionChanged(); } /** * Deletes all of the selected messages and disables selection mode. * Call {@link #getSelectedMessages()} before calling this method to delete messages from your data source. */ public void deleteSelectedMessages() { List selectedMessages = getSelectedMessages(); delete(selectedMessages); unselectAllItems(); } /** * Sets click listener for item. Fires ONLY if list is not in selection mode. * * @param onMessageClickListener click listener. */ public void setOnMessageClickListener(OnMessageClickListener onMessageClickListener) { this.onMessageClickListener = onMessageClickListener; } /** * Sets click listener for message view. Fires ONLY if list is not in selection mode. * * @param onMessageViewClickListener click listener. */ public void setOnMessageViewClickListener(OnMessageViewClickListener onMessageViewClickListener) { this.onMessageViewClickListener = onMessageViewClickListener; } /** * Registers click listener for view by id * * @param viewId view * @param onMessageViewClickListener click listener. */ public void registerViewClickListener(int viewId, OnMessageViewClickListener onMessageViewClickListener) { this.viewClickListenersArray.append(viewId, onMessageViewClickListener); } /** * Sets long click listener for item. Fires only if selection mode is disabled. * * @param onMessageLongClickListener long click listener. */ public void setOnMessageLongClickListener(OnMessageLongClickListener onMessageLongClickListener) { this.onMessageLongClickListener = onMessageLongClickListener; } /** * Sets long click listener for message view. Fires ONLY if selection mode is disabled. * * @param onMessageViewLongClickListener long click listener. */ public void setOnMessageViewLongClickListener(OnMessageViewLongClickListener onMessageViewLongClickListener) { this.onMessageViewLongClickListener = onMessageViewLongClickListener; } public void setOnMessageViewFocusListener(OnMessageViewFocusListener onMessageViewFocusListener) { this.onMessageViewFocusListener = onMessageViewFocusListener; } /** * Set callback to be invoked when list scrolled to top. * * @param loadMoreListener listener. */ public void setLoadMoreListener(OnLoadMoreListener loadMoreListener) { this.loadMoreListener = loadMoreListener; } /** * Sets custom {@link DateFormatter.Formatter} for text representation of date headers. */ public void setDateHeadersFormatter(DateFormatter.Formatter dateHeadersFormatter) { this.dateHeadersFormatter = dateHeadersFormatter; } /* * PRIVATE METHODS * */ private void recountDateHeaders() { List indicesToDelete = new ArrayList<>(); for (int i = 0; i < items.size(); i++) { Wrapper wrapper = items.get(i); if (wrapper.item instanceof Date) { if (i == 0) { indicesToDelete.add(i); } else { if (items.get(i - 1).item instanceof Date) { indicesToDelete.add(i); } } } } Collections.reverse(indicesToDelete); for (int i : indicesToDelete) { items.remove(i); notifyItemRemoved(i); } } protected void generateDateHeaders(List messages) { for (int i = 0; i < messages.size(); i++) { MESSAGE message = messages.get(i); this.items.add(new Wrapper<>(message)); if (!isDateHeaderEnabled) { continue; } if (messages.size() > i + 1) { MESSAGE nextMessage = messages.get(i + 1); if (!DateFormatter.isSameDay(message.getCreatedAt(), nextMessage.getCreatedAt())) { this.items.add(new Wrapper<>(message.getCreatedAt())); } } else { this.items.add(new Wrapper<>(message.getCreatedAt())); } } } public int getMessagePosition(MESSAGE message) { if (message == null) { return -1; } return getMessagePositionById(message.getId()); } @SuppressWarnings("unchecked") private int getMessagePositionById(String id) { for (int i = 0; i < items.size(); i++) { Wrapper wrapper = items.get(i); if (wrapper.item instanceof IMessage) { MESSAGE message = (MESSAGE) wrapper.item; if (message.getId().contentEquals(id)) { return i; } } } return -1; } @SuppressWarnings("unchecked") private boolean isPreviousSameDate(int position, Date dateToCompare) { if (items.size() <= position) return false; if (items.get(position).item instanceof IMessage) { Date previousPositionDate = ((MESSAGE) items.get(position).item).getCreatedAt(); return DateFormatter.isSameDay(dateToCompare, previousPositionDate); } else return false; } @SuppressWarnings("unchecked") private boolean isPreviousSameAuthor(String id, int position) { int prevPosition = position + 1; if (items.size() <= prevPosition) return false; else return items.get(prevPosition).item instanceof IMessage && ((MESSAGE) items.get(prevPosition).item).getUser().getId().contentEquals(id); } private void incrementSelectedItemsCount() { selectedItemsCount++; notifySelectionChanged(); } private void decrementSelectedItemsCount() { selectedItemsCount--; isSelectionModeEnabled = selectedItemsCount > 0; notifySelectionChanged(); } private void notifySelectionChanged() { if (selectionListener != null) { selectionListener.onSelectionChanged(selectedItemsCount); } } private void notifyMessageClicked(MESSAGE message) { if (onMessageClickListener != null) { onMessageClickListener.onMessageClick(message); } } private void notifyMessageViewClicked(View view, MESSAGE message) { if (onMessageViewClickListener != null) { onMessageViewClickListener.onMessageViewClick(view, message); } } private void notifyMessageLongClicked(MESSAGE message) { if (onMessageLongClickListener != null) { onMessageLongClickListener.onMessageLongClick(message); } } private void notifyMessageViewLongClicked(View view, MESSAGE message) { if (onMessageViewLongClickListener != null) { onMessageViewLongClickListener.onMessageViewLongClick(view, message); } } private void notifyMessageViewFocused(View view, MESSAGE message) { if (onMessageViewFocusListener != null) { onMessageViewFocusListener.onMessageViewFocus(view, message); } } private View.OnClickListener getMessageClickListener(final Wrapper wrapper) { return new DebouncedOnClickListener(3_000) { @Override public void onDebouncedClick(View view) { if (selectionListener != null && isSelectionModeEnabled) { wrapper.isSelected = !wrapper.isSelected; if (wrapper.isSelected) incrementSelectedItemsCount(); else decrementSelectedItemsCount(); MESSAGE message = (wrapper.item); notifyItemChanged(getMessagePositionById(message.getId())); } else { notifyMessageClicked(wrapper.item); notifyMessageViewClicked(view, wrapper.item); } } }; } private View.OnLongClickListener getMessageLongClickListener(final Wrapper wrapper) { return view -> { if (selectionListener == null) { notifyMessageLongClicked(wrapper.item); notifyMessageViewLongClicked(view, wrapper.item); } else { isSelectionModeEnabled = true; view.performClick(); } return true; }; } private View.OnFocusChangeListener getMessageFocusChangeListener(final Wrapper wrapper) { return (v, hasFocus) -> { // Change background of the focused message // NOTE: you can have only one focus listener View bubble = v.findViewById(R.id.bubble); //bubble.setBackgroundResource(hasFocus ? R.drawable.shape_incoming_message_focused : R.drawable.shape_incoming_message); // Save the current padding (API 19 fix) int paddingLeft = bubble.getPaddingLeft(); int paddingTop = bubble.getPaddingTop(); int paddingRight = bubble.getPaddingRight(); int paddingBottom = bubble.getPaddingBottom(); if (hasFocus) { // Invert text and bg color Drawable originalBackground = messagesListStyle.getIncomingBubbleDrawable(); Drawable shapeBackground = messagesListStyle.getIncomingBubbleSelectedDrawable(); bubble.setBackground(new LayerDrawable(new Drawable[]{originalBackground, shapeBackground})); } else { // Revert to original Drawable originalBackground = messagesListStyle.getIncomingBubbleDrawable(); bubble.setBackground(originalBackground); } // Restore the padding (API 19 fix) bubble.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom); if (hasFocus) { notifyMessageViewFocused(v, wrapper.item); } }; } private String getSelectedText(Formatter formatter, boolean reverse) { StringBuilder builder = new StringBuilder(); ArrayList selectedMessages = getSelectedMessages(); if (reverse) Collections.reverse(selectedMessages); for (MESSAGE message : selectedMessages) { builder.append(formatter == null ? message.toString() : formatter.format(message)); builder.append("\n\n"); } builder.replace(builder.length() - 2, builder.length(), ""); return builder.toString(); } private void copyToClipboard(Context context, String copiedText) { ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); ClipData clip = ClipData.newPlainText(copiedText, copiedText); clipboard.setPrimaryClip(clip); } private void trimEnd() { if (maxItemsCount > 0) { int messagesCount = getMessagesCount(); int leftoversCount = messagesCount - maxItemsCount; if (leftoversCount > 0) { int size = items.size(); int firstIndex = size - leftoversCount; int lastIndex = size; items.subList(firstIndex, lastIndex).clear(); notifyItemRangeRemoved(firstIndex, leftoversCount); recountDateHeaders(); } } } void setLayoutManager(RecyclerView.LayoutManager layoutManager) { this.layoutManager = layoutManager; } void setStyle(MessagesListStyle style) { this.messagesListStyle = style; } /* * WRAPPER * */ public static class Wrapper { public DATA item; public boolean isSelected; Wrapper(DATA item) { this.item = item; } } /* * LISTENERS * */ /** * Interface definition for a callback to be invoked when next part of messages need to be loaded. */ public interface OnLoadMoreListener { /** * Fires when user scrolled to the end of list. * * @param page next page to download. * @param totalItemsCount current items count. */ void onLoadMore(int page, int totalItemsCount); } /** * Interface definition for a callback to be invoked when selected messages count is changed. */ public interface SelectionListener { /** * Fires when selected items count is changed. * * @param count count of selected items. */ void onSelectionChanged(int count); } /** * Interface definition for a callback to be invoked when message item is clicked. */ public interface OnMessageClickListener { /** * Fires when message is clicked. * * @param message clicked message. */ void onMessageClick(MESSAGE message); } /** * Interface definition for a callback to be invoked when message view is clicked. */ public interface OnMessageViewClickListener { /** * Fires when message view is clicked. * * @param message clicked message. */ void onMessageViewClick(View view, MESSAGE message); } public interface OnMessageViewFocusListener { void onMessageViewFocus(View view, MESSAGE message); } /** * Interface definition for a callback to be invoked when message item is long clicked. */ public interface OnMessageLongClickListener { /** * Fires when message is long clicked. * * @param message clicked message. */ void onMessageLongClick(MESSAGE message); } /** * Interface definition for a callback to be invoked when message view is long clicked. */ public interface OnMessageViewLongClickListener { /** * Fires when message view is long clicked. * * @param message clicked message. */ void onMessageViewLongClick(View view, MESSAGE message); } /** * Interface used to format your message model when copying. */ public interface Formatter { /** * Formats an string representation of the message object. * * @param message The object that should be formatted. * @return Formatted text. */ String format(MESSAGE message); } /** * This class is deprecated. Use {@link MessageHolders} instead. */ @Deprecated public static class HoldersConfig extends MessageHolders { /** * This method is deprecated. Use {@link MessageHolders#setIncomingTextConfig(Class, int)} instead. * * @param holder holder class. * @param layout layout resource. */ @Deprecated public void setIncoming(Class> holder, @LayoutRes int layout) { super.setIncomingTextConfig(holder, layout); } /** * This method is deprecated. Use {@link MessageHolders#setIncomingTextHolder(Class)} instead. * * @param holder holder class. */ @Deprecated public void setIncomingHolder(Class> holder) { super.setIncomingTextHolder(holder); } /** * This method is deprecated. Use {@link MessageHolders#setIncomingTextLayout(int)} instead. * * @param layout layout resource. */ @Deprecated public void setIncomingLayout(@LayoutRes int layout) { super.setIncomingTextLayout(layout); } /** * This method is deprecated. Use {@link MessageHolders#setOutcomingTextConfig(Class, int)} instead. * * @param holder holder class. * @param layout layout resource. */ @Deprecated public void setOutcoming(Class> holder, @LayoutRes int layout) { super.setOutcomingTextConfig(holder, layout); } /** * This method is deprecated. Use {@link MessageHolders#setOutcomingTextHolder(Class)} instead. * * @param holder holder class. */ @Deprecated public void setOutcomingHolder(Class> holder) { super.setOutcomingTextHolder(holder); } /** * This method is deprecated. Use {@link MessageHolders#setOutcomingTextLayout(int)} instead. * * @param layout layout resource. */ @Deprecated public void setOutcomingLayout(@LayoutRes int layout) { this.setOutcomingTextLayout(layout); } /** * This method is deprecated. Use {@link MessageHolders#setDateHeaderConfig(Class, int)} instead. * * @param holder holder class. * @param layout layout resource. */ @Deprecated public void setDateHeader(Class> holder, @LayoutRes int layout) { super.setDateHeaderConfig(holder, layout); } } /** * This class is deprecated. Use {@link MessageHolders.BaseMessageViewHolder} instead. */ @Deprecated public static abstract class BaseMessageViewHolder extends MessageHolders.BaseMessageViewHolder { private boolean isSelected; /** * Callback for implementing images loading in message list */ protected ImageLoader imageLoader; public BaseMessageViewHolder(View itemView) { super(itemView); } /** * Returns whether is item selected * * @return weather is item selected. */ public boolean isSelected() { return isSelected; } /** * Returns weather is selection mode enabled * * @return weather is selection mode enabled. */ public boolean isSelectionModeEnabled() { return isSelectionModeEnabled; } /** * Getter for {@link #imageLoader} * * @return image loader interface. */ public ImageLoader getImageLoader() { return imageLoader; } protected void configureLinksBehavior(final TextView text) { text.setLinksClickable(false); text.setMovementMethod(new LinkMovementMethod() { @Override public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { boolean result = false; if (!isSelectionModeEnabled) { result = super.onTouchEvent(widget, buffer, event); } itemView.onTouchEvent(event); return result; } }); } } /** * This class is deprecated. Use {@link MessageHolders.DefaultDateHeaderViewHolder} instead. */ @Deprecated public static class DefaultDateHeaderViewHolder extends ViewHolder implements MessageHolders.DefaultMessageViewHolder { protected TextView text; protected String dateFormat; protected DateFormatter.Formatter dateHeadersFormatter; public DefaultDateHeaderViewHolder(View itemView) { super(itemView); text = itemView.findViewById(R.id.messageText); } @Override public void onBind(Date date) { if (text != null) { String formattedDate = null; if (dateHeadersFormatter != null) formattedDate = dateHeadersFormatter.format(date); text.setText(formattedDate == null ? DateFormatter.format(date, dateFormat) : formattedDate); } } @SuppressLint("WrongConstant") @Override public void applyStyle(MessagesListStyle style) { if (text != null) { text.setTextColor(style.getDateHeaderTextColor()); text.setTextSize(TypedValue.COMPLEX_UNIT_PX, style.getDateHeaderTextSize()); text.setTypeface(text.getTypeface(), style.getDateHeaderTextStyle()); text.setPadding(style.getDateHeaderPadding(), style.getDateHeaderPadding(), style.getDateHeaderPadding(), style.getDateHeaderPadding()); } dateFormat = style.getDateHeaderFormat(); dateFormat = dateFormat == null ? DateFormatter.Template.STRING_DAY_MONTH_YEAR.get() : dateFormat; } } /** * This class is deprecated. Use {@link MessageHolders.IncomingTextMessageViewHolder} instead. */ @Deprecated public static class IncomingMessageViewHolder extends MessageHolders.IncomingTextMessageViewHolder implements MessageHolders.DefaultMessageViewHolder { public IncomingMessageViewHolder(View itemView) { super(itemView); } } /** * This class is deprecated. Use {@link MessageHolders.OutcomingTextMessageViewHolder} instead. */ @Deprecated public static class OutcomingMessageViewHolder extends MessageHolders.OutcomingTextMessageViewHolder { public OutcomingMessageViewHolder(View itemView) { super(itemView); } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/messages/MessagesListStyle.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.messages; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.core.graphics.drawable.DrawableCompat; import com.stfalcon.chatkit.R; import com.stfalcon.chatkit.commons.Style; /** * Style for MessagesListStyle customization by xml attributes */ @SuppressWarnings("WeakerAccess") class MessagesListStyle extends Style { private int textAutoLinkMask; private int incomingTextLinkColor; private int outcomingTextLinkColor; private int incomingAvatarWidth; private int incomingAvatarHeight; private int incomingBubbleDrawable; private int incomingDefaultBubbleColor; private int incomingDefaultBubblePressedColor; private int incomingDefaultBubbleSelectedColor; private int incomingImageOverlayDrawable; private int incomingDefaultImageOverlayPressedColor; private int incomingDefaultImageOverlaySelectedColor; private int incomingDefaultBubblePaddingLeft; private int incomingDefaultBubblePaddingRight; private int incomingDefaultBubblePaddingTop; private int incomingDefaultBubblePaddingBottom; private int incomingTextColor; private int incomingTextSize; private int incomingTextStyle; private boolean showIncomingTime; private int incomingTimeTextColor; private int incomingTimeTextSize; private int incomingTimeTextStyle; private int incomingImageTimeTextColor; private int incomingImageTimeTextSize; private int incomingImageTimeTextStyle; private int outcomingBubbleDrawable; private int outcomingDefaultBubbleColor; private int outcomingDefaultBubblePressedColor; private int outcomingDefaultBubbleSelectedColor; private int outcomingImageOverlayDrawable; private int outcomingDefaultImageOverlayPressedColor; private int outcomingDefaultImageOverlaySelectedColor; private int outcomingDefaultBubblePaddingLeft; private int outcomingDefaultBubblePaddingRight; private int outcomingDefaultBubblePaddingTop; private int outcomingDefaultBubblePaddingBottom; private int outcomingTextColor; private int outcomingTextSize; private int outcomingTextStyle; private boolean showOutcomingTime; private int outcomingTimeTextColor; private int outcomingTimeTextSize; private int outcomingTimeTextStyle; private int outcomingImageTimeTextColor; private int outcomingImageTimeTextSize; private int outcomingImageTimeTextStyle; private int dateHeaderPadding; private String dateHeaderFormat; private int dateHeaderTextColor; private int dateHeaderTextSize; private int dateHeaderTextStyle; private boolean isMessageFocusable; static MessagesListStyle parse(Context context, AttributeSet attrs) { MessagesListStyle style = new MessagesListStyle(context, attrs); TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MessagesList); style.textAutoLinkMask = typedArray.getInt(R.styleable.MessagesList_textAutoLink, 0); style.incomingTextLinkColor = typedArray.getColor(R.styleable.MessagesList_incomingTextLinkColor, style.getSystemAccentColor()); style.outcomingTextLinkColor = typedArray.getColor(R.styleable.MessagesList_outcomingTextLinkColor, style.getSystemAccentColor()); style.incomingAvatarWidth = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingAvatarWidth, style.getDimension(R.dimen.message_avatar_width)); style.incomingAvatarHeight = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingAvatarHeight, style.getDimension(R.dimen.message_avatar_height)); style.incomingBubbleDrawable = typedArray.getResourceId(R.styleable.MessagesList_incomingBubbleDrawable, -1); style.incomingDefaultBubbleColor = typedArray.getColor(R.styleable.MessagesList_incomingDefaultBubbleColor, style.getColor(R.color.white_two)); style.incomingDefaultBubblePressedColor = typedArray.getColor(R.styleable.MessagesList_incomingDefaultBubblePressedColor, style.getColor(R.color.white_two)); style.incomingDefaultBubbleSelectedColor = typedArray.getColor(R.styleable.MessagesList_incomingDefaultBubbleSelectedColor, style.getColor(R.color.cornflower_blue_two_24)); style.incomingImageOverlayDrawable = typedArray.getResourceId(R.styleable.MessagesList_incomingImageOverlayDrawable, -1); style.incomingDefaultImageOverlayPressedColor = typedArray.getColor(R.styleable.MessagesList_incomingDefaultImageOverlayPressedColor, style.getColor(R.color.transparent)); style.incomingDefaultImageOverlaySelectedColor = typedArray.getColor(R.styleable.MessagesList_incomingDefaultImageOverlaySelectedColor, style.getColor(R.color.cornflower_blue_light_40)); style.incomingDefaultBubblePaddingLeft = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingBubblePaddingLeft, style.getDimension(R.dimen.message_padding_left)); style.incomingDefaultBubblePaddingRight = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingBubblePaddingRight, style.getDimension(R.dimen.message_padding_right)); style.incomingDefaultBubblePaddingTop = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingBubblePaddingTop, style.getDimension(R.dimen.message_padding_top)); style.incomingDefaultBubblePaddingBottom = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingBubblePaddingBottom, style.getDimension(R.dimen.message_padding_bottom)); style.incomingTextColor = typedArray.getColor(R.styleable.MessagesList_incomingTextColor, style.getColor(R.color.dark_grey_two)); style.incomingTextSize = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingTextSize, style.getDimension(R.dimen.message_text_size)); style.incomingTextStyle = typedArray.getInt(R.styleable.MessagesList_incomingTextStyle, Typeface.NORMAL); style.showIncomingTime = typedArray.getBoolean(R.styleable.MessagesList_showIncomingTime, true); style.incomingTimeTextColor = typedArray.getColor(R.styleable.MessagesList_incomingTimeTextColor, style.getColor(R.color.warm_grey_four)); style.incomingTimeTextSize = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingTimeTextSize, style.getDimension(R.dimen.message_time_text_size)); style.incomingTimeTextStyle = typedArray.getInt(R.styleable.MessagesList_incomingTimeTextStyle, Typeface.NORMAL); style.incomingImageTimeTextColor = typedArray.getColor(R.styleable.MessagesList_incomingImageTimeTextColor, style.getColor(R.color.warm_grey_four)); style.incomingImageTimeTextSize = typedArray.getDimensionPixelSize(R.styleable.MessagesList_incomingImageTimeTextSize, style.getDimension(R.dimen.message_time_text_size)); style.incomingImageTimeTextStyle = typedArray.getInt(R.styleable.MessagesList_incomingImageTimeTextStyle, Typeface.NORMAL); style.outcomingBubbleDrawable = typedArray.getResourceId(R.styleable.MessagesList_outcomingBubbleDrawable, -1); style.outcomingDefaultBubbleColor = typedArray.getColor(R.styleable.MessagesList_outcomingDefaultBubbleColor, style.getColor(R.color.cornflower_blue_two)); style.outcomingDefaultBubblePressedColor = typedArray.getColor(R.styleable.MessagesList_outcomingDefaultBubblePressedColor, style.getColor(R.color.cornflower_blue_two)); style.outcomingDefaultBubbleSelectedColor = typedArray.getColor(R.styleable.MessagesList_outcomingDefaultBubbleSelectedColor, style.getColor(R.color.cornflower_blue_two_24)); style.outcomingImageOverlayDrawable = typedArray.getResourceId(R.styleable.MessagesList_outcomingImageOverlayDrawable, -1); style.outcomingDefaultImageOverlayPressedColor = typedArray.getColor(R.styleable.MessagesList_outcomingDefaultImageOverlayPressedColor, style.getColor(R.color.transparent)); style.outcomingDefaultImageOverlaySelectedColor = typedArray.getColor(R.styleable.MessagesList_outcomingDefaultImageOverlaySelectedColor, style.getColor(R.color.cornflower_blue_light_40)); style.outcomingDefaultBubblePaddingLeft = typedArray.getDimensionPixelSize(R.styleable.MessagesList_outcomingBubblePaddingLeft, style.getDimension(R.dimen.message_padding_left)); style.outcomingDefaultBubblePaddingRight = typedArray.getDimensionPixelSize(R.styleable.MessagesList_outcomingBubblePaddingRight, style.getDimension(R.dimen.message_padding_right)); style.outcomingDefaultBubblePaddingTop = typedArray.getDimensionPixelSize(R.styleable.MessagesList_outcomingBubblePaddingTop, style.getDimension(R.dimen.message_padding_top)); style.outcomingDefaultBubblePaddingBottom = typedArray.getDimensionPixelSize(R.styleable.MessagesList_outcomingBubblePaddingBottom, style.getDimension(R.dimen.message_padding_bottom)); style.outcomingTextColor = typedArray.getColor(R.styleable.MessagesList_outcomingTextColor, style.getColor(R.color.white)); style.outcomingTextSize = typedArray.getDimensionPixelSize(R.styleable.MessagesList_outcomingTextSize, style.getDimension(R.dimen.message_text_size)); style.outcomingTextStyle = typedArray.getInt(R.styleable.MessagesList_outcomingTextStyle, Typeface.NORMAL); style.showOutcomingTime = typedArray.getBoolean(R.styleable.MessagesList_showOutcomingTime, true); style.outcomingTimeTextColor = typedArray.getColor(R.styleable.MessagesList_outcomingTimeTextColor, style.getColor(R.color.white60)); style.outcomingTimeTextSize = typedArray.getDimensionPixelSize(R.styleable.MessagesList_outcomingTimeTextSize, style.getDimension(R.dimen.message_time_text_size)); style.outcomingTimeTextStyle = typedArray.getInt(R.styleable.MessagesList_outcomingTimeTextStyle, Typeface.NORMAL); style.outcomingImageTimeTextColor = typedArray.getColor(R.styleable.MessagesList_outcomingImageTimeTextColor, style.getColor(R.color.warm_grey_four)); style.outcomingImageTimeTextSize = typedArray.getDimensionPixelSize(R.styleable.MessagesList_outcomingImageTimeTextSize, style.getDimension(R.dimen.message_time_text_size)); style.outcomingImageTimeTextStyle = typedArray.getInt(R.styleable.MessagesList_outcomingImageTimeTextStyle, Typeface.NORMAL); style.dateHeaderPadding = typedArray.getDimensionPixelSize(R.styleable.MessagesList_dateHeaderPadding, style.getDimension(R.dimen.message_date_header_padding)); style.dateHeaderFormat = typedArray.getString(R.styleable.MessagesList_dateHeaderFormat); style.dateHeaderTextColor = typedArray.getColor(R.styleable.MessagesList_dateHeaderTextColor, style.getColor(R.color.warm_grey_two)); style.dateHeaderTextSize = typedArray.getDimensionPixelSize(R.styleable.MessagesList_dateHeaderTextSize, style.getDimension(R.dimen.message_date_header_text_size)); style.dateHeaderTextStyle = typedArray.getInt(R.styleable.MessagesList_dateHeaderTextStyle, Typeface.NORMAL); style.isMessageFocusable = typedArray.getBoolean(R.styleable.MessagesList_isMessageFocusable, true); typedArray.recycle(); return style; } private MessagesListStyle(Context context, AttributeSet attrs) { super(context, attrs); } private Drawable getMessageSelector(@ColorInt int normalColor, @ColorInt int selectedColor, @ColorInt int pressedColor, @DrawableRes int shape) { Drawable drawable = DrawableCompat.wrap(getVectorDrawable(shape)).mutate(); DrawableCompat.setTintList( drawable, new ColorStateList( new int[][]{ new int[]{android.R.attr.state_selected}, new int[]{android.R.attr.state_pressed}, new int[]{-android.R.attr.state_pressed, -android.R.attr.state_selected} }, new int[]{selectedColor, pressedColor, normalColor} )); return drawable; } protected int getTextAutoLinkMask() { return textAutoLinkMask; } protected int getIncomingTextLinkColor() { return incomingTextLinkColor; } protected int getOutcomingTextLinkColor() { return outcomingTextLinkColor; } protected int getIncomingAvatarWidth() { return incomingAvatarWidth; } protected int getIncomingAvatarHeight() { return incomingAvatarHeight; } protected int getIncomingDefaultBubblePaddingLeft() { return incomingDefaultBubblePaddingLeft; } protected int getIncomingDefaultBubblePaddingRight() { return incomingDefaultBubblePaddingRight; } protected int getIncomingDefaultBubblePaddingTop() { return incomingDefaultBubblePaddingTop; } protected int getIncomingDefaultBubblePaddingBottom() { return incomingDefaultBubblePaddingBottom; } protected int getIncomingTextColor() { return incomingTextColor; } protected int getIncomingTextSize() { return incomingTextSize; } protected int getIncomingTextStyle() { return incomingTextStyle; } protected Drawable getOutcomingBubbleDrawable() { if (outcomingBubbleDrawable == -1) { return getMessageSelector(outcomingDefaultBubbleColor, outcomingDefaultBubbleSelectedColor, outcomingDefaultBubblePressedColor, R.drawable.shape_outcoming_message); } else { return getDrawable(outcomingBubbleDrawable); } } protected Drawable getOutcomingImageOverlayDrawable() { if (outcomingImageOverlayDrawable == -1) { return getMessageSelector(Color.TRANSPARENT, outcomingDefaultImageOverlaySelectedColor, outcomingDefaultImageOverlayPressedColor, R.drawable.shape_outcoming_message); } else { return getDrawable(outcomingImageOverlayDrawable); } } protected int getOutcomingDefaultBubblePaddingLeft() { return outcomingDefaultBubblePaddingLeft; } protected int getOutcomingDefaultBubblePaddingRight() { return outcomingDefaultBubblePaddingRight; } protected int getOutcomingDefaultBubblePaddingTop() { return outcomingDefaultBubblePaddingTop; } protected int getOutcomingDefaultBubblePaddingBottom() { return outcomingDefaultBubblePaddingBottom; } protected int getOutcomingTextColor() { return outcomingTextColor; } protected int getOutcomingTextSize() { return outcomingTextSize; } protected int getOutcomingTextStyle() { return outcomingTextStyle; } protected boolean showOutcomingTime() { return showOutcomingTime; } protected int getOutcomingTimeTextColor() { return outcomingTimeTextColor; } protected int getOutcomingTimeTextSize() { return outcomingTimeTextSize; } protected int getOutcomingTimeTextStyle() { return outcomingTimeTextStyle; } protected int getOutcomingImageTimeTextColor() { return outcomingImageTimeTextColor; } protected int getOutcomingImageTimeTextSize() { return outcomingImageTimeTextSize; } protected int getOutcomingImageTimeTextStyle() { return outcomingImageTimeTextStyle; } protected int getDateHeaderTextColor() { return dateHeaderTextColor; } protected int getDateHeaderTextSize() { return dateHeaderTextSize; } protected int getDateHeaderTextStyle() { return dateHeaderTextStyle; } protected int getDateHeaderPadding() { return dateHeaderPadding; } protected String getDateHeaderFormat() { return dateHeaderFormat; } protected boolean showIncomingTime() { return showIncomingTime; } protected int getIncomingTimeTextSize() { return incomingTimeTextSize; } protected int getIncomingTimeTextStyle() { return incomingTimeTextStyle; } protected int getIncomingTimeTextColor() { return incomingTimeTextColor; } protected int getIncomingImageTimeTextColor() { return incomingImageTimeTextColor; } protected int getIncomingImageTimeTextSize() { return incomingImageTimeTextSize; } protected int getIncomingImageTimeTextStyle() { return incomingImageTimeTextStyle; } protected Drawable getIncomingBubbleDrawable() { if (incomingBubbleDrawable == -1) { return getMessageSelector(incomingDefaultBubbleColor, incomingDefaultBubbleSelectedColor, incomingDefaultBubblePressedColor, R.drawable.shape_incoming_message); } else { return getDrawable(incomingBubbleDrawable); } } protected Drawable getIncomingBubbleSelectedDrawable() { return getMessageSelector(incomingDefaultBubbleSelectedColor, incomingDefaultBubbleSelectedColor, incomingDefaultBubblePressedColor, R.drawable.shape_incoming_message); } protected Drawable getIncomingImageOverlayDrawable() { if (incomingImageOverlayDrawable == -1) { return getMessageSelector(Color.TRANSPARENT, incomingDefaultImageOverlaySelectedColor, incomingDefaultImageOverlayPressedColor, R.drawable.shape_incoming_message); } else { return getDrawable(incomingImageOverlayDrawable); } } protected boolean isMessageFocusable() { return isMessageFocusable; } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/messages/RecyclerScrollMoreListener.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.messages; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; class RecyclerScrollMoreListener extends RecyclerView.OnScrollListener { private OnLoadMoreListener loadMoreListener; private int currentPage = 0; private int previousTotalItemCount = 0; private int currentScrollPos = 0; private int maxScrollPos = 0; private boolean loading = true; private RecyclerView.LayoutManager mLayoutManager; RecyclerScrollMoreListener(LinearLayoutManager layoutManager, OnLoadMoreListener loadMoreListener) { this.mLayoutManager = layoutManager; this.loadMoreListener = loadMoreListener; } private int getLastVisibleItem(int[] lastVisibleItemPositions) { int maxSize = 0; for (int i = 0; i < lastVisibleItemPositions.length; i++) { if (i == 0) { maxSize = lastVisibleItemPositions[i]; } else if (lastVisibleItemPositions[i] > maxSize) { maxSize = lastVisibleItemPositions[i]; } } return maxSize; } // MODIFIED: fully custom // Fix: lastVisibleItemPositions is wrong. Solution: remove it altogether. @Override public void onScrolled(RecyclerView view, int dx, int dy) { if (loadMoreListener != null) { // MODIFIED: throttle calls // Swallow scrolling up. Continue on scroll down. currentScrollPos += dy; if (currentScrollPos <= maxScrollPos) { return; } maxScrollPos += dy; int totalItemCount = mLayoutManager.getItemCount(); if (totalItemCount < previousTotalItemCount) { this.currentPage = 0; this.previousTotalItemCount = totalItemCount; if (totalItemCount == 0) { this.loading = true; } } if (loading && (totalItemCount > previousTotalItemCount)) { loading = false; previousTotalItemCount = totalItemCount; } if (!loading) { currentPage++; loadMoreListener.onLoadMore(loadMoreListener.getMessagesCount(), totalItemCount); loading = true; } } } // ORIGIN //@Override //public void onScrolled(RecyclerView view, int dx, int dy) { // if (loadMoreListener != null) { // int lastVisibleItemPosition = 0; // int totalItemCount = mLayoutManager.getItemCount(); // // if (mLayoutManager instanceof StaggeredGridLayoutManager) { // int[] lastVisibleItemPositions = ((StaggeredGridLayoutManager) mLayoutManager).findLastVisibleItemPositions(null); // lastVisibleItemPosition = getLastVisibleItem(lastVisibleItemPositions); // } else if (mLayoutManager instanceof LinearLayoutManager) { // lastVisibleItemPosition = ((LinearLayoutManager) mLayoutManager).findLastVisibleItemPosition(); // } else if (mLayoutManager instanceof GridLayoutManager) { // lastVisibleItemPosition = ((GridLayoutManager) mLayoutManager).findLastVisibleItemPosition(); // } // // if (totalItemCount < previousTotalItemCount) { // this.currentPage = 0; // this.previousTotalItemCount = totalItemCount; // if (totalItemCount == 0) { // this.loading = true; // } // } // // if (loading && (totalItemCount > previousTotalItemCount)) { // loading = false; // previousTotalItemCount = totalItemCount; // } // // int visibleThreshold = 5; // if (!loading && (lastVisibleItemPosition + visibleThreshold) > totalItemCount) { // currentPage++; // loadMoreListener.onLoadMore(loadMoreListener.getMessagesCount(), totalItemCount); // loading = true; // } // } //} interface OnLoadMoreListener { void onLoadMore(int page, int total); int getMessagesCount(); } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/utils/DateFormatter.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.utils; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.Locale; public final class DateFormatter { private DateFormatter() { throw new AssertionError(); } public static String format(Date date, Template template) { return format(date, template.get()); } public static String format(Date date, String format) { if (date == null) return ""; return new SimpleDateFormat(format, Locale.getDefault()) .format(date); } public static boolean isSameDay(Date date1, Date date2) { if (date1 == null || date2 == null) { throw new IllegalArgumentException("Dates must not be null"); } Calendar cal1 = Calendar.getInstance(); cal1.setTime(date1); Calendar cal2 = Calendar.getInstance(); cal2.setTime(date2); return isSameDay(cal1, cal2); } public static boolean isSameDay(Calendar cal1, Calendar cal2) { if (cal1 == null || cal2 == null) { throw new IllegalArgumentException("Dates must not be null"); } return (cal1.get(Calendar.ERA) == cal2.get(Calendar.ERA) && cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)); } public static boolean isSameYear(Date date1, Date date2) { if (date1 == null || date2 == null) { throw new IllegalArgumentException("Dates must not be null"); } Calendar cal1 = Calendar.getInstance(); cal1.setTime(date1); Calendar cal2 = Calendar.getInstance(); cal2.setTime(date2); return isSameYear(cal1, cal2); } public static boolean isSameYear(Calendar cal1, Calendar cal2) { if (cal1 == null || cal2 == null) { throw new IllegalArgumentException("Dates must not be null"); } return (cal1.get(Calendar.ERA) == cal2.get(Calendar.ERA) && cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR)); } public static boolean isToday(Calendar calendar) { return isSameDay(calendar, Calendar.getInstance()); } public static boolean isToday(Date date) { return isSameDay(date, Calendar.getInstance().getTime()); } public static boolean isYesterday(Calendar calendar) { Calendar yesterday = Calendar.getInstance(); yesterday.add(Calendar.DAY_OF_MONTH, -1); return isSameDay(calendar, yesterday); } public static boolean isYesterday(Date date) { Calendar yesterday = Calendar.getInstance(); yesterday.add(Calendar.DAY_OF_MONTH, -1); return isSameDay(date, yesterday.getTime()); } public static boolean isCurrentYear(Date date) { return isSameYear(date, Calendar.getInstance().getTime()); } public static boolean isCurrentYear(Calendar calendar) { return isSameYear(calendar, Calendar.getInstance()); } /** * Interface used to format dates before they were displayed (e.g. dialogs time, messages date headers etc.). */ public interface Formatter { /** * Formats an string representation of the date object. * * @param date The date that should be formatted. * @return Formatted text. */ String format(Date date); } public enum Template { STRING_DAY_MONTH_YEAR("d MMMM yyyy"), STRING_DAY_MONTH("d MMMM"), TIME("HH:mm"); private String template; Template(String template) { this.template = template; } public String get() { return template; } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/utils/RoundedImageView.java ================================================ package com.stfalcon.chatkit.utils; import android.content.Context; import android.content.res.Resources; import android.content.res.Resources.NotFoundException; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.graphics.BitmapShader; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PixelFormat; import android.graphics.RectF; import android.graphics.Shader; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.net.Uri; import android.util.AttributeSet; import androidx.annotation.DimenRes; import androidx.annotation.NonNull; import androidx.appcompat.widget.AppCompatImageView; import androidx.core.content.ContextCompat; /** * Thanks to Joonho Kim (https://github.com/pungrue26) for his lightweight SelectableRoundedImageView, * that was used as default image message representation */ public class RoundedImageView extends AppCompatImageView { private int mResource = 0; private Drawable mDrawable; private float[] mRadii = new float[]{0, 0, 0, 0, 0, 0, 0, 0}; public RoundedImageView(Context context) { super(context); } public RoundedImageView(Context context, AttributeSet attrs) { super(context, attrs); } public RoundedImageView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void drawableStateChanged() { super.drawableStateChanged(); invalidate(); } @Override public void setImageDrawable(Drawable drawable) { mResource = 0; mDrawable = RoundedCornerDrawable.fromDrawable(drawable, getResources()); super.setImageDrawable(mDrawable); updateDrawable(); } @Override public void setImageBitmap(Bitmap bm) { mResource = 0; mDrawable = RoundedCornerDrawable.fromBitmap(bm, getResources()); super.setImageDrawable(mDrawable); updateDrawable(); } @Override public void setImageResource(int resId) { if (mResource != resId) { mResource = resId; mDrawable = resolveResource(); super.setImageDrawable(mDrawable); updateDrawable(); } } @Override public void setImageURI(Uri uri) { super.setImageURI(uri); setImageDrawable(getDrawable()); } public void setCorners(@DimenRes int leftTop, @DimenRes int rightTop, @DimenRes int rightBottom, @DimenRes int leftBottom) { setCorners( leftTop == 0 ? 0 : getResources().getDimension(leftTop), rightTop == 0 ? 0 : getResources().getDimension(rightTop), rightBottom == 0 ? 0 : getResources().getDimension(rightBottom), leftBottom == 0 ? 0 : getResources().getDimension(leftBottom) ); } public void setCorners(float leftTop, float rightTop, float rightBottom, float leftBottom) { mRadii = new float[]{ leftTop, leftTop, rightTop, rightTop, rightBottom, rightBottom, leftBottom, leftBottom}; updateDrawable(); } private Drawable resolveResource() { Drawable d = null; if (mResource != 0) { try { d = ContextCompat.getDrawable(getContext(), mResource); } catch (NotFoundException e) { mResource = 0; } } return RoundedCornerDrawable.fromDrawable(d, getResources()); } private void updateDrawable() { if (mDrawable == null) return; ((RoundedCornerDrawable) mDrawable).setCornerRadii(mRadii); } private static class RoundedCornerDrawable extends Drawable { private RectF mBounds = new RectF(); private final RectF mBitmapRect = new RectF(); private final int mBitmapWidth; private final int mBitmapHeight; private final Paint mBitmapPaint; private float[] mRadii = new float[]{0, 0, 0, 0, 0, 0, 0, 0}; private Path mPath = new Path(); private Bitmap mBitmap; private boolean mBoundsConfigured = false; private RoundedCornerDrawable(Bitmap bitmap, Resources r) { mBitmap = bitmap; BitmapShader mBitmapShader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); mBitmapWidth = bitmap.getScaledWidth(r.getDisplayMetrics()); mBitmapHeight = bitmap.getScaledHeight(r.getDisplayMetrics()); mBitmapRect.set(0, 0, mBitmapWidth, mBitmapHeight); mBitmapPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mBitmapPaint.setStyle(Paint.Style.FILL); mBitmapPaint.setShader(mBitmapShader); } private static RoundedCornerDrawable fromBitmap(Bitmap bitmap, Resources r) { if (bitmap != null) return new RoundedCornerDrawable(bitmap, r); else return null; } private static Drawable fromDrawable(Drawable drawable, Resources r) { if (drawable != null) { if (drawable instanceof RoundedCornerDrawable) { return drawable; } else if (drawable instanceof LayerDrawable) { LayerDrawable ld = (LayerDrawable) drawable; final int num = ld.getNumberOfLayers(); for (int i = 0; i < num; i++) { Drawable d = ld.getDrawable(i); ld.setDrawableByLayerId(ld.getId(i), fromDrawable(d, r)); } return ld; } Bitmap bm = drawableToBitmap(drawable); if (bm != null) return new RoundedCornerDrawable(bm, r); } return drawable; } private static Bitmap drawableToBitmap(Drawable drawable) { if (drawable == null) return null; if (drawable instanceof BitmapDrawable) { return ((BitmapDrawable) drawable).getBitmap(); } Bitmap bitmap; int width = Math.max(drawable.getIntrinsicWidth(), 2); int height = Math.max(drawable.getIntrinsicHeight(), 2); try { bitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); drawable.draw(canvas); } catch (IllegalArgumentException e) { e.printStackTrace(); bitmap = null; } return bitmap; } private void configureBounds(Canvas canvas) { Matrix canvasMatrix = canvas.getMatrix(); applyScaleToRadii(canvasMatrix); mBounds.set(mBitmapRect); } private void applyScaleToRadii(Matrix m) { float[] values = new float[9]; m.getValues(values); for (int i = 0; i < mRadii.length; i++) { mRadii[i] = mRadii[i] / values[0]; } } @Override public void draw(@NonNull Canvas canvas) { canvas.save(); if (!mBoundsConfigured) { configureBounds(canvas); mBoundsConfigured = true; } mPath.addRoundRect(mBounds, mRadii, Path.Direction.CW); canvas.drawPath(mPath, mBitmapPaint); canvas.restore(); } void setCornerRadii(float[] radii) { if (radii == null) return; if (radii.length != 8) throw new ArrayIndexOutOfBoundsException("radii[] needs 8 values"); System.arraycopy(radii, 0, mRadii, 0, radii.length); } @Override public int getOpacity() { return (mBitmap == null || mBitmap.hasAlpha() || mBitmapPaint.getAlpha() < 255) ? PixelFormat.TRANSLUCENT : PixelFormat.OPAQUE; } @Override public void setAlpha(int alpha) { mBitmapPaint.setAlpha(alpha); invalidateSelf(); } @Override public void setColorFilter(ColorFilter cf) { mBitmapPaint.setColorFilter(cf); invalidateSelf(); } @Override public void setDither(boolean dither) { mBitmapPaint.setDither(dither); invalidateSelf(); } @Override public void setFilterBitmap(boolean filter) { mBitmapPaint.setFilterBitmap(filter); invalidateSelf(); } @Override public int getIntrinsicWidth() { return mBitmapWidth; } @Override public int getIntrinsicHeight() { return mBitmapHeight; } } } ================================================ FILE: chatkit/src/main/java/com/stfalcon/chatkit/utils/ShapeImageView.java ================================================ /******************************************************************************* * Copyright 2016 stfalcon.com *

* Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* http://www.apache.org/licenses/LICENSE-2.0 *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.stfalcon.chatkit.utils; import android.content.Context; import android.graphics.Canvas; import android.graphics.Path; import android.util.AttributeSet; import android.view.View; /** * ImageView with mask what described with Bézier Curves */ public class ShapeImageView extends androidx.appcompat.widget.AppCompatImageView { private Path path; public ShapeImageView(Context context) { super(context); setLayerType(View.LAYER_TYPE_SOFTWARE, null); } public ShapeImageView(Context context, AttributeSet attrs) { super(context, attrs); setLayerType(View.LAYER_TYPE_SOFTWARE, null); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); path = new Path(); float halfWidth = (float) w / 2f; float firstParam = (float) w * 0.1f; float secondParam = (float) w * 0.8875f; //Bézier Curves path.moveTo(halfWidth, (float) w); path.cubicTo(firstParam, (float) w, 0, secondParam, 0, halfWidth); path.cubicTo(0, firstParam, firstParam, 0, halfWidth, 0); path.cubicTo(secondParam, 0, (float) w, firstParam, (float) w, halfWidth); path.cubicTo((float) w, secondParam, secondParam, (float) w, halfWidth, (float) w); path.close(); } @Override protected void onDraw(Canvas canvas) { if (path.isEmpty()) { super.onDraw(canvas); return; } int saveCount = canvas.save(); canvas.clipPath(path); super.onDraw(canvas); canvas.restoreToCount(saveCount); } } ================================================ FILE: chatkit/src/main/res/color/textchange.xml ================================================ ================================================ FILE: chatkit/src/main/res/drawable/bgchange.xml ================================================ ================================================ FILE: chatkit/src/main/res/drawable/bubble_circle.xml ================================================ ================================================ FILE: chatkit/src/main/res/drawable/shape_incoming_message.xml ================================================ ================================================ FILE: chatkit/src/main/res/drawable/shape_incoming_message_focused.xml ================================================ ================================================ FILE: chatkit/src/main/res/drawable/shape_outcoming_message.xml ================================================ ================================================ FILE: chatkit/src/main/res/layout/item_date_header.xml ================================================ ================================================ FILE: chatkit/src/main/res/layout/item_dialog.xml ================================================ ================================================ FILE: chatkit/src/main/res/layout/item_incoming_image_message.xml ================================================ ================================================ FILE: chatkit/src/main/res/layout/item_incoming_text_message.xml ================================================ ================================================ FILE: chatkit/src/main/res/layout/item_outcoming_image_message.xml ================================================ ================================================ FILE: chatkit/src/main/res/layout/item_outcoming_text_message.xml ================================================ ================================================ FILE: chatkit/src/main/res/layout/view_message_input.xml ================================================ ================================================ FILE: chatkit/src/main/res/values/attrs.xml ================================================ ================================================ FILE: chatkit/src/main/res/values/colors.xml ================================================ #ffcc0000 #ffff4444 #ffffff #a0ffffff #efefef #ebebeb #e0e0e0 #dadada #031cd7 #000000 #00000000 #333333 #282a2b #191a1b #7f7f7f #9c9c9c #8b8b8b #979797 #51c05c #4f62d7 #3d4f62d7 #475bd4 #64bec5f7 @color/white_three @color/dark_gray @color/warm_grey @color/warm_grey_two @color/white @color/dark_mint #1EC2DCF2 ================================================ FILE: chatkit/src/main/res/values/dimens.xml ================================================ 36dp 36dp 16dp 17sp 16sp 16sp 8sp 8sp 15dp 56dp 56dp 24dp 24dp 18sp 16sp 14sp 13sp 88dp 0dp 40dp 40dp 16dp 16dp 16dp 16dp 16sp 14sp 16sp 16dp 0dp 88dp ================================================ FILE: chatkit/src/main/res/values/fonts.xml ================================================ sans-serif ================================================ FILE: chatkit/src/main/res/values/ids.xml ================================================ ================================================ FILE: chatkit/src/main/res/values/strings.xml ================================================ ================================================ FILE: chatkit/src/main/res/values/styles.xml ================================================ ================================================ FILE: chatkit/src/main/res/values-v21/fonts.xml ================================================ sans-serif-medium ================================================ FILE: common/.gitignore ================================================ /build ================================================ FILE: common/build.gradle ================================================ apply from: gradle.ext.sharedModulesConstants apply plugin: 'kotlin-android' apply plugin: 'com.android.library' android { // FIX: Default interface methods are only supported starting with Android N (--min-api 24) compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } compileSdkVersion project.properties.compileSdkVersion buildToolsVersion project.properties.buildToolsVersion testOptions.unitTests.includeAndroidResources = true defaultConfig { minSdkVersion project.properties.minSdkVersion targetSdkVersion project.properties.targetSdkVersion versionCode 10 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles 'consumer-rules.pro' // More info: http://myhexaville.com/2017/03/10/android-multidex/ // Additionally, you should extend your application from MultiDexApplication multiDexEnabled = true } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } lintOptions { abortOnError true disable 'MissingTranslation' disable 'MissingQuantity' } // gradle 4.6 migration: disable dimensions mechanism // more: https://proandroiddev.com/advanced-android-flavors-part-4-a-new-version-fc2ad80c01bb flavorDimensions "default" productFlavors { stbeta {} ststable {} stfdroid {} } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:' + appCompatXVersion testImplementation 'junit:junit:' + junitVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion implementation project(':sharedutils') implementation project(':fragment-1.1.0') implementation project(':mediaserviceinterfaces') implementation project(':youtubeapi') implementation project(':filepicker-lib') implementation 'io.reactivex.rxjava2:rxandroid:' + rxAndroidVersion implementation 'io.reactivex.rxjava2:rxjava:' + rxJavaVersion //////// BEGIN EXOPLAYER ///////// implementation project(':exoplayer-library') implementation project(':exoplayer-extension-okhttp') implementation project(':exoplayer-extension-cronet') // implementation 'com.amazon.android:exoplayer:' + amazonExoplayerVersion // implementation 'com.amazon.android:extension-okhttp:' + amazonExoplayerVersion // implementation 'com.google.android.exoplayer:exoplayer:' + exoplayerVersion // implementation 'com.google.android.exoplayer:extension-okhttp:' + exoplayerVersion // implementation 'com.github.amzn:exoplayer-amazon-port:' + amazonExoplayerJitpackVersion //////// END EXOPLAYER ////////// implementation 'androidx.media:media:' + mediaXVersion // exoplayer fix implementation 'com.github.bumptech.glide:glide:' + glideVersion implementation 'androidx.work:work-runtime:' + workVersion implementation 'com.google.guava:guava:' + guavaVersion // Work library deps implementation 'androidx.browser:browser:' + browserXVersion implementation 'androidx.core:core-ktx:' + kotlinCoreXVersion implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + kotlinVersion implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:' + kotlinxVersion implementation 'com.jakewharton:process-phoenix:' + phoenixVersion implementation project(':leanbackassistant') implementation project(':appupdatechecker2') implementation project(':slidableactivity') // https://github.com/r0adkll/Slidr } ================================================ FILE: common/consumer-rules.pro ================================================ ================================================ FILE: common/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: common/src/main/AndroidManifest.xml ================================================ ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/data/BrowseSection.java ================================================ package com.liskovsoft.smartyoutubetv2.common.app.models.data; import androidx.annotation.Nullable; import com.liskovsoft.sharedutils.helpers.Helpers; public class BrowseSection { public static final int TYPE_GRID = 0; public static final int TYPE_ROW = 1; public static final int TYPE_SETTINGS_GRID = 2; public static final int TYPE_MULTI_GRID = 3; public static final int TYPE_ERROR = 4; public static final int TYPE_SHORTS_GRID = 5; private static final int MAX_TITLE_LENGTH_CHARS = 30; private final int mId; private String mTitle; private final int mResId; private final String mIconUrl; private final boolean mIsAuthOnly; private final Object mData; private boolean mEnabled = true; private int mType; public BrowseSection(int id, String title, int type, int resId) { this(id, title, type, resId, false); } public BrowseSection(int id, String title, int type, String iconUrl) { this(id, title, type, iconUrl, false); } public BrowseSection(int id, String title, int type, String iconUrl, boolean isAuthOnly) { this(id, title, type, -1, iconUrl, isAuthOnly, null); } public BrowseSection(int id, String title, int type, String iconUrl, boolean isAuthOnly, Object data) { this(id, title, type, -1, iconUrl, isAuthOnly, data); } public BrowseSection(int id, String title, int type, int resId, boolean isAuthOnly) { this(id, title, type, resId, null, isAuthOnly, null); } public BrowseSection(int id, String title, int type, int resId, boolean isAuthOnly, Object data) { this(id, title, type, resId, null, isAuthOnly, data); } public BrowseSection(int id, String title, int type, int resId, String iconUrl, boolean isAuthOnly, Object data) { mId = id; mTitle = Helpers.abbreviate(title, MAX_TITLE_LENGTH_CHARS); mType = type; mResId = resId; mIconUrl = iconUrl; mIsAuthOnly = isAuthOnly; mData = data; } public String getTitle() { return mTitle; } public void setTitle(String title) { mTitle = title; } public int getId() { return mId; } public int getType() { return mType; } public void setType(int type) { mType = type; } public int getResId() { return mResId; } public String getIconUrl() { return mIconUrl; } public boolean isAuthOnly() { return mIsAuthOnly; } public void setEnabled(boolean enabled) { mEnabled = enabled; } public boolean isEnabled() { return mEnabled; } public Object getData() { return mData; } /** * Check reserved ids range for default (built-in) sections */ public boolean isDefault() { return mId < 30; } @Override public boolean equals(@Nullable Object obj) { return obj instanceof BrowseSection && ((BrowseSection) obj).getId() == getId(); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/data/Playlist.java ================================================ package com.liskovsoft.smartyoutubetv2.common.app.models.data; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * Manages a playlist of videos. */ public class Playlist { private static final int LOW_RAM_PLAYLIST_MAX_SIZE = 50; private static final int HIGH_RAM_PLAYLIST_MAX_SIZE = 300; private final int mPlaylistMaxSize; private final List

* Note that you must register a {@link UhdHelperListener listener} using * {@link UhdHelper#registerModeChangeListener(UhdHelperListener) registerModeChangeListener} * to receive the callback for success or failure. * Also, note that this method need to be called from Main UI thread. *

* The method will not attempt a mode switch and fail immediately with callback if * 1) Device SDK is less than Android L * 2) Device is Android L but not Amazon AFT* devices. * * @param targetWindow {@link Window Window} to use for setting the display * and call parameters * @param modeId The desired mode to switch to. Must be a valid mode supported * by the platform. * @param allowOverlayDisplay Flag request to allow display overlay on applicable device. */ @TargetApi(17) public void setPreferredDisplayModeId(Window targetWindow, int modeId, boolean allowOverlayDisplay) { if (modeId == 0) { // mode is not set return; } /* * The Android M preview adds a preferredDisplayModeId to * WindowManager.LayoutParams.preferredDisplayModeId API. A PreferredDisplayModeId can be * set in the LayoutParams of any Window. */ String deviceName = Build.MODEL; // Let the handler know what listener to use, we will // send null callback in case of an error. mWorkHandler.setCallbackListener(mListener); boolean supportedDevice = DisplaySyncHelper.supportsDisplayModeChange(); //Some basic failure conditions that need handling if (!supportedDevice) { Log.i(TAG, "Attempt to set preferred Display mode on an unsupported device: " + deviceName); //send and cleanup mWorkHandler.sendMessage(mWorkHandler.obtainMessage(SEND_CALLBACK_WITH_SUPPLIED_RESULT, 1, 1, null)); return; } else if (!DisplaySyncHelper.isAmazonFireTVDevice()) { //We cannot not show interstitial for Non-Amazon Fire TV devices allowOverlayDisplay = false; } if (mIsSetModeInProgress.get()) { Log.e(TAG, "setPreferredDisplayModeId is already in progress! " + "Cannot set another while it is in progress"); //Send but don't cleanup as further processing is expected. mWorkHandler.sendMessage(mWorkHandler.obtainMessage(SEND_CALLBACK_WITH_SUPPLIED_RESULT, null)); return; } Mode currentMode = getCurrentMode(); if (currentMode == null || currentMode.getModeId() == modeId) { Log.i(TAG, "Current mode id same as mode id requested or is Null. Aborting."); //send and cleanup receivers/callback listeners mWorkHandler.sendMessage(mWorkHandler.obtainMessage(SEND_CALLBACK_WITH_SUPPLIED_RESULT, 1, 1, currentMode)); return; } //Check if the modeId given is even supported by the system. Mode[] supportedModes = getSupportedModes(); boolean isRequestedModeSupported = false; boolean isRequestedModeUhd = false; for (Mode mode : supportedModes) { if (mode.getModeId() == modeId) { isRequestedModeUhd = (mode.getPhysicalHeight() >= HEIGHT_UHD ? true : false); isRequestedModeSupported = true; break; } } if (!isRequestedModeSupported) { Log.e(TAG, "Requested mode id not among the supported Mode Id."); //send and cleanup receivers/callback listeners mWorkHandler.sendMessage(mWorkHandler.obtainMessage(SEND_CALLBACK_WITH_SUPPLIED_RESULT, 1, 1, null)); return; } //We are now going to do setMode call and will do callback for it. mIsSetModeInProgress.set(true); //Let the handler know what modeId onDisplayChanged callback event to look for mWorkHandler.setExpectedMode(modeId); //mContext.registerReceiver(overlayStateChangeReceiver, new IntentFilter(MODESWITCH_OVERLAY_STATE_CHANGED)); ContextCompat.registerReceiver(mContext, overlayStateChangeReceiver, new IntentFilter(MODESWITCH_OVERLAY_STATE_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED); mDisplayListener = new DisplayManager.DisplayListener() { @Override public void onDisplayAdded(int displayId) { } @Override public void onDisplayRemoved(int displayId) { } @Override public void onDisplayChanged(int displayId) { Display display = mDisplayManager.getDisplay(displayId); if (display != null) { Log.i(TAG, "onDisplayChanged. id= " + displayId + " " + display.toString()); } mWorkHandler.obtainMessage(MODE_CHANGED_MSG).sendToTarget(); } }; mDisplayManager.registerDisplayListener(mDisplayListener, mWorkHandler); isReceiversRegistered = true; mTargetWindow = targetWindow; showInterstitial = (allowOverlayDisplay && isRequestedModeUhd); //Also check if flag is available, otherwise fail and return WindowManager.LayoutParams mWindowAttributes = mTargetWindow.getAttributes(); //Check if the field is available or not. This is for early failure. Class cLayoutParams = mWindowAttributes.getClass(); Field attributeFlags; try { attributeFlags = cLayoutParams.getDeclaredField(sPreferredDisplayModeIdFieldName); } catch (Exception e) { Log.e(TAG, "error getting field", e); //send and cleanup receivers/callback listeners mWorkHandler.sendMessage(mWorkHandler.obtainMessage(SEND_CALLBACK_WITH_SUPPLIED_RESULT, 1, 1, null)); return; } if (showInterstitial) { isInterstitialFadeReceived = false; showOptimizingOverlay(); mWorkHandler.sendMessageDelayed(mWorkHandler.obtainMessage(INTERSTITIAL_TIMEOUT_MSG), SHOW_INTERSTITIAL_TIMEOUT_DELAY_MS); } else { initModeChange(modeId, attributeFlags); } } /** * Start the mode change by setting the preferredDisplayModeId field of {@link WindowManager.LayoutParams} */ private void initModeChange(int modeId, Field attributeFlagField) { WindowManager.LayoutParams mWindowAttributes = mTargetWindow.getAttributes(); try { if (attributeFlagField == null) { Class cLayoutParams = mWindowAttributes.getClass(); attributeFlagField = cLayoutParams.getDeclaredField(sPreferredDisplayModeIdFieldName); } // ensure mode is not set int currentModeId = attributeFlagField.getInt(mWindowAttributes); if (currentModeId != modeId) { // attempt mode switch attributeFlagField.setInt(mWindowAttributes, modeId); mTargetWindow.setAttributes(mWindowAttributes); } } catch (Exception e) { Log.e(TAG, "error getting field", e); // send and cleanup receivers/callback listeners mWorkHandler.sendMessage(mWorkHandler.obtainMessage(SEND_CALLBACK_WITH_SUPPLIED_RESULT, 1, 1, null)); return; } // We assume that the mode change is not instantaneous and will send the onDisplayChanged callback. // Start the clock on the mode change timeout mWorkHandler.sendMessageDelayed(mWorkHandler.obtainMessage(MODE_CHANGE_TIMEOUT_MSG), SET_MODE_TIMEOUT_DELAY_MS); } /** * Send the broadcast to show overlay display */ private void showOptimizingOverlay() { final Intent overlayIntent = new Intent(MODESWITCH_OVERLAY_ENABLE); mContext.sendBroadcast(overlayIntent); Log.i(TAG, "Sending the broadcast to display overlay"); } /** * Send the broadcast to hide overlay display if showing. */ private void hideOptimizingOverlay() { final Intent overlayIntent = new Intent(MODESWITCH_OVERLAY_DISABLE); mContext.sendBroadcast(overlayIntent); Log.i(TAG, "Sending the broadcast to hide display overlay"); } /** * Register a {@link UhdHelperListener listener} to be notified of result * of the {@link UhdHelper#setPreferredDisplayModeId(Window, int, boolean) setPreferredDisplayModeId} * call. * * @param listener that will receive the result of the callback. */ public void registerModeChangeListener(UhdHelperListener listener) { mListener = listener; } /** * Register the {@link UhdHelperListener listener} * * @param listener */ public void unregisterDisplayModeChangeListener(UhdHelperListener listener) { mListener = null; } public static String toResolution(Mode mode) { if (mode == null) { return null; } return String.format("%sx%s@%s", mode.getPhysicalWidth(), mode.getPhysicalHeight(), mode.getRefreshRate()); } public static Mode getCurrentMode(Context context) { if (Build.VERSION.SDK_INT < 23) { WindowManager wm = (WindowManager) context.getApplicationContext().getSystemService(Context.WINDOW_SERVICE); // the results will be higher than using the activity context object or the getWindowManager() shortcut Display display = wm.getDefaultDisplay(); if (display == null) { return null; } Point size = new Point(); display.getSize(size); return new Mode(0, size.x, size.y, display.getRefreshRate()); } else { Display display = getCurrentDisplay(context); if (display == null) { return null; } Display.Mode mode = display.getMode(); return new Mode(mode.getModeId(), mode.getPhysicalWidth(), mode.getPhysicalHeight(), mode.getRefreshRate()); } } @TargetApi(17) private static Display getCurrentDisplay(Context context) { DisplayManager displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); if (displayManager == null) return null; Display[] displays = displayManager.getDisplays(); if (displays == null || displays.length == 0) { return null; } //assuming the 1st display is the actual display. return displays[0]; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/autoframerate/internal/UhdHelperListener.java ================================================ package com.liskovsoft.smartyoutubetv2.common.autoframerate.internal; import android.view.Window; /** * The interface that must be implemented and registered * with {@link UhdHelper#registerModeChangeListener(UhdHelperListener) registerListener} * to find out the result of requested mode change. *

* Callback will be issued on the Main/UI thread of the application. * * To unregister the listener, use * {@link UhdHelper#unregisterDisplayModeChangeListener(UhdHelperListener) unregisterDisplayModeChangeListener} */ public interface UhdHelperListener { /** * Callback containing the result of the mode change after * {@link UhdHelper#setPreferredDisplayModeId(Window, int,boolean) setPreferredDisplayModeId} * returns a true. * * @param mode The {@link DisplayHolder.Mode Mode} object containing * the mode switched to OR NULL if there was a timeout * or internal error while changing the mode. */ void onModeChanged(DisplayHolder.Mode mode); } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/ExoMediaSourceFactory.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer; import android.annotation.SuppressLint; import android.content.Context; import android.net.Uri; import android.text.TextUtils; import androidx.annotation.NonNull; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ext.cronet.CronetDataSourceFactory; import com.google.android.exoplayer2.ext.cronet.CronetEngineWrapper; import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSourceFactory; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.dash.DashChunkSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser2; import com.google.android.exoplayer2.source.dash.manifest.Period; import com.google.android.exoplayer2.source.dash.manifest.ProgramInformation; import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.sabr.DefaultSabrChunkSource; import com.google.android.exoplayer2.source.sabr.SabrChunkSource; import com.google.android.exoplayer2.source.sabr.SabrMediaSource; import com.google.android.exoplayer2.source.sabr.manifest.SabrManifest; import com.google.android.exoplayer2.source.sabr.manifest.SabrManifestParser; import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSource.Factory; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; import com.google.android.exoplayer2.util.Util; import com.liskovsoft.mediaserviceinterfaces.data.MediaItemFormatInfo; import com.liskovsoft.sharedutils.cronet.CronetManager; import com.liskovsoft.sharedutils.helpers.FileHelpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.okhttp.OkHttpManager; import com.liskovsoft.smartyoutubetv2.common.exoplayer.errors.DashDefaultLoadErrorHandlingPolicy; import com.liskovsoft.smartyoutubetv2.common.exoplayer.errors.SabrDefaultLoadErrorHandlingPolicy; import com.liskovsoft.smartyoutubetv2.common.exoplayer.errors.TrackErrorFixer; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import com.liskovsoft.googlecommon.common.helpers.DefaultHeaders; import java.io.IOException; import java.io.InputStream; import java.util.List; import java.util.concurrent.Executors; public class ExoMediaSourceFactory { private static final String TAG = ExoMediaSourceFactory.class.getSimpleName(); @SuppressLint("StaticFieldLeak") //private static ExoMediaSourceFactory sInstance; private static final int MAX_SEGMENTS_PER_LOAD = 1; // default - 1 (1-5) private static final String USER_AGENT = DefaultHeaders.APP_USER_AGENT; @SuppressLint("StaticFieldLeak") private static final DefaultBandwidthMeter BANDWIDTH_METER = new DefaultBandwidthMeter(); private final Context mContext; private static final Uri DASH_MANIFEST_URI = Uri.parse("https://example.com/test.mpd"); private static final String DASH_MANIFEST_EXTENSION = "mpd"; private static final String HLS_PLAYLIST_EXTENSION = "m3u8"; private static final boolean USE_BANDWIDTH_METER = false; private TrackErrorFixer mTrackErrorFixer; private Factory mMediaDataSourceFactory; public ExoMediaSourceFactory(Context context) { mContext = context; } public MediaSource fromSabrFormatInfo(MediaItemFormatInfo formatInfo) { return buildSabrMediaSource(formatInfo); } public MediaSource fromDashFormatInfo(MediaItemFormatInfo formatInfo) { return buildDashMediaSource(formatInfo); } public MediaSource fromDashManifest(InputStream dashManifest) { return buildMPDMediaSource(DASH_MANIFEST_URI, dashManifest); } public MediaSource fromDashManifestUrl(String dashManifestUrl) { return buildMediaSource(Uri.parse(dashManifestUrl), DASH_MANIFEST_EXTENSION); } public MediaSource fromHlsPlaylist(String hlsPlaylist) { return buildMediaSource(Uri.parse(hlsPlaylist), HLS_PLAYLIST_EXTENSION); } public MediaSource fromUrlList(List urlList) { MediaSource[] mediaSources = new MediaSource[urlList.size()]; for (int i = 0; i < urlList.size(); i++) { mediaSources[i] = buildMediaSource(Uri.parse(urlList.get(i)), null); } //return mediaSources.length == 1 ? mediaSources[0] : new ConcatenatingMediaSource(mediaSources); // or playlist return mediaSources[0]; // item with max resolution } /** * Returns a new DataSource factory. * * @param useBandwidthMeter Whether to set {@link #BANDWIDTH_METER} as a listener to the new * DataSource factory. * @return A new DataSource factory. */ private DataSource.Factory buildDataSourceFactory(boolean useBandwidthMeter) { DefaultBandwidthMeter bandwidthMeter = useBandwidthMeter ? BANDWIDTH_METER : null; return new DefaultDataSourceFactory(mContext, bandwidthMeter, buildHttpDataSourceFactory(useBandwidthMeter)); } /** * Returns a new HttpDataSource factory. * * @param useBandwidthMeter Whether to set {@link #BANDWIDTH_METER} as a listener to the new * DataSource factory. * @return A new HttpDataSource factory. */ private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) { PlayerTweaksData tweaksData = PlayerTweaksData.instance(mContext); int source = tweaksData.getPlayerDataSource(); DefaultBandwidthMeter bandwidthMeter = useBandwidthMeter ? BANDWIDTH_METER : null; return source == PlayerTweaksData.PLAYER_DATA_SOURCE_OKHTTP ? buildOkHttpDataSourceFactory(bandwidthMeter) : source == PlayerTweaksData.PLAYER_DATA_SOURCE_CRONET && CronetManager.getEngine(mContext) != null ? buildCronetDataSourceFactory(bandwidthMeter) : buildDefaultHttpDataSourceFactory(bandwidthMeter); } @SuppressWarnings("deprecation") private MediaSource buildMediaSource(Uri uri, String overrideExtension) { int type = TextUtils.isEmpty(overrideExtension) ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); switch (type) { case C.TYPE_SS: SsMediaSource ssSource = new SsMediaSource.Factory( getSsChunkSourceFactory(), getMediaDataSourceFactory() ) .createMediaSource(uri); if (mTrackErrorFixer != null) { ssSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return ssSource; case C.TYPE_DASH: DashMediaSource dashSource = new DashMediaSource.Factory( getDashChunkSourceFactory(), getMediaDataSourceFactory() ) .setManifestParser(new LiveDashManifestParser()) // Don't make static! Need state reset for each live source. .setLoadErrorHandlingPolicy(new DashDefaultLoadErrorHandlingPolicy()) .createMediaSource(uri); if (mTrackErrorFixer != null) { dashSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return dashSource; case C.TYPE_HLS: HlsMediaSource hlsSource = new HlsMediaSource.Factory(getMediaDataSourceFactory()).createMediaSource(uri); if (mTrackErrorFixer != null) { hlsSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return hlsSource; case C.TYPE_OTHER: ExtractorMediaSource extractorSource = new ExtractorMediaSource.Factory(getMediaDataSourceFactory()) .setExtractorsFactory(new DefaultExtractorsFactory()) .createMediaSource(uri); if (mTrackErrorFixer != null) { extractorSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return extractorSource; default: { throw new IllegalStateException("Unsupported type: " + type); } } } private MediaSource buildSabrMediaSource(MediaItemFormatInfo formatInfo) { // Are you using FrameworkSampleSource or ExtractorSampleSource when you build your player? SabrMediaSource sabrSource = new SabrMediaSource.Factory( getSabrChunkSourceFactory(), null ) .setLoadErrorHandlingPolicy(new SabrDefaultLoadErrorHandlingPolicy()) .createMediaSource(getSabrManifest(formatInfo)); if (mTrackErrorFixer != null) { sabrSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return sabrSource; } private MediaSource buildDashMediaSource(MediaItemFormatInfo formatInfo) { // Are you using FrameworkSampleSource or ExtractorSampleSource when you build your player? DashMediaSource dashSource = new DashMediaSource.Factory( getDashChunkSourceFactory(), null ) .setLoadErrorHandlingPolicy(new DashDefaultLoadErrorHandlingPolicy()) .createMediaSource(getManifest(formatInfo)); if (mTrackErrorFixer != null) { dashSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return dashSource; } private MediaSource buildMPDMediaSource(Uri uri, InputStream mpdContent) { // Are you using FrameworkSampleSource or ExtractorSampleSource when you build your player? DashMediaSource dashSource = new DashMediaSource.Factory( getDashChunkSourceFactory(), null ) .setLoadErrorHandlingPolicy(new DashDefaultLoadErrorHandlingPolicy()) .createMediaSource(getManifest(uri, mpdContent)); if (mTrackErrorFixer != null) { dashSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return dashSource; } private MediaSource buildMPDMediaSource(Uri uri, String mpdContent) { if (mpdContent == null || mpdContent.isEmpty()) { Log.e(TAG, "Can't build media source. MpdContent is null or empty. " + mpdContent); return null; } // Are you using FrameworkSampleSource or ExtractorSampleSource when you build your player? DashMediaSource dashSource = new DashMediaSource.Factory( new DefaultDashChunkSource.Factory(getMediaDataSourceFactory()), null ) .createMediaSource(getManifest(uri, mpdContent)); if (mTrackErrorFixer != null) { dashSource.addEventListener(Utils.sHandler, mTrackErrorFixer); } return dashSource; } private SabrManifest getSabrManifest(MediaItemFormatInfo formatInfo) { SabrManifestParser parser = new SabrManifestParser(); return parser.parse(formatInfo); } private DashManifest getManifest(MediaItemFormatInfo formatInfo) { DashManifestParser2 parser = new DashManifestParser2(); return parser.parse(formatInfo); } private DashManifest getManifest(Uri uri, InputStream mpdContent) { DashManifestParser parser = new StaticDashManifestParser(); DashManifest result; try { result = parser.parse(uri, mpdContent); } catch (IOException e) { throw new IllegalStateException("Malformed mpd file:\n" + mpdContent, e); } return result; } private DashManifest getManifest(Uri uri, String mpdContent) { DashManifestParser parser = new StaticDashManifestParser(); DashManifest result; try { result = parser.parse(uri, FileHelpers.toStream(mpdContent)); } catch (IOException e) { throw new IllegalStateException("Malformed mpd file:\n" + mpdContent, e); } return result; } /** * Use OkHttp for networking */ private HttpDataSource.Factory buildOkHttpDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { OkHttpDataSourceFactory dataSourceFactory = new OkHttpDataSourceFactory(OkHttpManager.instance().getClient(), USER_AGENT, bandwidthMeter); addCommonHeaders(dataSourceFactory); return dataSourceFactory; } private HttpDataSource.Factory buildCronetDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { CronetDataSourceFactory dataSourceFactory = new CronetDataSourceFactory( new CronetEngineWrapper(CronetManager.getEngine(mContext)), Executors.newSingleThreadExecutor(), null, bandwidthMeter, (int) OkHttpManager.getConnectTimeoutMs(), (int) OkHttpManager.getReadTimeoutMs(), true, USER_AGENT); addCommonHeaders(dataSourceFactory); return dataSourceFactory; } /** * Use built-in component for networking */ private HttpDataSource.Factory buildDefaultHttpDataSourceFactory(DefaultBandwidthMeter bandwidthMeter) { DefaultHttpDataSourceFactory dataSourceFactory = new DefaultHttpDataSourceFactory( USER_AGENT, bandwidthMeter, (int) OkHttpManager.getConnectTimeoutMs(), (int) OkHttpManager.getReadTimeoutMs(), true); // allowCrossProtocolRedirects = true addCommonHeaders(dataSourceFactory); // cause troubles for some users return dataSourceFactory; } private static void addCommonHeaders(BaseFactory dataSourceFactory) { // Doesn't work // Trying to fix 429 error (too many requests) //String authorization = RetrofitOkHttpHelper.getAuthHeaders().get("Authorization"); // //if (authorization != null) { // dataSourceFactory.getDefaultRequestProperties().set("Authorization", authorization); //} //HeaderManager headerManager = new HeaderManager(context); //HashMap headers = headerManager.getHeaders(); // NOTE: "Accept-Encoding" should not be set manually (gzip is added by default). //for (String header : headers.keySet()) { // if (EXO_HEADERS.contains(header)) { // dataSourceFactory.getDefaultRequestProperties().set(header, headers.get(header)); // } //} // Emulate browser request //dataSourceFactory.getDefaultRequestProperties().set("accept", "*/*"); //dataSourceFactory.getDefaultRequestProperties().set("accept-encoding", "identity"); // Next won't work: gzip, deflate, br //dataSourceFactory.getDefaultRequestProperties().set("accept-language", "en-US,en;q=0.9"); //dataSourceFactory.getDefaultRequestProperties().set("dnt", "1"); //dataSourceFactory.getDefaultRequestProperties().set("origin", "https://www.youtube.com"); //dataSourceFactory.getDefaultRequestProperties().set("referer", "https://www.youtube.com/"); //dataSourceFactory.getDefaultRequestProperties().set("sec-fetch-dest", "empty"); //dataSourceFactory.getDefaultRequestProperties().set("sec-fetch-mode", "cors"); //dataSourceFactory.getDefaultRequestProperties().set("sec-fetch-site", "cross-site"); // WARN: Compression won't work with legacy streams. // "Accept-Encoding" should not be set manually (gzip is added by default). // Otherwise you should do decompression yourself. // Source: https://stackoverflow.com/questions/18898959/httpurlconnection-not-decompressing-gzip/42346308#42346308 //dataSourceFactory.getDefaultRequestProperties().set("Accept-Encoding", AppConstants.ACCEPT_ENCODING_DEFAULT); } public void setTrackErrorFixer(TrackErrorFixer trackErrorFixer) { mTrackErrorFixer = trackErrorFixer; } public void release() { mMediaDataSourceFactory = null; } @NonNull private DefaultSsChunkSource.Factory getSsChunkSourceFactory() { return new DefaultSsChunkSource.Factory(getMediaDataSourceFactory()); } @NonNull private SabrChunkSource.Factory getSabrChunkSourceFactory() { return new DefaultSabrChunkSource.Factory(getMediaDataSourceFactory(), MAX_SEGMENTS_PER_LOAD); } @NonNull private DashChunkSource.Factory getDashChunkSourceFactory() { return new DefaultDashChunkSource.Factory(getMediaDataSourceFactory(), MAX_SEGMENTS_PER_LOAD); } private Factory getMediaDataSourceFactory() { if (mMediaDataSourceFactory == null) { mMediaDataSourceFactory = buildDataSourceFactory(USE_BANDWIDTH_METER); } return mMediaDataSourceFactory; } // EXO: 2.10 - 2.12 private static class StaticDashManifestParser extends DashManifestParser { @Override protected DashManifest buildMediaPresentationDescription( long availabilityStartTime, long durationMs, long minBufferTimeMs, boolean dynamic, long minUpdateTimeMs, long timeShiftBufferDepthMs, long suggestedPresentationDelayMs, long publishTimeMs, ProgramInformation programInformation, UtcTimingElement utcTiming, Uri location, List periods) { return new DashManifest( availabilityStartTime, durationMs, minBufferTimeMs, false, minUpdateTimeMs, timeShiftBufferDepthMs, suggestedPresentationDelayMs, publishTimeMs, programInformation, utcTiming, location, periods); } } // EXO: 2.13 //private static class StaticDashManifestParser extends DashManifestParser { // @Override // protected DashManifest buildMediaPresentationDescription( // long availabilityStartTime, // long durationMs, // long minBufferTimeMs, // boolean dynamic, // long minUpdateTimeMs, // long timeShiftBufferDepthMs, // long suggestedPresentationDelayMs, // long publishTimeMs, // @Nullable ProgramInformation programInformation, // @Nullable UtcTimingElement utcTiming, // @Nullable ServiceDescriptionElement serviceDescription, // @Nullable Uri location, // List periods) { // return new DashManifest( // availabilityStartTime, // durationMs, // minBufferTimeMs, // false, // minUpdateTimeMs, // timeShiftBufferDepthMs, // suggestedPresentationDelayMs, // publishTimeMs, // programInformation, // utcTiming, // serviceDescription, // location, // periods); // } //} } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/LiveDashManifestParser.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer; import android.net.Uri; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.dash.DashSegmentIndex; import com.google.android.exoplayer2.source.dash.manifest.AdaptationSet; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; import com.google.android.exoplayer2.source.dash.manifest.Descriptor; import com.google.android.exoplayer2.source.dash.manifest.Period; import com.google.android.exoplayer2.source.dash.manifest.RangedUri; import com.google.android.exoplayer2.source.dash.manifest.Representation; import com.google.android.exoplayer2.source.dash.manifest.Representation.MultiSegmentRepresentation; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.MultiSegmentBase; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SegmentList; import com.google.android.exoplayer2.source.dash.manifest.SegmentBase.SegmentTimelineElement; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.querystringparser.UrlQueryString; import com.liskovsoft.sharedutils.querystringparser.UrlQueryStringFactory; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; /** * Supported ExoPlayer versions: 2.10.6 */ @SuppressWarnings("unchecked") public class LiveDashManifestParser extends DashManifestParser { private static final String TAG = LiveDashManifestParser.class.getSimpleName(); // Should be close to zero but not zero to increase buffer size to 30 sec (Radio Record). // Higher values may produce 'url not working' error. private static final long MAX_LIVE_STREAM_LENGTH_MS = 30 * 1_000; // Usually gaming streams. 10 hrs max. private static final long MAX_PAST_STREAM_LENGTH_MS = 12 * 60 * 60 * 1_000; private static final long MAX_NEW_STREAM_LENGTH_MS = 30 * 1_000; private DashManifest mOldManifest; private long mOldSegmentNum; @Override public DashManifest parse(Uri uri, InputStream inputStream) throws IOException { DashManifest manifest = super.parse(uri, inputStream); //Log.d(TAG, "Parse start: " + System.currentTimeMillis()); appendManifest(manifest); //Log.d(TAG, "Parse end: " + System.currentTimeMillis()); return mOldManifest; } private void appendManifest(DashManifest newManifest) { if (newManifest == null) { return; } // Optimize ram usage on short streams (< 2 hours) if (getFirstSegmentNum(newManifest) == 0) { // Short stream. No need to do something special. mOldManifest = newManifest; // Below line will be needed later (> 2 hours), when the stream no longer starts from 0 segment mOldSegmentNum = getLastSegmentNum(newManifest); return; } // Even 4+ hours streams could have different length. // So, we should take into account last segment num instead of first one. long newSegmentNum = getLastSegmentNum(newManifest); if (mOldManifest == null) { //recreateMissingSegments(newManifest); //newManifest.availabilityStartTimeMs = -1; Period newPeriod = newManifest.getPeriod(0); // TODO: modified //newPeriod.startMs = 0; Helpers.setField(newPeriod, "startMs", 0); mOldSegmentNum = newSegmentNum; for (int i = 0; i < newPeriod.adaptationSets.size(); i++) { for (int j = 0; j < newPeriod.adaptationSets.get(i).representations.size(); j++) { MultiSegmentRepresentation representation = (MultiSegmentRepresentation) newPeriod.adaptationSets.get(i).representations.get(j); //representation.presentationTimeOffsetUs = 0; // TODO: modified //SegmentList newSegmentList = (SegmentList) representation.segmentBase; SegmentList newSegmentList = (SegmentList) Helpers.getField(representation, "segmentBase"); // TODO: modified //newSegmentList.presentationTimeOffset = 0; Helpers.setField(newSegmentList, "presentationTimeOffset", 0); // TODO: modified //newSegmentList.startNumber = 0; Helpers.setField(newSegmentList, "startNumber", 0); } } mOldManifest = newManifest; return; } //long oldSegmentNum = getFirstSegmentNum(mManifest); Period oldPeriod = mOldManifest.getPeriod(0); Period newPeriod = newManifest.getPeriod(0); for (int i = 0; i < oldPeriod.adaptationSets.size(); i++) { for (int j = 0; j < oldPeriod.adaptationSets.get(i).representations.size(); j++) { appendRepresentation( oldPeriod.adaptationSets.get(i).representations.get(j), newPeriod.adaptationSets.get(i).representations.get(j), newSegmentNum - mOldSegmentNum ); } } mOldSegmentNum = newSegmentNum; //mManifest.timeShiftBufferDepthMs += (newSegmentNum - oldSegmentNum) * 5_000; } private static void appendRepresentation(Representation oldRepresentation, Representation newRepresentation, long segmentNumShift) { if (segmentNumShift <= 0) { return; } MultiSegmentRepresentation oldMultiRepresentation = (MultiSegmentRepresentation) oldRepresentation; MultiSegmentRepresentation newMultiRepresentation = (MultiSegmentRepresentation) newRepresentation; // TODO: modified //SegmentList oldSegmentList = (SegmentList) oldRepresentation.segmentBase; SegmentList oldSegmentList = (SegmentList) Helpers.getField(oldMultiRepresentation, "segmentBase"); // TODO: modified //SegmentList newSegmentList = (SegmentList) newRepresentation.segmentBase; SegmentList newSegmentList = (SegmentList) Helpers.getField(newMultiRepresentation, "segmentBase"); // TODO: modified //List oldMediaSegments = oldSegmentList.mediaSegments; List oldMediaSegments = (List) Helpers.getField(oldSegmentList, "mediaSegments"); // TODO: modified //List newMediaSegments = newSegmentList.mediaSegments; List newMediaSegments = (List) Helpers.getField(newSegmentList, "mediaSegments"); oldMediaSegments.addAll( newMediaSegments.subList(newMediaSegments.size() - (int) segmentNumShift, newMediaSegments.size())); // TODO: modified //List oldSegmentTimeline = oldSegmentList.segmentTimeline; List oldSegmentTimeline = (List) Helpers.getField(oldSegmentList, "segmentTimeline"); // segmentTimeline is the same for all segments if (oldMediaSegments.size() != oldSegmentTimeline.size()) { SegmentTimelineElement lastTimeline = oldSegmentTimeline.get(oldSegmentTimeline.size() - 1); // TODO: modified //long lastTimelineDuration = lastTimeline.duration; long lastTimelineDuration = (Long) Helpers.getField(lastTimeline, "duration"); // TODO: modified //long lastTimelineStartTime = lastTimeline.startTime; long lastTimelineStartTime = (Long) Helpers.getField(lastTimeline, "startTime"); for (int i = 1; i <= segmentNumShift; i++) { oldSegmentTimeline.add(new SegmentTimelineElement(lastTimelineStartTime + (lastTimelineDuration * i), lastTimelineDuration)); } //oldSegmentTimeline.addAll( // newSegmentList.segmentTimeline.subList(newSegmentList.segmentTimeline.size() - (int) segmentNumShift - 1, newSegmentList.segmentTimeline.size())); } } private static void recreateMissingSegments(DashManifest manifest) { if (manifest == null) { return; } long minUpdatePeriodMs = (long) Helpers.getField(manifest, "minUpdatePeriodMs"); long timeShiftBufferDepthMs = (long) Helpers.getField(manifest, "timeShiftBufferDepthMs"); // active live stream long durationMs = (long) Helpers.getField(manifest, "durationMs"); // past live stream long firstSegmentNum = getFirstSegmentNum(manifest); long firstSegmentDurationMs = getFirstSegmentDurationMs(manifest); long currentSegmentCount = getSegmentCount(manifest); if (minUpdatePeriodMs <= 0) { // past live stream // May has different length 5_000 (4hrs) or 2_000 (2hrs) minUpdatePeriodMs = durationMs / (currentSegmentCount - 1) / 10 * 10; // Round ending digits } if (minUpdatePeriodMs != firstSegmentDurationMs) { // variable segment timeline (unpredictable) return; } boolean isNewStream = firstSegmentNum < 10_000 && currentSegmentCount > 3; boolean isPastStream = durationMs > 0 && currentSegmentCount > 3; long maxSegmentsCount = (isPastStream ? MAX_PAST_STREAM_LENGTH_MS : isNewStream ? MAX_NEW_STREAM_LENGTH_MS : MAX_LIVE_STREAM_LENGTH_MS) / minUpdatePeriodMs; long recreateSegmentCount = Math.min(firstSegmentNum, maxSegmentsCount - currentSegmentCount); if (recreateSegmentCount <= 0) { return; } // 2_000 Ms streams has variable limit values in url (that is unpredictable) if (minUpdatePeriodMs <= 2_000) { return; // url won't work on small (2_000Ms) segments } // Skip past streams that are truncated (truncated streams have a problems) if ((isNewStream || isPastStream) && firstSegmentNum > recreateSegmentCount) { return; } if (timeShiftBufferDepthMs > 0) { // active live stream Helpers.setField(manifest, "timeShiftBufferDepthMs", timeShiftBufferDepthMs + (recreateSegmentCount * minUpdatePeriodMs)); } else { // past live stream Helpers.setField(manifest, "durationMs", durationMs + (recreateSegmentCount * minUpdatePeriodMs)); } Period oldPeriod = manifest.getPeriod(0); for (int i = 0; i < oldPeriod.adaptationSets.size(); i++) { AdaptationSet adaptationSet = oldPeriod.adaptationSets.get(i); lazyRecreateRepresentations(adaptationSet, recreateSegmentCount, minUpdatePeriodMs); //List representations = adaptationSet.representations; //for (int j = 0; j < representations.size(); j++) { // Representation oldRepresentation = representations.get(j); // recreateRepresentation(oldRepresentation, recreateSegmentCount, minUpdatePeriodMs); //} } } private static void recreateRepresentation(Representation oldRepresentation, long segmentCount, long minUpdatePeriodMs) { MultiSegmentRepresentation oldMultiRepresentation = (MultiSegmentRepresentation) oldRepresentation; SegmentList oldSegmentList = (SegmentList) Helpers.getField(oldMultiRepresentation, "segmentBase"); List oldMediaSegments = (List) Helpers.getField(oldSegmentList, "mediaSegments"); RangedUri firstSegment = oldMediaSegments.get(0); RangedUri secondSegment = oldMediaSegments.get(1); long start = firstSegment.start; long length = firstSegment.length; String firstSegmentUri = (String) Helpers.getField(firstSegment, "referenceUri"); String secondSegmentUri = (String) Helpers.getField(secondSegment, "referenceUri"); UrlQueryString firstSegmentQuery = UrlQueryStringFactory.parse("/" + firstSegmentUri); UrlQueryString secondSegmentQuery = UrlQueryStringFactory.parse("/" + secondSegmentUri); long firstSegmentNum = Helpers.parseLong(firstSegmentQuery.get("sq")); long firstSegmentLimit = Helpers.parseLong(firstSegmentQuery.get("lmt")); long secondSegmentLimit = Helpers.parseLong(secondSegmentQuery.get("lmt")); long limitDiff = secondSegmentLimit - firstSegmentLimit; // Skip variable segment limit (huge limit diff values) if (firstSegmentNum <= 0 || limitDiff > 100) { return; } long presentationTimeOffsetUs = oldRepresentation.presentationTimeOffsetUs; Helpers.setField(oldRepresentation, "presentationTimeOffsetUs", presentationTimeOffsetUs - (segmentCount * minUpdatePeriodMs * 1_000)); long currentSegmentNum = firstSegmentNum - 1; long currentSegmentLimit = firstSegmentLimit - limitDiff; for (int i = 1; i <= segmentCount; i++) { oldMediaSegments.add(0, new RangedUri(String.format("sq/%s/lmt/%s", currentSegmentNum, currentSegmentLimit), start, length)); currentSegmentNum--; currentSegmentLimit -= limitDiff; } List oldSegmentTimeline = (List) Helpers.getField(oldSegmentList, "segmentTimeline"); // segmentTimeline is the same for all segments if (oldMediaSegments.size() != oldSegmentTimeline.size()) { SegmentTimelineElement lastTimeline = oldSegmentTimeline.get(oldSegmentTimeline.size() - 1); long lastTimelineDuration = (Long) Helpers.getField(lastTimeline, "duration"); long lastTimelineStartTime = (Long) Helpers.getField(lastTimeline, "startTime"); for (int i = 1; i <= segmentCount; i++) { oldSegmentTimeline.add(new SegmentTimelineElement(lastTimelineStartTime + (lastTimelineDuration * i), lastTimelineDuration)); } } Log.d(TAG, "Recreate representation: done"); } private static void lazyRecreateRepresentations(AdaptationSet adaptationSet, long segmentCount, long minUpdatePeriodMs) { List representations = adaptationSet.representations; List newRepresentations = new ArrayList<>(); for (int j = 0; j < representations.size(); j++) { Representation oldRepresentation = representations.get(j); newRepresentations.add(new MultiSegmentRepresentationWrapper((MultiSegmentRepresentation) oldRepresentation, segmentCount, minUpdatePeriodMs)); } Helpers.setField(adaptationSet, "representations", newRepresentations); } private static long getFirstSegmentNum(DashManifest manifest) { DashSegmentIndex dashSegmentIndex = manifest.getPeriod(0).adaptationSets.get(0).representations.get(0).getIndex(); return dashSegmentIndex.getFirstSegmentNum(); } private static long getLastSegmentNum(DashManifest manifest) { DashSegmentIndex dashSegmentIndex = manifest.getPeriod(0).adaptationSets.get(0).representations.get(0).getIndex(); return dashSegmentIndex.getFirstSegmentNum() + dashSegmentIndex.getSegmentCount(DashSegmentIndex.INDEX_UNBOUNDED) - 1; } private static long getSegmentCount(DashManifest manifest) { return manifest.getPeriod(0).adaptationSets.get(0).representations.get(0).getIndex().getSegmentCount(C.TIME_UNSET); } private static long getFirstSegmentDurationMs(DashManifest manifest) { DashSegmentIndex dashSegmentIndex = manifest.getPeriod(0).adaptationSets.get(0).representations.get(0).getIndex(); return dashSegmentIndex.getDurationUs(getFirstSegmentNum(manifest), C.TIME_UNSET) / 1_000; } private static class MultiSegmentRepresentationWrapper extends MultiSegmentRepresentation { private long mSegmentCount; private long mMinUpdatePeriodMs; private boolean mInitDone; public MultiSegmentRepresentationWrapper(MultiSegmentRepresentation origin, long segmentCount, long minUpdatePeriodMs) { this(origin.revisionId, origin.format, origin.baseUrl, (SegmentList) Helpers.getField(origin, "segmentBase"), origin.inbandEventStreams); mSegmentCount = segmentCount; mMinUpdatePeriodMs = minUpdatePeriodMs; } public MultiSegmentRepresentationWrapper( long revisionId, Format format, String baseUrl, MultiSegmentBase segmentBase, List inbandEventStreams) { super(revisionId, format, baseUrl, segmentBase, inbandEventStreams); } // DashSegmentIndex implementation. @Override public RangedUri getSegmentUrl(long segmentIndex) { init(); return super.getSegmentUrl(segmentIndex); } @Override public long getSegmentNum(long timeUs, long periodDurationUs) { init(); return super.getSegmentNum(timeUs, periodDurationUs); } @Override public long getTimeUs(long segmentIndex) { init(); return super.getTimeUs(segmentIndex); } @Override public long getDurationUs(long segmentIndex, long periodDurationUs) { init(); return super.getDurationUs(segmentIndex, periodDurationUs); } @Override public long getFirstSegmentNum() { init(); return super.getFirstSegmentNum(); } @Override public int getSegmentCount(long periodDurationUs) { init(); return super.getSegmentCount(periodDurationUs); } @Override public boolean isExplicit() { init(); return super.isExplicit(); } private void init() { if (mInitDone) { return; } recreateRepresentation(this, mSegmentCount, mMinUpdatePeriodMs); mInitDone = true; } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/controller/ExoPlayerController.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.controller; import android.content.Context; import android.os.Build; import android.os.Build.VERSION; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.liskovsoft.mediaserviceinterfaces.data.MediaItemFormatInfo; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.BuildConfig; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.listener.PlayerEventListener; import com.liskovsoft.smartyoutubetv2.common.exoplayer.ExoMediaSourceFactory; import com.liskovsoft.smartyoutubetv2.common.exoplayer.errors.TrackErrorFixer; import com.liskovsoft.smartyoutubetv2.common.exoplayer.other.VolumeBooster; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.ExoFormatItem; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.FormatItem; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackInfoFormatter2; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackSelectorManager; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackSelectorUtil; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track.MediaTrack; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track.VideoTrack; import com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.ExoUtils; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; import java.io.InputStream; import java.lang.ref.WeakReference; import java.util.List; public class ExoPlayerController implements Player.EventListener { private static final String TAG = ExoPlayerController.class.getSimpleName(); private final Context mContext; private final ExoMediaSourceFactory mMediaSourceFactory; private final TrackSelectorManager mTrackSelectorManager; private final TrackInfoFormatter2 mTrackFormatter; private final TrackErrorFixer mTrackErrorFixer; private boolean mOnSourceChanged; private WeakReference

I assume that the tracks already have been sorted in descendants order.
*

Details: {@code com.liskovsoft.smartyoutubetv.flavors.exoplayer.youtubeinfoparser.mpdbuilder.MyMPDBuilder} * @param group the group * @param trackIndex current track in group * @return */ private int getRelatedTrackOffsets(TrackGroup group, int trackIndex) { int prevHeight = 0; int offset = 0; for (int i = trackIndex; i > 0; i--) { Format format = group.getFormat(i); if (prevHeight == 0) { prevHeight = format.height; } else if (prevHeight == format.height) { offset++; } else { break; } } return offset; } private static class Renderer { public boolean isDisabled; public TrackGroupArray trackGroups; public MediaTrack[][] mediaTracks; public SortedSet sortedTracks; public MediaTrack selectedTrack; } private static class MediaTrackFormatComparator implements Comparator { @Override public int compare(MediaTrack mediaTrack1, MediaTrack mediaTrack2) { Format format1 = mediaTrack1.format; Format format2 = mediaTrack2.format; if (format1 == null) { // assume it's auto option return -1; } if (format2 == null) { // assume it's auto option return 1; } // sort subtitles/audio tracks by language code if (format1.language != null && format2.language != null) { int result = format1.language.compareTo(format2.language); if (result != 0) { return result; } } int leftVal = format2.width + (int) format2.frameRate + MediaTrack.getCodecWeight(format2.codecs); int rightVal = format1.width + (int) format1.frameRate + MediaTrack.getCodecWeight(format1.codecs); int delta = leftVal - rightVal; if (delta == 0) { int delta2 = format2.bitrate - format1.bitrate; return delta2 == 0 ? 1 : delta2; // NOTE: don't return 0 or track will be removed } return delta; } } private boolean isTrackUnique(MediaTrack mediaTrack) { if (!mIsMergedSource) { return true; } Format format = mediaTrack.format; // Remove hls un-complete formats altogether if (format.codecs == null || format.codecs.isEmpty() || format.bitrate <= 0) { return false; } String hdrTag = TrackSelectorUtil.isHdrFormat(format) ? "hdr" : ""; String formatId = format.width + format.height + format.frameRate + format.sampleMimeType + hdrTag + format.language; Integer bitrate = mBlacklist.get(formatId); //if (bitrate == null || (bitrate < format.bitrate || mediaTrack instanceof AudioTrack)) { // mBlacklist.put(formatId, format.bitrate + 500_000); // return true; //} if (bitrate == null || bitrate < format.bitrate || (mediaTrack instanceof AudioTrack && Math.abs(bitrate - format.bitrate) > 10_000)) { int diff = mediaTrack instanceof AudioTrack ? 0 : 500_000; // video bitrate min diff mBlacklist.put(formatId, format.bitrate + diff); return true; } return false; } /** * Trying to fix error 'AudioSink.InitializationException: AudioTrack init failed'
* By removing mp4a tracks with high bitrate. */ private boolean isErrorInAudio(MediaTrack mediaTrack) { if (mediaTrack == null || mediaTrack.format == null) { return false; } if (!PlayerTweaksData.instance(mContext).isUnsafeAudioFormatsEnabled()) { return isUnsafeFormat(mediaTrack); } switch (Build.MODEL) { case "Smart TV Pro": // Smart TV Pro (G03_4K_GB) - TCL return isUnsafeFormat(mediaTrack); } return false; } private boolean isUnsafeFormat(MediaTrack mediaTrack) { return mediaTrack.isMP4ACodec() && mediaTrack.format.bitrate >= 195_000; } private String fixLangCode(String langCode) { if (langCode == null) { return null; } switch (langCode) { case "in": // Wrong Indonesian return "id"; // Correct Indonesian default: return langCode; } } public MediaTrack getSelectedTrack(int rendererIndex) { initRenderer(rendererIndex); Renderer renderer = mRenderers[rendererIndex]; if (renderer == null) { return null; } return renderer.selectedTrack; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/selector/TrackSelectorUtil.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.selector; import android.text.TextUtils; import android.util.Pair; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.util.MimeTypes; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track.SubtitleTrack; import java.util.HashMap; public class TrackSelectorUtil { private static final String CODEC_PREFIX_AV1 = "av01"; private static final String CODEC_PREFIX_AVC = "avc"; private static final String CODEC_PREFIX_VP9 = "vp9"; private static final String CODEC_PREFIX_VP09 = "vp09"; private static final String CODEC_PREFIX_MP4A = "mp4a"; private static final String CODEC_PREFIX_VORBIS = "vorbis"; private static final String CODEC_PREFIX_VP9_HDR = "vp9.2"; private static final String CODEC_SUFFIX_AV1_HDR = "10.0.110.09.18.09.0"; private static final String CODEC_SUFFIX_AV1_HDR2 = "10.0.110.09.16.09.0"; private static final String CODEC_SHORT_AV1 = "av1"; private static final String HDR_PROFILE_ENDING = "hdr"; private static final String SEPARATOR = ", "; private static final HashMap mResolutionMap = new HashMap<>(); // Unicode chars: https://symbl.cc/en/search/?q=mark public static final String HIGH_BITRATE_MARK = "\uD83D\uDC8E"; // diamond // Try to amplify resolution of aspect ratios that differ from 16:9 static { mResolutionMap.put(256, 144); mResolutionMap.put(426, 240); mResolutionMap.put(640, 360); mResolutionMap.put(854, 480); mResolutionMap.put(1280, 720); mResolutionMap.put(1920, 1080); mResolutionMap.put(2048, 1440); // Tom Zanetti - Didn't Know mResolutionMap.put(2560, 1440); mResolutionMap.put(3120, 2160); // Мастерская Синдиката - Мы собрали суперкар КУВАЛДОЙ! mResolutionMap.put(3840, 2160); mResolutionMap.put(7680, 4320); } /** * Builds a track name for display. * * @param format {@link Format} of the track. * @return a generated name specific to the track. */ public static CharSequence buildTrackNameShort(Format format) { String trackName; if (MimeTypes.isVideo(format.sampleMimeType)) { trackName = joinWithSeparator(joinWithSeparator(joinWithSeparator(joinWithSeparator(joinWithSeparator(buildResolutionShortString(format), buildFPSString(format)), buildBitrateString(format)), extractCodec(format)), buildHDRString(format)), buildHighBitrateMark(format)); } else if (MimeTypes.isAudio(format.sampleMimeType)) { trackName = joinWithSeparator(joinWithSeparator(joinWithSeparator(joinWithSeparator(joinWithSeparator(buildLanguageString(format), buildAudioPropertyString(format)), buildBitrateString(format)), extractCodec(format)), buildChannels(format)), buildDrcMark(format)); } else if (MimeTypes.isText(format.sampleMimeType)) { trackName = buildLanguageString(format); } else { trackName = joinWithSeparator(joinWithSeparator(buildLanguageString(format), buildBitrateString(format)), extractCodec(format)); } return trackName.length() == 0 ? "unknown" : trackName; } /** * Add high bitrate (Premium) mark */ public static String buildHighBitrateMark(Format format) { // Unicode chars: https://symbl.cc/en/search/?q=mark return isHighBitrateFormat(format) ? HIGH_BITRATE_MARK : ""; } public static boolean isHighBitrateFormat(Format format) { return format != null && Helpers.equalsAny(format.id, "15"); } public static boolean isHlsFormat(Format format) { return format != null && format.containerMimeType == null && format.height >= 1080; } public static String buildHDRString(Format format) { if (format == null) { return ""; } return isHdrFormat(format) ? "HDR" : ""; } private static String buildFPSString(Format format) { return format.frameRate == Format.NO_VALUE ? "" : Helpers.formatFloat(format.frameRate) + "fps"; } /** * Build short resolution: e.g. 720p, 1080p etc
* Try to amplify resolution of aspect ratios that differ from 16:9 */ private static String buildResolutionShortString(Format format) { return getResolutionLabel(format); } private static String buildAudioPropertyString(Format format) { return format.channelCount == Format.NO_VALUE || format.sampleRate == Format.NO_VALUE ? "" : format.channelCount + "ch, " + format.sampleRate + "Hz"; } private static String buildLanguageString(Format format) { return TextUtils.isEmpty(format.language) || "und".equals(format.language) ? "" : SubtitleTrack.trimIfAuto(format.language); } private static String buildBitrateString(Format format) { double bitrateMB = Helpers.round(format.bitrate / 1_000_000f, 2); return format.bitrate == Format.NO_VALUE || bitrateMB == 0 ? "" : String.format("%sMbps", Helpers.formatFloat(bitrateMB)); } private static String joinWithSeparator(String first, String second) { return first.length() == 0 ? second : (second.length() == 0 ? first : first + SEPARATOR + second); } /** * Add html color tag */ private static String color(String input, String color) { return String.format("%s", color, input); } public static boolean isHdrFormat(Format format) { if (format == null) { return false; } return isHdrFormat(format.id, format.codecs); } public static boolean isHdrFormat(String id, String codecs) { return id != null ? isHdrFormat(id) : isHdrCodec(codecs); } private static boolean isHdrCodec(String codec) { if (codec == null) { return false; } return codec.equals(CODEC_PREFIX_VP9_HDR) || Helpers.endsWithAny(codec, CODEC_SUFFIX_AV1_HDR, CODEC_SUFFIX_AV1_HDR2, HDR_PROFILE_ENDING); } private static boolean isHdrFormat(String id) { if (id == null) { return false; } // webm hdr range: 330-337 // mp4 hdr range: 694-701 int parsed = Helpers.parseInt(id); return (parsed >= 330 && parsed <= 337) || (parsed >= 694 && parsed <=701); } public static String extractCodec(Format format) { if (format.codecs == null) { return ""; } return codecNameShort(format.codecs); } public static String extractBitrate(Format format, int places) { double bitrateMB = Helpers.round(format.bitrate / 1_000_000f, places); return format.bitrate == Format.NO_VALUE || bitrateMB == 0 ? "" : Helpers.formatFloat(bitrateMB); } public static String codecNameShort(String codecNameFull) { if (codecNameFull == null) { return null; } String codec = codecNameFull.toLowerCase(); String[] codecNames = {CODEC_PREFIX_AV1, CODEC_PREFIX_AVC, CODEC_PREFIX_VP9, CODEC_PREFIX_VP09, CODEC_PREFIX_MP4A, CODEC_PREFIX_VORBIS}; for (String codecName : codecNames) { if (codec.contains(codecName)) { return fixShortCodecName(codecName); } } return codec; } private static String fixShortCodecName(String shortCodecName) { if (shortCodecName == null) { return null; } switch (shortCodecName) { case CODEC_PREFIX_AV1: return CODEC_SHORT_AV1; case CODEC_PREFIX_VP09: return CODEC_PREFIX_VP9; } return shortCodecName; } public static String buildChannels(Format format) { return is51Audio(format) ? "5.1" : ""; } public static String buildDrcMark(Format format) { return isDrc(format) ? "DRC" : ""; } public static boolean isDrc(Format format) { return format != null && (Helpers.endsWithAny(format.id, "drc") || format.isDrc); } public static boolean is51Audio(Format format) { if (format == null) { return false; } return format.bitrate > 300000; } public static boolean is48KAudio(Format format) { if (format == null) { return false; } return format.sampleRate >= 48000; } public static boolean isVideo(Format format) { return MimeTypes.isVideo(format.sampleMimeType); } public static boolean isAudio(Format format) { return MimeTypes.isAudio(format.sampleMimeType); } public static String stateToString(int playbackState) { return playbackState == Player.STATE_BUFFERING ? "STATE_BUFFERING" : playbackState == Player.STATE_READY ? "STATE_READY" : playbackState == Player.STATE_IDLE ? "STATE_IDLE" : "STATE_ENDED"; } /** * Check widescreen: 16:9, 16:8, 16:7 etc
*/ public static boolean isWideScreenOld(Format format) { if (format == null) { return false; } return format.width / (float) format.height >= 1.77; } /** * MOD: Mimic official behavior (handle low res shorts etc) */ public static boolean isWideScreen(Format format) { if (format == null) { return false; } return format.width / (float) format.height > 1; } public static boolean is4to3Screen(Format format) { if (format == null) { return false; } float ratio = format.width / (float) format.height; float targetRatio = 4 / (float) 3; return Math.abs(ratio - targetRatio) < 0.2; } public static String getResolutionLabel(Format format) { Pair labels = getResolutionPrefixAndHeight(format); if (labels == null) { return null; } String prefix = labels.first != null ? "(" + labels.first + ") " : ""; return prefix + labels.second + "p"; } public static String getShortResolutionLabel(Format format) { Pair labels = getResolutionPrefixAndHeight(format); if (labels == null) { return null; } return labels.first != null ? labels.first : labels.second; } private static int getOriginHeight(int height) { int originHeight = height; // Non-regular examples // Мастерская Синдиката - Мы собрали суперкар КУВАЛДОЙ! - 2560x1182 // [AMATORY] ALL STARS: LIVE IN MOSCOW 2021 - 2560x1088 // Dream Theater - Night Terror (Official Video) - 2560x1066 if (height < 160) { // 256x144 originHeight = 144; } else if (height < 260) { // 426x240 originHeight = 240; } else if (height < 380) { // 640x360 originHeight = 360; } else if (height < 500) { // 854x480 originHeight = 480; } else if (height < 750) { // 1280x720 originHeight = 720; } else if (height < 1085) { // 1920x1080 originHeight = 1080; } else if (height < 1500) { // 2560x1440 originHeight = 1440; } else if (height < 2200) { // 3840x2160 originHeight = 2160; } else if (height < 4400) { // 7680x4320 originHeight = 4320; } return originHeight; } private static int getHeightByWidth(int width) { int originHeight = -1; if (width < 280) { // 256x144 originHeight = 144; } else if (width < 440) { // 426x240 originHeight = 240; } else if (width < 650) { // 640x360 originHeight = 360; } else if (width < 870) { // 854x480 originHeight = 480; } else if (width < 1300) { // 1280x720 originHeight = 720; } else if (width < 2000) { // 1920x1080 originHeight = 1080; } else if (width < 2600) { // 2560x1440 originHeight = 1440; } else if (width < 3900) { // 3840x2160 originHeight = 2160; } else if (width < 7700) { // 7680x4320 originHeight = 4320; } return originHeight; } /** * Get the height in terms like it's understandable by the codec. */ public static int getRealHeight(Format format) { if (format == null) { return -1; } int height = format.height; int width = format.width; if (width == Format.NO_VALUE || height == Format.NO_VALUE) { return -1; } // Make resolution calculation of the vertical videos more closer to the official app. //boolean isUltraWide = (float) width/height >= 2.1; // maybe 2.3??? //int originHeight = isUltraWide ? getHeightByWidth(width) : getOriginHeight(Math.min(height, width)); boolean isUltraWide = (float) width/height >= 2; // maybe 2.1 int originHeight = isUltraWide ? getHeightByWidth(width) : getOriginHeight(height); return originHeight; } private static String getResolutionPrefix(int originHeight) { String prefix = null; if (originHeight == 1440) { prefix = "2K"; } else if (originHeight == 2160) { prefix = "4K"; } else if (originHeight == 4320) { prefix = "8K"; } return prefix; } private static Pair getResolutionPrefixAndHeight(Format format) { int originHeight = getRealHeight(format); if (originHeight == -1) { return null; } String prefix = getResolutionPrefix(originHeight); return new Pair<>(prefix, String.valueOf(originHeight)); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/selector/track/AudioTrack.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track; import com.google.android.exoplayer2.Format; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackSelectorUtil; public class AudioTrack extends MediaTrack { public AudioTrack(int rendererIndex) { super(rendererIndex); } //@Override //public int inBounds(MediaTrack track2) { // int result = compare(track2); // // // Select at least something. // if (result == -1 && track2 != null && track2.format != null) { // result = 1; // } // // return result; //} @Override public int inBounds(MediaTrack track2) { if (format == null) { return -1; } if (track2 == null || track2.format == null) { return 1; } int result = -1; String id1 = format.id; String id2 = track2.format.id; int bitrate1 = format.bitrate; int bitrate2 = track2.format.bitrate; // Compare by language isn't robust since language set may not contain target language String language1 = format.language; String language2 = track2.format.language; boolean sameLanguage = sameLanguage(language1, language2); if (Helpers.equals(id1, id2)) { result = 0; } else if (bitrate1 != -1 && bitrateLessOrEquals(bitrate2, bitrate1)) { result = 1; } else if (bitrate1 == -1 && (TrackSelectorUtil.is51Audio(format) || !TrackSelectorUtil.is51Audio(track2.format))) { result = 1; } return result; } @Override public int compare(MediaTrack track2) { if (format == null) { return -1; } if (track2 == null || track2.format == null) { return 1; } int result = -1; if (format.id == null && format.language == null && format.bitrate == -1 && codecEquals(this, track2)) { result = 0; } else if (Helpers.equals(format.id, track2.format.id) && drcEquals(format, track2.format)) { result = 1; } else if (!codecEquals(this, track2) || !drcEquals(format, track2.format) || bitrateLessOrEquals(track2.format.bitrate, format.bitrate)) { result = 0; } return result; } private boolean sameLanguage(String language1, String language2) { return Helpers.equals(language1, language2) || (language1 == null || language2 == null); } private static boolean drcEquals(Format format1, Format format2) { if (format1 == null || format2 == null) { return false; } return TrackSelectorUtil.isDrc(format1) == TrackSelectorUtil.isDrc(format2); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/selector/track/MediaTrack.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection.Definition; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackSelectorManager; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackSelectorUtil; public abstract class MediaTrack { private static final int BITRATE_DIFF_PERCENTS = 7; private static final int VP9_WEIGHT = 31; private static final int AVC_WEIGHT = 28; private static final int AV1_WEIGHT = 14; private static int sVP9Weight = VP9_WEIGHT; private static int sAVCWeight = AVC_WEIGHT; private static int sAV1Weight = AV1_WEIGHT; public Format format; public int groupIndex = -1; public int trackIndex = -1; public boolean isSelected; public boolean isSaved; public boolean isPreset; public int rendererIndex; public MediaTrack(int rendererIndex) { this.rendererIndex = rendererIndex; } public static MediaTrack from(int rendererIndex, TrackGroupArray groups, Definition definition) { MediaTrack mediaTrack = forRendererIndex(rendererIndex); if (mediaTrack == null || groups == null || definition == null || definition.tracks == null) { return null; } mediaTrack.groupIndex = groups.indexOf(definition.group); mediaTrack.trackIndex = definition.tracks[0]; return mediaTrack; } public abstract int compare(MediaTrack track2); public abstract int inBounds(MediaTrack track2); public static MediaTrack forRendererIndex(int rendererIndex) { switch (rendererIndex) { case TrackSelectorManager.RENDERER_INDEX_VIDEO: return new VideoTrack(rendererIndex); case TrackSelectorManager.RENDERER_INDEX_AUDIO: return new AudioTrack(rendererIndex); case TrackSelectorManager.RENDERER_INDEX_SUBTITLE: return new SubtitleTrack(rendererIndex); } return null; } protected static boolean bitrateLessOrEquals(int bitrate1, int bitrate2) { if (bitrate1 == -1 || bitrate2 == -1) { return true; } return bitrate1 <= bitrate2 || bitrateAlmostEquals(bitrate1, bitrate2); } private static boolean codecEquals(String codecs1, String codecs2) { if (codecs1 == null || codecs2 == null) { return false; } return Helpers.equals(TrackSelectorUtil.codecNameShort(codecs1), TrackSelectorUtil.codecNameShort(codecs2)); } private static boolean codecEquals(Format format1, Format format2) { if (format1 == null || format2 == null) { return false; } return codecEquals(format1.codecs, format2.codecs); } private static boolean bitrateEquals(Format format1, Format format2) { if (format1 == null || format2 == null) { return false; } return format1.bitrate == format2.bitrate; } private static boolean bitrateAlmostEquals(int bitrate1, int bitrate2) { return bitrate1 == bitrate2 || Math.abs(bitrate2 - bitrate1) < (bitrate2 / 100 * BITRATE_DIFF_PERCENTS); } private static boolean preferByBitrate(Format format1, Format format2) { if (format1 == null) { return false; } if (format2 == null) { return true; } if (!codecEquals(format1, format2)) { return true; } return format1.bitrate > format2.bitrate; } public static boolean codecEquals(MediaTrack track1, MediaTrack track2) { if (track1 == null || track2 == null) { return false; } return codecEquals(track1.format, track2.format); } public static boolean bitrateEquals(MediaTrack track1, MediaTrack track2) { if (track1 == null || track2 == null) { return false; } return bitrateEquals(track1.format, track2.format); } public static boolean preferByBitrate(MediaTrack track1, MediaTrack track2) { if (track1 == null || track2 == null) { return false; } return preferByBitrate(track1.format, track2.format); } public static int getCodecWeight(MediaTrack track) { if (track == null || track.format == null) { return 0; } return getCodecWeight(track.format.codecs); } public static int getCodecWeight(String codec) { return isVP9Codec(codec) ? sVP9Weight : isAVCCodec(codec) ? sAVCWeight : isAV1Codec(codec) ? sAV1Weight : 0; } public static boolean preferByCodec(MediaTrack prevTrack, MediaTrack nextTrack) { return getCodecWeight(prevTrack) - getCodecWeight(nextTrack) > 0; } public static void preferAvcOverVp9(boolean prefer) { sAVCWeight = prefer ? VP9_WEIGHT : AVC_WEIGHT; sVP9Weight = prefer ? AVC_WEIGHT : VP9_WEIGHT; } public static boolean preferByDrc(MediaTrack origin, MediaTrack prevTrack, MediaTrack nextTrack) { if (!(origin instanceof AudioTrack)) { return true; } boolean isDrcOrigin = TrackSelectorUtil.isDrc(origin.format); boolean isDrcPrev = TrackSelectorUtil.isDrc(prevTrack.format); boolean isDrcNext = TrackSelectorUtil.isDrc(nextTrack.format); boolean preferByDrc = (isDrcOrigin == isDrcNext) || (isDrcPrev == isDrcNext); return preferByDrc; } public boolean isVP9Codec() { return format != null && isVP9Codec(format.codecs); } public boolean isAV1Codec() { return format != null && isAV1Codec(format.codecs); } public boolean isMP4ACodec() { return format != null && isMP4ACodec(format.codecs); } public boolean isEmpty() { return groupIndex == -1 && trackIndex == -1; } public int getWidth() { return format != null ? format.width : -1; } public int getHeight() { return format != null ? format.height : -1; } private static boolean isVP9Codec(String codec) { if (codec == null) { return false; } codec = codec.toLowerCase(); return Helpers.containsAny(codec, "vp9", "vp09"); } private static boolean isAVCCodec(String codec) { if (codec == null) { return false; } codec = codec.toLowerCase(); return codec.contains("avc"); } private static boolean isAV1Codec(String codec) { if (codec == null) { return false; } codec = codec.toLowerCase(); return codec.contains("av01"); } private static boolean isMP4ACodec(String codec) { if (codec == null) { return false; } codec = codec.toLowerCase(); return codec.contains("mp4a"); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/selector/track/SubtitleTrack.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.youtubeapi.videoinfo.models.TranslatedCaptionTrack; import java.util.regex.Pattern; public class SubtitleTrack extends MediaTrack { private static final Pattern AUTO_PATTERN = Pattern.compile(" \\(.*\\)$"); // May have mismatches e.g. 'English (United Kingdom)' private static final Pattern TRIM_PATTERN1 = Pattern.compile(" \\(.*\\) - .*"); private static final Pattern TRIM_PATTERN2 = Pattern.compile(" - .*"); private static final Pattern MARKER_PATTERN = Pattern.compile(".$"); public SubtitleTrack(int rendererIndex) { super(rendererIndex); } @Override public int inBounds(MediaTrack track2) { if (format == null) { return -1; } if (track2 == null || track2.format == null) { return 1; } int result = -1; if (Helpers.startsWith(track2.format.language, trim(format.language))) { // partial match if (!isAuto(track2.format.language)) { // Prefer original subs result = 0; } else { result = 1; } } return result; } @Override public int compare(MediaTrack track2) { if (format == null) { return -1; } if (track2 == null || track2.format == null) { return 1; } int result = -1; if (Helpers.startsWith(track2.format.language, trim(format.language))) { // partial match if (!isAuto(format.language)) { // Prefer original subs result = 1; } else if (isAuto(format.language) && isAuto(track2.format.language)) { result = 0; } } return result; } /** * Autogenerated subs by the user
* NOTE: Has an exceptions, like English (United Kingdom) */ private static boolean isAutoUser(String language) { if (language == null) { return false; } return Helpers.matchAll(language, AUTO_PATTERN); } /** * Remove autogenerated and other stuff */ public static String trim(String language) { if (language == null) { return null; } return trimMarker(Helpers.replace(Helpers.replace(language, TRIM_PATTERN1, ""), TRIM_PATTERN2, "")); // english - us bla -> english } public static String trimIfAuto(String language) { return isAuto(language) ? trim(language) : language; } /** * NOTE: Breaks Portuguese (Portugal) but fixes other similar languages */ private static String trimAuto(String language) { if (language == null) { return null; } return Helpers.replace(trimMarker(language), AUTO_PATTERN, ""); // english (us) bla -> english } /** * Removes auto translate marker */ private static String trimMarker(String language) { if (language != null && language.endsWith(TranslatedCaptionTrack.TRANSLATE_MARKER)) { return Helpers.replace(language, MARKER_PATTERN, ""); } else { return language; } } public static boolean isAuto(String language) { return hasMarker(language); } private static boolean hasMarker(String language) { return language != null && language.endsWith(TranslatedCaptionTrack.TRANSLATE_MARKER); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/selector/track/VideoTrack.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track; import com.google.android.exoplayer2.Format; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackSelectorUtil; public class VideoTrack extends MediaTrack { private static final float LOW_FPS_THRESHOLD = 10; private static final int SIZE_EQUITY_THRESHOLD_PERCENT = 5; // was 15 before private static final int COMPARE_TYPE_IN_BOUNDS = 0; private static final int COMPARE_TYPE_IN_BOUNDS_NO_FPS = 4; private static final int COMPARE_TYPE_IN_BOUNDS_PRESET = 1; private static final int COMPARE_TYPE_IN_BOUNDS_PRESET_NO_FPS = 3; private static final int COMPARE_TYPE_NORMAL = 2; public static boolean sIsNoFpsPresetsEnabled; public VideoTrack(int rendererIndex) { super(rendererIndex); } public static boolean sizeEquals(int size1, int size2) { return sizeEquals(size1, size2, SIZE_EQUITY_THRESHOLD_PERCENT); } public static boolean sizeEquals(int size1, int size2, int diffPercents) { if (size1 == -1 || size2 == -1) { return false; } int threshold = size1 / 100 * diffPercents; boolean diffWithinThreshold = Math.abs(size1 - size2) < threshold; return diffWithinThreshold; } private static boolean sizeLessOrEquals(int size1, int size2) { if (size1 == -1 || size2 == -1) { return false; } return size1 <= size2 || sizeEquals(size1, size2); } private static boolean sizeLess(int size1, int size2) { if (size1 == -1 || size2 == -1) { return false; } return !sizeEquals(size1, size2) && size1 < size2; } private static boolean fpsEquals(float fps1, float fps2) { if (fps1 == -1 || fps2 == -1) { return true; // probably LIVE translation } int threshold = 10; boolean diffWithinThreshold = Math.abs(fps1 - fps2) < threshold; return diffWithinThreshold; } private static boolean fpsLessOrEquals(float fps1, float fps2) { if (fps1 == -1 || fps2 == -1) { return true; // probably LIVE translation } return fps1 <= fps2 || fpsEquals(fps1, fps2); } private static boolean fpsLess(float fps1, float fps2) { // NOTE: commented out after no fps fix option //if (fps1 == -1 || fps2 == -1) { // return true; // probably LIVE translation //} if (fps1 == -1 && fps2 == -1) { return false; } return !fpsEquals(fps1, fps2) && fps1 < fps2; } private boolean isLive(MediaTrack track) { return track.format.frameRate == -1; } @Override public int inBounds(MediaTrack track2) { if (format == null) { return -1; } // NOTE: MultiFpsFormat: 25/50, 30/60. Currently no more that 720p. boolean isMultiFpsFormat = sizeLessOrEquals(format.height, 720); // Detect preset by id presence boolean isPreset = format.id == null; if (isPreset) { // Overcome non-standard aspect ratio by getting resolution label //boolean respectPresetsFps = !sIsNoFpsPresetsEnabled || // sizeEquals(format.height, TrackSelectorUtil.getOriginHeight(track2.format.height)); boolean respectPresetsFps = !sIsNoFpsPresetsEnabled || sizeEquals(TrackSelectorUtil.getRealHeight(format), TrackSelectorUtil.getRealHeight(track2.format)); return compare(track2, isMultiFpsFormat || respectPresetsFps ? COMPARE_TYPE_IN_BOUNDS_PRESET : COMPARE_TYPE_IN_BOUNDS_PRESET_NO_FPS); } else { return compare(track2, isMultiFpsFormat ? COMPARE_TYPE_IN_BOUNDS : COMPARE_TYPE_IN_BOUNDS_NO_FPS); } } @Override public int compare(MediaTrack track2) { return compare(track2, COMPARE_TYPE_NORMAL); } private int compare(MediaTrack track2, int type) { if (format == null) { return -1; } if (track2 == null || track2.format == null) { return 1; } int size1; int size2; // MOD: Mimic official behavior (handle low res shorts etc) //size1 = TrackSelectorUtil.isWideScreen(format) || exceedHeightLimit(format) ? format.height : format.width; //size2 = TrackSelectorUtil.isWideScreen(track2.format) || exceedHeightLimit(track2.format) ? track2.format.height : track2.format.width; size1 = TrackSelectorUtil.getRealHeight(format); size2 = TrackSelectorUtil.getRealHeight(track2.format); String id1 = format.id; String id2 = track2.format.id; // Low fps (e.g. 8fps) on original track could break whole comparison float frameRate1 = format.frameRate < LOW_FPS_THRESHOLD ? 30 : format.frameRate; float frameRate2 = track2.format.frameRate < LOW_FPS_THRESHOLD ? 30 : track2.format.frameRate; String codecs1 = format.codecs; String codecs2 = track2.format.codecs; int bitrate1 = format.bitrate; int bitrate2 = track2.format.bitrate; int result; if (type == COMPARE_TYPE_IN_BOUNDS) { result = inBounds(id1, id2, size1, size2, frameRate1, frameRate2, codecs1, codecs2, bitrate1, bitrate2); } else if (type == COMPARE_TYPE_IN_BOUNDS_NO_FPS) { result = inBounds(id1, id2, size1, size2, -1, -1, codecs1, codecs2, bitrate1, bitrate2); } else if (type == COMPARE_TYPE_IN_BOUNDS_PRESET) { result = inBoundsPreset(id1, id2, size1, size2, frameRate1, frameRate2, codecs1, codecs2); } else if (type == COMPARE_TYPE_IN_BOUNDS_PRESET_NO_FPS) { result = inBoundsPreset(id1, id2, size1, size2, -1, -1, codecs1, codecs2); } else { result = compare(id1, id2, size1, size2, frameRate1, frameRate2, codecs1, codecs2, bitrate1, bitrate2); } return result; } private int inBounds(String id1, String id2, int size1, int size2, float frameRate1, float frameRate2, String codecs1, String codecs2, int bitrate1, int bitrate2) { int result = -1; // Fix same id between normal videos and shorts if (Helpers.equals(id1, id2) && (size1 == size2)) { result = 0; //} else if (sizeLessOrEquals(size2, size1) && fpsLessOrEquals(frameRate2, frameRate1) && bitrateLessOrEquals(bitrate2, bitrate1)) { } else if (sizeLessOrEquals(size2, size1) && fpsLessOrEquals(frameRate2, frameRate1)) { // NOTE: Removed bitrate check to fix shorts? if (TrackSelectorUtil.isHdrFormat(id1, codecs1) == TrackSelectorUtil.isHdrFormat(id2, codecs2)) { result = 1; } else if (TrackSelectorUtil.isHdrFormat(id1, codecs1)) { result = 1; } } return result; } private int inBoundsPreset(String id1, String id2, int size1, int size2, float frameRate1, float frameRate2, String codecs1, String codecs2) { // Fix same id between normal videos and shorts if (Helpers.equals(id1, id2) && (size1 == size2)) { return 0; } if (!TrackSelectorUtil.isHdrFormat(id1, codecs1) && TrackSelectorUtil.isHdrFormat(id2, codecs2)) { return -1; } if (fpsLess(frameRate1, frameRate2)) { return -1; } if (sizeLess(size1, size2)) { return -1; } return 1; } //private int inBoundsPreset(String id1, String id2, int size1, int size2, float frameRate1, float frameRate2, String codecs1, String codecs2) { // int result = -1; // // if (Helpers.equals(id1, id2)) { // result = 0; // } else if (sizeEquals(size1, size2)) { // if (fpsEquals(frameRate2, frameRate1)) { // if (TrackSelectorUtil.isHdrCodec(codecs1) == TrackSelectorUtil.isHdrCodec(codecs2)) { // result = 0; // } else if (TrackSelectorUtil.isHdrCodec(codecs1)) { // result = 1; // } // } else if (fpsLessOrEquals(frameRate2, frameRate1)) { // if (TrackSelectorUtil.isHdrCodec(codecs1) == TrackSelectorUtil.isHdrCodec(codecs2)) { // result = 1; // } else if (TrackSelectorUtil.isHdrCodec(codecs1)) { // result = 1; // } // } // } else if (sizeLessOrEquals(size2, size1) && fpsLessOrEquals(frameRate2, frameRate1)) { // if (TrackSelectorUtil.isHdrCodec(codecs1) == TrackSelectorUtil.isHdrCodec(codecs2)) { // result = 1; // } else if (TrackSelectorUtil.isHdrCodec(codecs1)) { // result = 1; // } // } // // return result; //} private int compare(String id1, String id2, int size1, int size2, float frameRate1, float frameRate2, String codecs1, String codecs2, int bitrate1, int bitrate2) { if (Helpers.equals(id1, id2)) { return 0; } int leftScore = 0; int rightScore = 0; if (TrackSelectorUtil.isHdrFormat(id1, codecs1) && !TrackSelectorUtil.isHdrFormat(id2, codecs2)) { leftScore += 3; } else if (TrackSelectorUtil.isHdrFormat(id2, codecs2) && !TrackSelectorUtil.isHdrFormat(id1, codecs1)) { rightScore += 3; } if (fpsLess(frameRate1, frameRate2)) { rightScore += 2; } else if (fpsLess(frameRate2, frameRate1)) { leftScore += 2; } if (sizeLess(size1, size2)) { rightScore += 1; } else if (sizeLess(size2, size1)) { leftScore += 1; } int result = leftScore - rightScore; return result == 0 && TrackSelectorUtil.codecNameShort(codecs1).equals(TrackSelectorUtil.codecNameShort(codecs2)) ? bitrate1 - bitrate2 : result; } //private int compare(String id1, String id2, int size1, int size2, float frameRate1, float frameRate2, String codecs1, String codecs2) { // int result = -1; // // if (Helpers.equals(id1, id2)) { // result = 0; // } else if (sizeLessOrEquals(size2, size1)) { // if (fpsLessOrEquals(frameRate2, frameRate1)) { // if (TrackSelectorUtil.isHdrCodec(codecs1) == TrackSelectorUtil.isHdrCodec(codecs2)) { // result = 0; // } else if (TrackSelectorUtil.isHdrCodec(codecs2)) { // result = -1; // } else { // result = 1; // } // } // } // // return result; //} // Shorts fix private boolean exceedHeightLimit(Format format) { return format.height > 1080; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/ExoUtils.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; public class ExoUtils { private static String sVideoDecoderName; public static boolean isPlaying(ExoPlayer player) { if (player == null) { return false; } // Exo 2.9 //return player.getPlayWhenReady() && player.getPlaybackState() == Player.STATE_READY; // Exo 2.10 and up return player.isPlaying(); } public static boolean isLoading(ExoPlayer player) { if (player == null) { return false; } return player.isLoading(); } public static MediaCodecInfo getCapsDecoderInfo(String mimeType) { MediaCodecInfo info = null; try { // Exo 2.9 //info = MediaCodecUtil.getDecoderInfo(mimeType, false); // Exo 2.10 and up info = MediaCodecUtil.getDecoderInfo(mimeType, false, false); } catch (DecoderQueryException e) { e.printStackTrace(); } return info; } public static void updateVideoDecoderInfo(MediaCodecInfo codecInfo) { if (codecInfo == null) { return; } sVideoDecoderName = codecInfo.name; } public static String getVideoDecoderName() { return sVideoDecoderName; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/renderer/CustomOverridesRenderersFactory.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.renderer; import android.content.Context; import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.audio.AudioCapabilities; import com.google.android.exoplayer2.audio.AudioProcessor; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.audio.DefaultAudioSink; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.util.AmazonQuirks; import com.google.android.exoplayer2.video.VideoRendererEventListener; import com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.selector.BlacklistMediaCodecSelector; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; import java.util.ArrayList; /** * Main intent: override audio delay */ public class CustomOverridesRenderersFactory extends CustomRenderersFactoryBase { private static final String TAG = CustomOverridesRenderersFactory.class.getSimpleName(); private static final String[] FRAME_DROP_FIX_LIST = { "T95ZPLUS (q201_3GB)", "UGOOS (UGOOS)", "55UC30G (ctl_iptv_mrvl)" // Kivi 55uc30g }; private final PlayerData mPlayerData; private final PlayerTweaksData mPlayerTweaksData; // 2.12, 2.13 //private int mOperationMode = MediaCodecRenderer.OPERATION_MODE_SYNCHRONOUS; // 2.9, 2.10, 2.11 public CustomOverridesRenderersFactory(Context activity) { super(activity); mPlayerData = PlayerData.instance(activity); mPlayerTweaksData = PlayerTweaksData.instance(activity); setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON); // setEnableDecoderFallback(true); // Exo 2.10 and up if (mPlayerTweaksData.isSWDecoderForced()) { setMediaCodecSelector(new BlacklistMediaCodecSelector()); } AmazonQuirks.disableSnappingToVsync(mPlayerTweaksData.isSnappingToVsyncDisabled()); AmazonQuirks.skipProfileLevelCheck(mPlayerTweaksData.isProfileLevelCheckSkipped()); } // 2.12, 2.13 //public CustomOverridesRenderersFactory(Context activity) { // super(activity); // // mPlayerData = PlayerData.instance(activity); // mPlayerTweaksData = PlayerTweaksData.instance(activity); // // // Exo 2.12 (Exclusive experimental tweaks) // //mOperationMode = MediaCodecRenderer.OPERATION_MODE_ASYNCHRONOUS_DEDICATED_THREAD_ASYNCHRONOUS_QUEUEING; // //experimentalSetMediaCodecOperationMode(mOperationMode); // // setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON); // // setEnableDecoderFallback(true); // Exo 2.10 and up // // if (mPlayerTweaksData.isSWDecoderForced()) { // setMediaCodecSelector(new BlacklistMediaCodecSelector()); // } // // AmazonQuirks.skipProfileLevelCheck(mPlayerTweaksData.isProfileLevelCheckSkipped()); //} // Exo 2.9 //@Override //protected void buildAudioRenderers(Context context, int extensionRendererMode, MediaCodecSelector mediaCodecSelector, // @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, // AudioProcessor[] audioProcessors, Handler eventHandler, AudioRendererEventListener eventListener, // ArrayList out) { // super.buildAudioRenderers(context, extensionRendererMode, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, // audioProcessors, eventHandler, eventListener, out); // // CustomMediaCodecAudioRenderer audioRenderer = null; // // if (mPlayerData.getAudioDelayMs() != 0) { // audioRenderer = // new CustomMediaCodecAudioRenderer(context, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, eventHandler, // eventListener, new DefaultAudioSink(AudioCapabilities.getCapabilities(context), audioProcessors)); // // audioRenderer.setAudioDelayMs(mPlayerData.getAudioDelayMs()); // } // // replaceAudioRenderer(out, audioRenderer); //} // Exo 2.9 //@Override //protected void buildVideoRenderers(Context context, int extensionRendererMode, MediaCodecSelector mediaCodecSelector, // @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, // Handler eventHandler, VideoRendererEventListener eventListener, long allowedVideoJoiningTimeMs, // ArrayList out) { // super.buildVideoRenderers(context, extensionRendererMode, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, eventHandler, // eventListener, allowedVideoJoiningTimeMs, out); // // CustomMediaCodecVideoRenderer videoRenderer = null; // // if (mPlayerTweaksData.isFrameDropFixEnabled() || mPlayerTweaksData.isAmlogicFixEnabled()) { // videoRenderer = new CustomMediaCodecVideoRenderer(context, mediaCodecSelector, allowedVideoJoiningTimeMs, drmSessionManager, // playClearSamplesWithoutKeys, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); // // videoRenderer.enableFrameDropFix(mPlayerTweaksData.isFrameDropFixEnabled()); // videoRenderer.enableAmlogicFix(mPlayerTweaksData.isAmlogicFixEnabled()); // } // // replaceVideoRenderer(out, videoRenderer); //} // 2.10, 2.11 @Override protected void buildAudioRenderers(Context context, @ExtensionRendererMode int extensionRendererMode, MediaCodecSelector mediaCodecSelector, @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, boolean enableDecoderFallback, AudioProcessor[] audioProcessors, Handler eventHandler, AudioRendererEventListener eventListener, ArrayList out) { super.buildAudioRenderers(context, extensionRendererMode, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, audioProcessors, eventHandler, eventListener, out); if (mPlayerData.getAudioDelayMs() == 0 && !mPlayerTweaksData.isAudioSyncFixEnabled()) { // Improve performance a bit by eliminating calculations presented in custom renderer. return; } DelayMediaCodecAudioRenderer audioRenderer = new DelayMediaCodecAudioRenderer(context, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, eventHandler, eventListener, new DefaultAudioSink(AudioCapabilities.getCapabilities(context), audioProcessors)); audioRenderer.setAudioDelayMs(mPlayerData.getAudioDelayMs()); audioRenderer.enableAudioSyncFix(mPlayerTweaksData.isAudioSyncFixEnabled()); replaceAudioRenderer(out, audioRenderer); } // 2.10, 2.11 @Override protected void buildVideoRenderers(Context context, int extensionRendererMode, MediaCodecSelector mediaCodecSelector, @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, boolean enableDecoderFallback, Handler eventHandler, VideoRendererEventListener eventListener, long allowedVideoJoiningTimeMs, ArrayList out) { super.buildVideoRenderers(context, extensionRendererMode, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, eventHandler, eventListener, allowedVideoJoiningTimeMs, out); if (!mPlayerTweaksData.isAmazonFrameDropFixEnabled() && !mPlayerTweaksData.isSonyFrameDropFixEnabled() && !mPlayerTweaksData.isAmlogicFixEnabled()) { // Improve performance a bit by eliminating some if conditions presented in tweaks. // But we need to obtain codec real name somehow. So use interceptor below. DebugInfoMediaCodecVideoRenderer videoRenderer = new DebugInfoMediaCodecVideoRenderer(context, mediaCodecSelector, allowedVideoJoiningTimeMs, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); videoRenderer.enableSetOutputSurfaceWorkaround(true); // Force enable? replaceVideoRenderer(out, videoRenderer); return; } TweaksMediaCodecVideoRenderer videoRenderer = new TweaksMediaCodecVideoRenderer(context, mediaCodecSelector, allowedVideoJoiningTimeMs, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); videoRenderer.enableFrameDropFix(mPlayerTweaksData.isAmazonFrameDropFixEnabled()); videoRenderer.enableFrameDropSonyFix(mPlayerTweaksData.isSonyFrameDropFixEnabled()); videoRenderer.enableAmlogicFix(mPlayerTweaksData.isAmlogicFixEnabled()); videoRenderer.enableSetOutputSurfaceWorkaround(true); // Force enable? replaceVideoRenderer(out, videoRenderer); } // Exo 2.12, 2.13 //@Override //protected void buildAudioRenderers(Context context, // int extensionRendererMode, // MediaCodecSelector mediaCodecSelector, // boolean enableDecoderFallback, // AudioSink audioSink, // Handler eventHandler, // AudioRendererEventListener eventListener, // ArrayList out) { // super.buildAudioRenderers( // context, // extensionRendererMode, // mediaCodecSelector, // enableDecoderFallback, // audioSink, // eventHandler, // eventListener, // out); // // if (mPlayerData.getAudioDelayMs() == 0) { // // Improve performance a bit by eliminating calculations presented in custom renderer. // // return; // } // // DelayMediaCodecAudioRenderer audioRenderer = // new DelayMediaCodecAudioRenderer(context, // mediaCodecSelector, // enableDecoderFallback, // eventHandler, // eventListener, // audioSink); // // audioRenderer.setAudioDelayMs(mPlayerData.getAudioDelayMs()); // // // Restore global operation mode (needed for stability) // //audioRenderer.experimentalSetMediaCodecOperationMode(mOperationMode); // // replaceAudioRenderer(out, audioRenderer); //} // Exo 2.12, 2.13 //@Override //protected void buildVideoRenderers(Context context, // int extensionRendererMode, // MediaCodecSelector mediaCodecSelector, // boolean enableDecoderFallback, // Handler eventHandler, // VideoRendererEventListener eventListener, // long allowedVideoJoiningTimeMs, // ArrayList out) { // super.buildVideoRenderers( // context, // extensionRendererMode, // mediaCodecSelector, // enableDecoderFallback, // eventHandler, // eventListener, // allowedVideoJoiningTimeMs, // out); // // // if (!mPlayerTweaksData.isFrameDropFixEnabled() && !mPlayerTweaksData.isAmlogicFixEnabled()) { // // Improve performance a bit by eliminating some if conditions presented in tweaks. // // But we need to obtain codec real name somehow. So use interceptor below. // // DebugInfoMediaCodecVideoRenderer videoRenderer = // new DebugInfoMediaCodecVideoRenderer(context, // mediaCodecSelector, // allowedVideoJoiningTimeMs, // enableDecoderFallback, // eventHandler, // eventListener, // MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); // // // Restore global operation mode (needed for stability) // //videoRenderer.experimentalSetMediaCodecOperationMode(mOperationMode); // videoRenderer.enableSetOutputSurfaceWorkaround(mPlayerTweaksData.isSetOutputSurfaceWorkaroundEnabled()); // // replaceVideoRenderer(out, videoRenderer); // // return; // } // // TweaksMediaCodecVideoRenderer videoRenderer = // new TweaksMediaCodecVideoRenderer(context, // mediaCodecSelector, // allowedVideoJoiningTimeMs, // enableDecoderFallback, // eventHandler, // eventListener, // MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); // // videoRenderer.enableFrameDropFix(mPlayerTweaksData.isFrameDropFixEnabled()); // videoRenderer.enableAmlogicFix(mPlayerTweaksData.isAmlogicFixEnabled()); // videoRenderer.enableSetOutputSurfaceWorkaround(mPlayerTweaksData.isSetOutputSurfaceWorkaroundEnabled()); // // // Restore global operation mode (needed for stability) // //videoRenderer.experimentalSetMediaCodecOperationMode(mOperationMode); // // replaceVideoRenderer(out, videoRenderer); //} } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/renderer/CustomRenderersFactoryBase.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.renderer; import android.content.Context; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; import java.util.ArrayList; public abstract class CustomRenderersFactoryBase extends DefaultRenderersFactory { public CustomRenderersFactoryBase(Context context) { super(context); } protected void replaceVideoRenderer(ArrayList renderers, MediaCodecVideoRenderer videoRenderer) { if (renderers != null && videoRenderer != null) { Renderer originMediaCodecVideoRenderer = null; int index = 0; for (Renderer renderer : renderers) { if (renderer instanceof MediaCodecVideoRenderer) { originMediaCodecVideoRenderer = renderer; break; } index++; } if (originMediaCodecVideoRenderer != null) { // replace origin with custom renderers.remove(originMediaCodecVideoRenderer); renderers.add(index, videoRenderer); } } } protected void replaceAudioRenderer(ArrayList renderers, MediaCodecAudioRenderer audioRenderer) { if (renderers != null && audioRenderer != null) { Renderer originMediaCodecAudioRenderer = null; int index = 0; for (Renderer renderer : renderers) { if (renderer instanceof MediaCodecAudioRenderer) { originMediaCodecAudioRenderer = renderer; break; } index++; } if (originMediaCodecAudioRenderer != null) { // replace origin with custom renderers.remove(originMediaCodecAudioRenderer); renderers.add(index, audioRenderer); } } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/renderer/DebugInfoMediaCodecVideoRenderer.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.renderer; import android.content.Context; import android.media.MediaCodec; import android.os.Build.VERSION; import android.os.Handler; import android.view.Surface; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; import com.google.android.exoplayer2.video.VideoRendererEventListener; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.ExoUtils; public class DebugInfoMediaCodecVideoRenderer extends MediaCodecVideoRenderer { private static final String TAG = DebugInfoMediaCodecVideoRenderer.class.getSimpleName(); private int mFrameIndex; private boolean mIsSetOutputSurfaceWorkaroundEnabled; // Exo 2.9 //public DebugInfoMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, // @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, // @Nullable Handler eventHandler, @Nullable VideoRendererEventListener eventListener, // int maxDroppedFramesToNotify) { // super(context, mediaCodecSelector, allowedJoiningTimeMs, drmSessionManager, playClearSamplesWithoutKeys, eventHandler, eventListener, // maxDroppedFramesToNotify); //} // Exo 2.10, 2.11 public DebugInfoMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, boolean enableDecoderFallback, @Nullable Handler eventHandler, @Nullable VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) { super(context, mediaCodecSelector, allowedJoiningTimeMs, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); } // Exo 2.12, 2.13 //public DebugInfoMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, // boolean enableDecoderFallback, @Nullable Handler eventHandler, // @Nullable VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) { // super(context, mediaCodecSelector, allowedJoiningTimeMs, enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); //} @Override protected CodecMaxValues getCodecMaxValues( MediaCodecInfo codecInfo, Format format, Format[] streamFormats) { ExoUtils.updateVideoDecoderInfo(codecInfo); return super.getCodecMaxValues(codecInfo, format, streamFormats); } // Measure real fps. // Note, that you can't accurate measure frame rate because actual frame rate is the average frame rate for the whole video track! // 29.97fps test: https://www.youtube.com/watch?v=LXb3EKWsInQ (Costa Rica) // More info: https://github.com/google/ExoPlayer/issues/4088 //@Override //protected void renderOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { // super.renderOutputBuffer(codec, index, presentationTimeUs); //} // //@Override //protected void renderOutputBufferV21(MediaCodec codec, int index, long presentationTimeUs, long releaseTimeNs) { // super.renderOutputBufferV21(codec, index, presentationTimeUs, releaseTimeNs); // // mFrameIndex++; // // Log.d(TAG, "Real fps: %s", 1_000_000f / (presentationTimeUs / mFrameIndex)); //} @Override protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { // Null surface error on Android 9 (VERSION.SDK_INT >= 28) and above (appears on background audio playback) // Need to be enabled on older version of ExoPlayer (e.g. 2.10.6). // It's because there's no tweaks for modern devices. return mIsSetOutputSurfaceWorkaroundEnabled || super.codecNeedsSetOutputSurfaceWorkaround(name); } /** * Null surface error on Android 9 (VERSION.SDK_INT >= 28) and above (appears on background audio playback)
* Need to be enabled on older version of ExoPlayer (e.g. 2.10.6).
* It's because there's no tweaks for modern devices. */ public void enableSetOutputSurfaceWorkaround(boolean enable) { mIsSetOutputSurfaceWorkaroundEnabled = enable; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/renderer/DelayMediaCodecAudioRenderer.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.renderer; import android.content.Context; import android.media.MediaCodec; import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.audio.AudioSink; import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.liskovsoft.sharedutils.helpers.Helpers; import java.nio.ByteBuffer; public class DelayMediaCodecAudioRenderer extends MediaCodecAudioRenderer { private static final String TAG = DelayMediaCodecAudioRenderer.class.getSimpleName(); private int mDelayUs; private boolean mIsAudioSyncFixEnabled; private boolean mIsAudioSyncFixChanged; // Exo 2.9 //public CustomMediaCodecAudioRenderer(Context context, MediaCodecSelector mediaCodecSelector, // @Nullable DrmSessionManager drmSessionManager, // boolean playClearSamplesWithoutKeys, @Nullable Handler eventHandler, // @Nullable AudioRendererEventListener eventListener, AudioSink audioSink) { // super(context, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, eventHandler, eventListener, audioSink); //} // Exo 2.10, 2.11 public DelayMediaCodecAudioRenderer(Context context, MediaCodecSelector mediaCodecSelector, @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, boolean enableDecoderFallback, @Nullable Handler eventHandler, @Nullable AudioRendererEventListener eventListener, AudioSink audioSink) { super(context, mediaCodecSelector, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, eventHandler, eventListener, audioSink); } // Exo 2.12, 2.13 //public DelayMediaCodecAudioRenderer(Context context, MediaCodecSelector mediaCodecSelector, // boolean enableDecoderFallback, @Nullable Handler eventHandler, // @Nullable AudioRendererEventListener eventListener, AudioSink audioSink) { // super(context, mediaCodecSelector, enableDecoderFallback, eventHandler, eventListener, audioSink); //} @Override public long getPositionUs() { return super.getPositionUs() + mDelayUs; } public void setAudioDelayMs(int delayMs) { mDelayUs = delayMs * 1_000; } public int getAudioDelayMs() { return mDelayUs / 1_000; } @Override protected boolean processOutputBuffer(long positionUs, long elapsedRealtimeUs, MediaCodec codec, ByteBuffer buffer, int bufferIndex, int bufferFlags, long bufferPresentationTimeUs, boolean isDecodeOnlyBuffer, boolean isLastBuffer, Format format) throws ExoPlaybackException { boolean result = super.processOutputBuffer( positionUs, elapsedRealtimeUs, codec, buffer, bufferIndex, bufferFlags, bufferPresentationTimeUs, isDecodeOnlyBuffer, isLastBuffer, format ); // Disable the use of AudioTrack.getTimestamp and force ExoPlayer to go through the legacy path of using // AudioTrack.getPlaybackHeadPosition instead, which might help if the first one drifts but the second one doesn't. if (mIsAudioSyncFixEnabled && mIsAudioSyncFixChanged) { Object audioSink = Helpers.getField(this, "audioSink"); if (audioSink != null) { Object audioTrackPositionTracker = Helpers.getField(audioSink, "audioTrackPositionTracker"); if (audioTrackPositionTracker != null) { Object audioTimestampPoller = Helpers.getField(audioTrackPositionTracker, "audioTimestampPoller"); if (audioTimestampPoller != null) { Helpers.setField(audioTimestampPoller, "audioTimestamp", null); Helpers.setField(audioTimestampPoller, "state", 3); mIsAudioSyncFixChanged = false; } } } } return result; } public void enableAudioSyncFix(boolean enable) { if (mIsAudioSyncFixEnabled == enable) { return; } mIsAudioSyncFixEnabled = enable; mIsAudioSyncFixChanged = true; } public boolean isAudioSyncFixEnabled() { return mIsAudioSyncFixEnabled; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/renderer/TweaksMediaCodecVideoRenderer.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.renderer; import android.annotation.TargetApi; import android.content.Context; import android.media.MediaCodec; import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.video.VideoRendererEventListener; import com.liskovsoft.sharedutils.mylogger.Log; public class TweaksMediaCodecVideoRenderer extends DebugInfoMediaCodecVideoRenderer { private static final String TAG = TweaksMediaCodecVideoRenderer.class.getSimpleName(); private boolean mIsFrameDropFixEnabled; private boolean mIsFrameDropSonyFixEnabled; private boolean mIsAmlogicFixEnabled; // Exo 2.9 //public CustomMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, // @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, // @Nullable Handler eventHandler, @Nullable VideoRendererEventListener eventListener, // int maxDroppedFramesToNotify) { // super(context, mediaCodecSelector, allowedJoiningTimeMs, drmSessionManager, playClearSamplesWithoutKeys, eventHandler, eventListener, // maxDroppedFramesToNotify); //} // Exo 2.10, 2.11 public TweaksMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, @Nullable DrmSessionManager drmSessionManager, boolean playClearSamplesWithoutKeys, boolean enableDecoderFallback, @Nullable Handler eventHandler, @Nullable VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) { super(context, mediaCodecSelector, allowedJoiningTimeMs, drmSessionManager, playClearSamplesWithoutKeys, enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); } // Exo 2.12, 2.13 //public TweaksMediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, long allowedJoiningTimeMs, // boolean enableDecoderFallback, @Nullable Handler eventHandler, // @Nullable VideoRendererEventListener eventListener, int maxDroppedFramesToNotify) { // super(context, mediaCodecSelector, allowedJoiningTimeMs, enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); //} // EXO: 2.10, 2.11, 2.12 @TargetApi(21) protected void renderOutputBufferV21( MediaCodec codec, int index, long presentationTimeUs, long releaseTimeNs) { // Fix frame drops on SurfaceView // https://github.com/google/ExoPlayer/issues/6348 // https://developer.android.com/reference/android/media/MediaCodec#releaseOutputBuffer(int,%20long) super.renderOutputBufferV21(codec, index, presentationTimeUs, mIsFrameDropFixEnabled ? 0 : releaseTimeNs); } // EXO: 2.13 //@TargetApi(21) //protected void renderOutputBufferV21( // MediaCodecAdapter codec, int index, long presentationTimeUs, long releaseTimeNs) { // // Fix frame drops on SurfaceView // // https://github.com/google/ExoPlayer/issues/6348 // // https://developer.android.com/reference/android/media/MediaCodec#releaseOutputBuffer(int,%20long) // super.renderOutputBufferV21(codec, index, presentationTimeUs, 0); //} @Override protected CodecMaxValues getCodecMaxValues( MediaCodecInfo codecInfo, Format format, Format[] streamFormats) { CodecMaxValues maxValues = super.getCodecMaxValues(codecInfo, format, streamFormats); if (mIsAmlogicFixEnabled) { if (maxValues.width < 1920 || maxValues.height < 1089) { Log.d(TAG, "Applying Amlogic fix..."); return new CodecMaxValues( Math.max(maxValues.width, 1920), Math.max(maxValues.height, 1089), maxValues.inputSize); } } return maxValues; } /** * Frame drop fixes on Sony Bravia
* https://github.com/google/ExoPlayer/issues/6348#issuecomment-718986083 */ @Override protected boolean isBufferLate(long earlyUs) { if (mIsFrameDropSonyFixEnabled) { return earlyUs < -1000000; } return super.isBufferLate(earlyUs); } /** * Frame drop fixes on Sony Bravia
* https://github.com/google/ExoPlayer/issues/6348#issuecomment-718986083 */ @Override protected boolean isBufferVeryLate(long earlyUs) { if (mIsFrameDropSonyFixEnabled) { return earlyUs < -1500000; } return super.isBufferVeryLate(earlyUs); } public void enableFrameDropFix(boolean enabled) { mIsFrameDropFixEnabled = enabled; } public void enableFrameDropSonyFix(boolean enabled) { mIsFrameDropSonyFixEnabled = enabled; } public void enableAmlogicFix(boolean enabled) { mIsAmlogicFixEnabled = enabled; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/selector/BlacklistMediaCodecSelector.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.selector; import androidx.annotation.Nullable; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.mediacodec.MediaCodecUtil; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.renderer.CustomOverridesRenderersFactory; import java.util.ArrayList; import java.util.List; /** * Usage {@link CustomOverridesRenderersFactory#setMediaCodecSelector} */ public class BlacklistMediaCodecSelector implements MediaCodecSelector { private static final String TAG = BlacklistMediaCodecSelector.class.getSimpleName(); // list of strings used in blacklisting codecs final static String[] ALL_DECODERS = { "OMX.google.h264.decoder", "OMX.google.vp9.decoder", "OMX.Nvidia.vp9.decoder", "OMX.MTK.VIDEO.DECODER.VP9", "OMX.amlogic.vp9.decoder.awesome", "OMX.amlogic.avc.decoder.awesome", "OMX.qcom.video.decoder.avc", "OMX.rk.video_decoder.avc", "OMX.allwinner.video.decoder.avc" }; final static String[] SW_DECODERS = {"OMX.google"}; final static String[] HW_DECODERS = {"OMX.amlogic", "OMX.MTK", "OMX.Nvidia", "OMX.qcom", "OMX.rk", "OMX.allwinner"}; // Ver. 2.9.6 //@Override //public List getDecoderInfos(String mimeType, boolean requiresSecureDecoder) throws MediaCodecUtil.DecoderQueryException { // // List codecInfos = MediaCodecUtil.getDecoderInfos( // mimeType, requiresSecureDecoder); // // filter codecs based on blacklist template // List filteredCodecInfos = new ArrayList<>(); // for (MediaCodecInfo codecInfo: codecInfos) { // Log.d(TAG, "Checking codec: " + codecInfo); // boolean blacklisted = false; // for (String blackListedCodec: BLACKLISTEDCODECS) { // if (codecInfo != null && codecInfo.name.toLowerCase().contains(blackListedCodec.toLowerCase())) { // Log.d(TAG, "Blacklisting codec: " + blackListedCodec); // blacklisted = true; // break; // } // } // if (!blacklisted) { // filteredCodecInfos.add(codecInfo); // } // } // return filteredCodecInfos; //} // Exo 2.10 and up @Override public List getDecoderInfos(String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) throws MediaCodecUtil.DecoderQueryException { List codecInfos = MediaCodecUtil.getDecoderInfos( mimeType, requiresSecureDecoder, requiresTunnelingDecoder); // filter codecs based on blacklist template List filteredCodecInfos = new ArrayList<>(); for (MediaCodecInfo codecInfo: codecInfos) { Log.d(TAG, "Checking codec: " + codecInfo); boolean blacklisted = false; for (String blacklistedDecoder: HW_DECODERS) { if (codecInfo != null && codecInfo.name.toLowerCase().startsWith(blacklistedDecoder.toLowerCase())) { Log.d(TAG, "Blacklisting decoder: " + blacklistedDecoder); blacklisted = true; break; } } if (!blacklisted) { filteredCodecInfos.add(codecInfo); } } return filteredCodecInfos; } // Exo 2.10 @Nullable @Override public MediaCodecInfo getPassthroughDecoderInfo() throws MediaCodecUtil.DecoderQueryException { return MediaCodecUtil.getPassthroughDecoderInfo(); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/selector/RestoreTrackSelector.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.selector; import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelection.Definition; import com.google.android.exoplayer2.trackselection.TrackSelection.Factory; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.TrackSelectorManager; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.track.MediaTrack; public class RestoreTrackSelector extends DefaultTrackSelector { private static final String TAG = RestoreTrackSelector.class.getSimpleName(); private static final int FORMAT_NOT_SUPPORTED = 19; private static final int FORMAT_FORCE_SUPPORT = 52; private TrackSelectorCallback mCallback; public interface TrackSelectorCallback { Pair onSelectVideoTrack(TrackGroupArray groups, Parameters params); Pair onSelectAudioTrack(TrackGroupArray groups, Parameters params); Pair onSelectSubtitleTrack(TrackGroupArray groups, Parameters params); void updateVideoTrackSelection(TrackGroupArray groups, Parameters params, Definition definition); void updateAudioTrackSelection(TrackGroupArray groups, Parameters params, Definition definition); void updateSubtitleTrackSelection(TrackGroupArray groups, Parameters params, Definition definition); } public RestoreTrackSelector(Factory trackSelectionFactory) { super(trackSelectionFactory); // Could help with Shield resolution bug? //setParameters(buildUponParameters().setForceHighestSupportedBitrate(true)); } public void setOnTrackSelectCallback(TrackSelectorCallback callback) { mCallback = callback; } // Exo 2.9 //@Nullable //@Override //protected TrackSelection selectVideoTrack(TrackGroupArray groups, int[][] formatSupports, int mixedMimeTypeAdaptationSupports, // Parameters params, @Nullable Factory adaptiveTrackSelectionFactory) throws ExoPlaybackException { // if (mCallback != null) { // Pair resultPair = mCallback.onSelectVideoTrack(groups, params); // // if (resultPair != null) { // Log.d(TAG, "selectVideoTrack: choose custom video processing"); // return resultPair.first.toSelection(); // } // } // // Log.d(TAG, "selectVideoTrack: choose default video processing"); // // TrackSelection trackSelection = super.selectVideoTrack(groups, formatSupports, mixedMimeTypeAdaptationSupports, params, adaptiveTrackSelectionFactory); // // // Don't invoke if track already has been selected by the app // if (mCallback != null && trackSelection != null && !params.hasSelectionOverride(TrackSelectorManager.RENDERER_INDEX_VIDEO, groups)) { // mCallback.updateVideoTrackSelection(groups, params, Definition.from(trackSelection)); // } // // return trackSelection; //} // Exo 2.9 //@Nullable //@Override //protected Pair selectAudioTrack(TrackGroupArray groups, int[][] formatSupports, // int mixedMimeTypeAdaptationSupports, Parameters params, // @Nullable Factory adaptiveTrackSelectionFactory) throws ExoPlaybackException { // if (mCallback != null) { // Pair resultPair = mCallback.onSelectAudioTrack(groups, params); // if (resultPair != null) { // Log.d(TAG, "selectVideoTrack: choose custom audio processing"); // return new Pair<>(resultPair.first.toSelection(), new AudioTrackScore(resultPair.second.format, params, RendererCapabilities.FORMAT_HANDLED)); // } // } // // Log.d(TAG, "selectAudioTrack: choose default audio processing"); // // Pair selectionPair = // super.selectAudioTrack(groups, formatSupports, mixedMimeTypeAdaptationSupports, params, adaptiveTrackSelectionFactory); // // // Don't invoke if track already has been selected by the app // if (mCallback != null && selectionPair != null && !params.hasSelectionOverride(TrackSelectorManager.RENDERER_INDEX_AUDIO, groups)) { // mCallback.updateAudioTrackSelection(groups, params, Definition.from(selectionPair.first)); // } // // return selectionPair; //} // Exo 2.9 //@Nullable //@Override //protected Pair selectTextTrack(TrackGroupArray groups, int[][] formatSupport, Parameters params) throws ExoPlaybackException { // if (mCallback != null) { // Pair resultPair = mCallback.onSelectSubtitleTrack(groups, params); // if (resultPair != null) { // Log.d(TAG, "selectTextTrack: choose custom text processing"); // return new Pair<>(resultPair.first.toSelection(), 10); // } // } // // Log.d(TAG, "selectTextTrack: choose default text processing"); // // Pair selectionPair = super.selectTextTrack(groups, formatSupport, params); // // // Don't invoke if track already has been selected by the app // if (mCallback != null && selectionPair != null && !params.hasSelectionOverride(TrackSelectorManager.RENDERER_INDEX_SUBTITLE, groups)) { // mCallback.updateSubtitleTrackSelection(groups, params, Definition.from(selectionPair.first)); // } // // return selectionPair; //} //@Override //public void setParameters(Parameters parameters) { // // Fix dropping to 144p by disabling any overrides. // invalidate(); //} // Exo 2.10 and up @Nullable @Override protected Definition selectVideoTrack(TrackGroupArray groups, int[][] formatSupports, int mixedMimeTypeAdaptationSupports, Parameters params, boolean enableAdaptiveTrackSelection) throws ExoPlaybackException { if (mCallback != null) { Pair resultPair = mCallback.onSelectVideoTrack(groups, params); if (resultPair != null) { Log.d(TAG, "selectVideoTrack: choose custom video processing"); return resultPair.first; } else { return null; // video disabled } } Log.d(TAG, "selectVideoTrack: choose default video processing"); Definition definition = super.selectVideoTrack(groups, formatSupports, mixedMimeTypeAdaptationSupports, params, false); // Don't invoke if track already has been selected by the app if (mCallback != null && definition != null) { mCallback.updateVideoTrackSelection(groups, params, definition); } return definition; } // Exo 2.10 and up @Nullable @Override protected Pair selectAudioTrack(TrackGroupArray groups, int[][] formatSupports, int mixedMimeTypeAdaptationSupports, Parameters params, boolean enableAdaptiveTrackSelection) throws ExoPlaybackException { if (mCallback != null) { Pair resultPair = mCallback.onSelectAudioTrack(groups, params); if (resultPair != null) { Log.d(TAG, "selectVideoTrack: choose custom audio processing"); return new Pair<>(resultPair.first, new AudioTrackScore(resultPair.second.format, params, RendererCapabilities.FORMAT_HANDLED)); } else { return null; // audio disabled } } Log.d(TAG, "selectAudioTrack: choose default audio processing"); Pair definitionPair = super.selectAudioTrack(groups, formatSupports, mixedMimeTypeAdaptationSupports, params, false); // Don't invoke if track already has been selected by the app if (mCallback != null && definitionPair != null) { mCallback.updateAudioTrackSelection(groups, params, definitionPair.first); } return definitionPair; } // Exo 2.10 and up @Nullable @Override protected Pair selectTextTrack(TrackGroupArray groups, int[][] formatSupport, Parameters params, @Nullable String selectedAudioLanguage) throws ExoPlaybackException { if (mCallback != null) { Pair resultPair = mCallback.onSelectSubtitleTrack(groups, params); if (resultPair != null) { Log.d(TAG, "selectTextTrack: choose custom text processing"); return new Pair<>(resultPair.first, new TextTrackScore(resultPair.second.format, params, RendererCapabilities.FORMAT_HANDLED, "")); } } Log.d(TAG, "selectTextTrack: choose default text processing"); Pair definitionPair = super.selectTextTrack(groups, formatSupport, params, selectedAudioLanguage); // Don't invoke if track already has been selected by the app if (mCallback != null && definitionPair != null) { mCallback.updateSubtitleTrackSelection(groups, params, definitionPair.first); } return definitionPair; } private void unlockAllVideoFormats(int[][] formatSupports) { final int videoTrackIndex = 0; for (int j = 0; j < formatSupports[videoTrackIndex].length; j++) { if (formatSupports[videoTrackIndex][j] == FORMAT_NOT_SUPPORTED) { // video format not supported by system decoders formatSupports[videoTrackIndex][j] = FORMAT_FORCE_SUPPORT; // force support of video format } } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/exoplayer/versions/selector/backport/Definition.java ================================================ package com.liskovsoft.smartyoutubetv2.common.exoplayer.versions.selector.backport; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.trackselection.FixedTrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection; // Backport from Exo 2.10 to 2.9 /** Contains of a subset of selected tracks belonging to a {@link TrackGroup}. */ public final class Definition { /** The {@link TrackGroup} which tracks belong to. */ public final TrackGroup group; /** The indices of the selected tracks in {@link #group}. */ public final int[] tracks; /** The track selection reason. One of the {@link C} SELECTION_REASON_ constants. */ public final int reason; /** Optional data associated with this selection of tracks. */ @Nullable public final Object data; /** * @param group The {@link TrackGroup}. Must not be null. * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be * null or empty. May be in any order. */ public Definition(TrackGroup group, int... tracks) { this(group, tracks, C.SELECTION_REASON_UNKNOWN, /* data= */ null); } /** * @param group The {@link TrackGroup}. Must not be null. * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be * @param reason The track selection reason. One of the {@link C} SELECTION_REASON_ constants. * @param data Optional data associated with this selection of tracks. */ public Definition(TrackGroup group, int[] tracks, int reason, @Nullable Object data) { this.group = group; this.tracks = tracks; this.reason = reason; this.data = data; } // Exo 2.10 //public static Definition from(TrackSelection selection) { // return new Definition(selection.getTrackGroup(), selection.getSelectedIndex()); //} // Exo 2.10 //@SuppressWarnings("deprecation") //public TrackSelection toSelection() { // return new FixedTrackSelection.Factory().createTrackSelection(group, null, tracks); //} } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/AppDataSourceManager.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.models.data.SettingsItem; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.AboutSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.AboutSimpleSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.AccountSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.AutoFrameRateSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.BackupSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.SponsorBlockSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.DeArrowSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.GeneralSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.LanguageSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.MainUISettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.PlayerSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.RemoteControlSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.SearchSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.SubtitleSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.FormatItem.VideoPreset; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.ArrayList; import java.util.List; public class AppDataSourceManager { private static AppDataSourceManager sInstance; private AppDataSourceManager() { } public static AppDataSourceManager instance() { if (sInstance == null) { sInstance = new AppDataSourceManager(); } return sInstance; } public List getSettingItems(Context context) { List settingItems = new ArrayList<>(); settingItems.add(new SettingsItem( context.getString(R.string.settings_accounts), () -> AccountSettingsPresenter.instance(context).show(), R.drawable.settings_account)); settingItems.add(new SettingsItem( context.getString(R.string.settings_remote_control), () -> RemoteControlSettingsPresenter.instance(context).show(), R.drawable.settings_cast)); settingItems.add(new SettingsItem( context.getString(R.string.settings_language_country), () -> LanguageSettingsPresenter.instance(context).show(), R.drawable.settings_language)); settingItems.add(new SettingsItem( context.getString(R.string.settings_general), () -> GeneralSettingsPresenter.instance(context).show(), R.drawable.settings_app)); settingItems.add(new SettingsItem( context.getString(R.string.settings_main_ui), () -> MainUISettingsPresenter.instance(context).show(), R.drawable.settings_main_ui)); settingItems.add(new SettingsItem( context.getString(R.string.settings_player), () -> PlayerSettingsPresenter.instance(context).show(), R.drawable.settings_player)); // Don't add afr support check here. // Users want even fake afr settings. settingItems.add(new SettingsItem( context.getString(R.string.auto_frame_rate), () -> AutoFrameRateSettingsPresenter.instance(context).show(), R.drawable.settings_afr)); settingItems.add(new SettingsItem( context.getString(R.string.subtitle_category_title), () -> SubtitleSettingsPresenter.instance(context).show(), R.drawable.settings_subtitles)); settingItems.add(new SettingsItem( context.getString(R.string.settings_search), () -> SearchSettingsPresenter.instance(context).show(), R.drawable.settings_search)); settingItems.add(new SettingsItem( context.getString(R.string.content_block_provider), () -> SponsorBlockSettingsPresenter.instance(context).show(), R.drawable.settings_block)); settingItems.add(new SettingsItem( context.getString(R.string.dearrow_provider), () -> DeArrowSettingsPresenter.instance(context).show(), R.drawable.settings_dearrow)); settingItems.add(new SettingsItem( context.getString(R.string.app_backup_restore), () -> BackupSettingsPresenter.instance(context).show(), R.drawable.settings_backup)); if (Helpers.equalsAny(context.getPackageName(), Utils.KNOWN_PACKAGES)) { settingItems.add(new SettingsItem( context.getString(R.string.settings_about), () -> AboutSettingsPresenter.instance(context).show(), R.drawable.settings_about)); } else { settingItems.add(new SettingsItem( context.getString(R.string.settings_about), () -> AboutSimpleSettingsPresenter.instance(context).show(), R.drawable.settings_about)); } return settingItems; } public VideoPreset[] getVideoPresets() { VideoPreset[] presets = { new VideoPreset("144p 30fps avc", "256,144,30,avc"), new VideoPreset("144p 30fps vp9", "256,144,30,vp9"), new VideoPreset("144p 30fps av01", "256,144,30,av01"), new VideoPreset("240p 30fps avc", "320,240,30,avc"), new VideoPreset("240p 30fps vp9", "320,240,30,vp9"), new VideoPreset("240p 30fps av01", "320,240,30,av01"), new VideoPreset("360p 30fps avc", "640,360,30,avc"), new VideoPreset("360p 30fps vp9", "640,360,30,vp9"), new VideoPreset("360p 30fps av01", "640,360,30,av01"), new VideoPreset("360p 60fps avc", "640,360,60,avc"), new VideoPreset("360p 60fps vp9", "640,360,60,vp9"), new VideoPreset("360p 60fps av01", "640,360,60,av01"), new VideoPreset("480p 30fps avc", "854,480,30,avc"), new VideoPreset("480p 30fps vp9", "854,480,30,vp9"), new VideoPreset("480p 30fps av01", "854,480,30,av01"), new VideoPreset("480p 60fps avc", "854,480,60,avc"), new VideoPreset("480p 60fps vp9", "854,480,60,vp9"), new VideoPreset("480p 60fps av01", "854,480,60,av01"), new VideoPreset("720p 30fps avc", "1280,720,30,avc"), new VideoPreset("720p 60fps avc", "1280,720,60,avc"), new VideoPreset("720p 30fps vp9", "1280,720,30,vp9"), new VideoPreset("720p 60fps vp9", "1280,720,60,vp9"), new VideoPreset("720p 30fps av01", "1280,720,30,av01"), new VideoPreset("720p 60fps av01", "1280,720,60,av01"), new VideoPreset("720p 30fps av01+hdr", "1280,720,30,av01.hdr"), new VideoPreset("720p 60fps av01+hdr", "1280,720,60,av01.hdr"), new VideoPreset("1080p 30fps avc", "1920,1080,30,avc"), new VideoPreset("1080p 60fps avc", "1920,1080,60,avc"), new VideoPreset("1080p 30fps vp9", "1920,1080,30,vp9"), new VideoPreset("1080p 60fps vp9", "1920,1080,60,vp9"), new VideoPreset("1080p 30fps vp9+hdr", "1920,1080,30,vp9.2"), new VideoPreset("1080p 60fps vp9+hdr", "1920,1080,60,vp9.2"), new VideoPreset("1080p 30fps av01", "1920,1080,30,av01"), new VideoPreset("1080p 60fps av01", "1920,1080,60,av01"), new VideoPreset("1080p 30fps av01+hdr", "1920,1080,30,av01.hdr"), new VideoPreset("1080p 60fps av01+hdr", "1920,1080,60,av01.hdr"), new VideoPreset("(2K) 1440p 30fps vp9", "2560,1440,30,vp9"), new VideoPreset("(2K) 1440p 60fps vp9", "2560,1440,60,vp9"), new VideoPreset("(2K) 1440p 30fps vp9+hdr", "2560,1440,30,vp9.2"), new VideoPreset("(2K) 1440p 60fps vp9+hdr", "2560,1440,60,vp9.2"), new VideoPreset("(2K) 1440p 30fps av01", "2560,1440,30,av01"), new VideoPreset("(2K) 1440p 60fps av01", "2560,1440,60,av01"), new VideoPreset("(2K) 1440p 30fps av01+hdr", "2560,1440,30,av01.hdr"), new VideoPreset("(2K) 1440p 60fps av01+hdr", "2560,1440,60,av01.hdr"), new VideoPreset("(4K) 2160p 30fps vp9", "3840,2160,30,vp9"), new VideoPreset("(4K) 2160p 60fps vp9", "3840,2160,60,vp9"), new VideoPreset("(4K) 2160p 30fps vp9+hdr", "3840,2160,30,vp9.2"), new VideoPreset("(4K) 2160p 60fps vp9+hdr", "3840,2160,60,vp9.2"), new VideoPreset("(4K) 2160p 30fps av01", "3840,2160,30,av01"), new VideoPreset("(4K) 2160p 60fps av01", "3840,2160,60,av01"), new VideoPreset("(4K) 2160p 30fps av01+hdr", "3840,2160,30,av01.hdr"), new VideoPreset("(4K) 2160p 60fps av01+hdr", "3840,2160,60,av01.hdr"), new VideoPreset("(8K) 4320p 30fps vp9", "7680,4320,30,vp9"), new VideoPreset("(8K) 4320p 60fps vp9", "7680,4320,60,vp9"), new VideoPreset("(8K) 4320p 30fps vp9+hdr", "7680,4320,30,vp9.2"), new VideoPreset("(8K) 4320p 60fps vp9+hdr", "7680,4320,60,vp9.2"), new VideoPreset("(8K) 4320p 30fps av01", "7680,4320,30,av01"), new VideoPreset("(8K) 4320p 60fps av01", "7680,4320,60,av01"), new VideoPreset("(8K) 4320p 30fps av01+hdr", "7680,4320,30,av01.hdr"), new VideoPreset("(8K) 4320p 60fps av01+hdr", "7680,4320,60,av01.hdr"), //new VideoPreset("Adaptive", null) }; return presets; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/BackgroundPlaybackService.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.IBinder; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.mylogger.Log; /** * This service isn't used at all! It can be safely removed in the future. */ public class BackgroundPlaybackService extends Service { private static final String TAG = BackgroundPlaybackService.class.getSimpleName(); @Override public void onCreate() { super.onCreate(); if (Build.VERSION.SDK_INT >= 26) { String CHANNEL_ID = "my_channel_01"; NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Channel human readable title", NotificationManager.IMPORTANCE_DEFAULT); ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel); Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("") .setContentText("").build(); startForeground(1, notification); } } @Nullable @Override public IBinder onBind(Intent intent) { Log.d(TAG, "onBind: %s", Helpers.toString(intent)); return null; } @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.d(TAG, "onStartCommand: %s", Helpers.toString(intent)); return super.onStartCommand(intent, flags, startId); } public static void start(Context context) { if (Build.VERSION.SDK_INT >= 26) { // Fake service to prevent the app from destroying Intent serviceIntent = new Intent(context, BackgroundPlaybackService.class); context.startForegroundService(serviceIntent); } } public static void stop(Context context) { if (Build.VERSION.SDK_INT >= 26) { // Fake service to prevent the app from destroying Intent serviceIntent = new Intent(context, BackgroundPlaybackService.class); context.stopService(serviceIntent); } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/BackupAndRestoreHelper.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.Build.VERSION; import android.provider.OpenableColumns; import android.widget.Toast; import androidx.core.content.FileProvider; import com.liskovsoft.sharedutils.helpers.FileHelpers; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.presenters.settings.BackupSettingsPresenter; import com.liskovsoft.smartyoutubetv2.common.misc.MotherActivity.OnResult; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; public class BackupAndRestoreHelper implements OnResult { private static final int REQ_PICK_FILES = 1001; private final Context mContext; private Runnable mOnSuccess; private final String[] mPreferredFileManagers = { "com.ghisler.android.TotalCommander", "com.lonelycatgames.Xplore", "com.alphainventor.filemanager", "pl.solidexplorer2" }; public BackupAndRestoreHelper(Context context) { mContext = context; } public void exportAppMediaFolder() { File mediaDir = FileHelpers.getExternalMediaDirectory(mContext); File dataDir = new File(mediaDir, "data"); if (!dataDir.exists() || FileHelpers.isEmpty(dataDir)) return; File zipFile = new File(mediaDir, "backup_" + mContext.getPackageName() + ".zip"); ZipHelper2.zipDirectory(dataDir, zipFile); Uri uri = FileProvider.getUriForFile( mContext, mContext.getPackageName() + ".update_provider", zipFile ); try { openFileManager(uri); } catch (Exception e) { // Activity launch may fail if called from background (e.g. WorkManager) e.printStackTrace(); } } private void openFileManager(Uri uri) { Intent intent = new Intent(Intent.ACTION_SEND); //intent.setType("application/zip"); intent.setType("*/*"); intent.putExtra(Intent.EXTRA_STREAM, uri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); PackageManager pm = mContext.getPackageManager(); for (String pkg : mPreferredFileManagers) { Intent targeted = new Intent(intent); targeted.setPackage(pkg); if (targeted.resolveActivity(pm) != null) { mContext.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); mContext.startActivity(targeted); return; } } mContext.startActivity(Intent.createChooser(intent, mContext.getString(R.string.app_backup))); } /** * NOTE: The file picker relies on apps that support the Storage Access Framework (SAF). * At the moment, no known third-party file manager properly supports selecting ZIP * archives through this API, so the backup file may not appear in the picker. */ public void importAppMediaFolder(Runnable onSuccess) { if (VERSION.SDK_INT < 19 || onSuccess == null) { return; } mOnSuccess = onSuccess; Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.setType("*/*"); //intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); //intent.addCategory(Intent.CATEGORY_OPENABLE); //intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{ // "application/zip", // "application/x-zip-compressed" //}); ((MotherActivity) mContext).addOnResult(this); ((Activity) mContext).startActivityForResult(intent, REQ_PICK_FILES); } @Override public void onResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQ_PICK_FILES && resultCode == Activity.RESULT_OK) { if (data == null) return; File mediaDir = FileHelpers.getExternalMediaDirectory(mContext); Uri uri = data.getData(); if (uri == null && data.getClipData() != null) { uri = data.getClipData().getItemAt(0).getUri(); } if (uri == null) return; File zipFile = new File(mediaDir, "restore.zip"); copyUriToDir(uri, zipFile); unpackTempZip(zipFile); mOnSuccess.run(); } } public void handleIncomingZip(Intent intent) { if (intent == null || !Intent.ACTION_SEND.equals(intent.getAction())) return; Uri zipUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); if (zipUri == null) { Toast.makeText(mContext, "No ZIP received", Toast.LENGTH_SHORT).show(); return; } try { File mediaDir = FileHelpers.getExternalMediaDirectory(mContext); // Copy ZIP from URI to temporary file File tempZip = new File(mediaDir, "imported_backup.zip"); copyUriToFile(zipUri, tempZip); unpackTempZip(tempZip); BackupSettingsPresenter.instance(mContext).showLocalRestoreDialogApi30(); } catch (Exception e) { e.printStackTrace(); Toast.makeText(mContext, "Failed to restore backup", Toast.LENGTH_SHORT).show(); } } public void unpackTempZip(File tempZip) { if (!tempZip.exists()) { return; } // Target folder: /Android/media//data File mediaDir = FileHelpers.getExternalMediaDirectory(mContext); File dataDir = new File(mediaDir, "data"); // Remove old data if (dataDir.exists()) FileHelpers.delete(dataDir); if (ZipHelper2.hasRootDir(tempZip, "data")) { // Unpack ZIP with data folder ZipHelper2.unzip(tempZip, mediaDir); } else { // Seems we've packed the contents of the data dir not data itself ZipHelper2.unzip(tempZip, dataDir); } // Delete the temporary ZIP tempZip.delete(); } private void copyUriToDir(Uri uri, File targetDir) { try { String fileName = getFileName(uri); if (fileName == null) fileName = "imported_" + System.currentTimeMillis(); File outFile = new File(targetDir, fileName); InputStream in = mContext.getContentResolver().openInputStream(uri); OutputStream out = new FileOutputStream(outFile); byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } in.close(); out.close(); } catch (Exception e) { e.printStackTrace(); } } private void copyUriToFile(Uri uri, File outFile) { try { InputStream in = mContext.getContentResolver().openInputStream(uri); OutputStream out = new FileOutputStream(outFile); byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } in.close(); out.close(); } catch (Exception e) { e.printStackTrace(); } } private String getFileName(Uri uri) { Cursor cursor = mContext.getContentResolver().query(uri, null, null, null, null); if (cursor != null) { int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); cursor.moveToFirst(); String name = cursor.getString(nameIndex); cursor.close(); return name; } return null; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/BackupAndRestoreManager.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.content.pm.PackageManager; import android.os.Environment; import android.os.Handler; import com.liskovsoft.sharedutils.helpers.AppInfoHelpers; import com.liskovsoft.sharedutils.helpers.FileHelpers; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.helpers.MessageHelpers; import com.liskovsoft.sharedutils.helpers.PermissionHelpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.prefs.HiddenPrefs; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.List; import io.reactivex.disposables.Disposable; public class BackupAndRestoreManager implements MotherActivity.OnPermissions { private static final String TAG = BackupAndRestoreManager.class.getSimpleName(); private static final String BACKUP_DIR_NAME = "Backup"; private final Context mContext; private static final String SHARED_PREFS_SUBDIR = "shared_prefs"; private final File mSharedPrefsDir; private final List mBackupDirs; private final BackupAndRestoreHelper mHelper; private final boolean mForceApi30; private Runnable mPendingHandler; private Disposable mZipAction; public interface OnBackupNames { void onBackupNames(List backupNames); } public BackupAndRestoreManager(Context context) { this(context, false); } public BackupAndRestoreManager(Context context, boolean forceApi30) { mContext = context; mForceApi30 = forceApi30; mHelper = new BackupAndRestoreHelper(context); mSharedPrefsDir = new File(mContext.getApplicationInfo().dataDir, SHARED_PREFS_SUBDIR); mBackupDirs = new ArrayList<>(); } private void initBackupDirs() { if (!mBackupDirs.isEmpty()) { return; } File externalDir = getExternalStorageDirectory(); // Main backup dir mBackupDirs.add(createBackupDir(new File(externalDir, String.format("data/%s", mContext.getPackageName())))); File dataDir = new File(externalDir, "data"); if (!dataDir.exists()) { dataDir.mkdirs(); } File[] appDirs = dataDir.listFiles(); if (appDirs == null) { return; } // Fallback dirs: in case multiple app flavors installed for (File appDir : appDirs) { File backupDir = createBackupDir(appDir); if (!mBackupDirs.contains(backupDir)) { mBackupDirs.add(backupDir); } } } private File createBackupDir(File appDir) { return new File(appDir, BACKUP_DIR_NAME); } private File getExternalStorageDirectory() { File result; if (hasAccessOnlyToAppFolders()) { result = FileHelpers.getExternalMediaDirectory(mContext); } else { result = Environment.getExternalStorageDirectory(); } return result; } public void checkPermAndRestore() { List backupNames = getBackupNames(); if (!backupNames.isEmpty()) { checkPermAndRestore(backupNames.get(0)); } } public void checkPermAndRestore(String backupName) { if (backupName == null) { return; } if (FileHelpers.isExternalStorageReadable()) { if (hasStoragePermissions(mContext)) { restoreData(backupName); } else { mPendingHandler = () -> restoreData(backupName); verifyStoragePermissionsAndReturn(); } } } public void checkPermAndBackup() { if (FileHelpers.isExternalStorageWritable()) { if (hasStoragePermissions(mContext)) { backupData(); } else { mPendingHandler = this::backupData; verifyStoragePermissionsAndReturn(); } } } public void backupData() { Log.d(TAG, "App has been updated or installed. Doing data backup..."); File currentBackup = getBackup(); if (currentBackup == null) { Log.d(TAG, "Oops. Backup location not writable."); return; } if (hasAccessOnlyToAppFolders()) { File mediaDir = FileHelpers.getExternalMediaDirectory(mContext); File dataDir = new File(mediaDir, "data"); FileHelpers.delete(dataDir); } else if (currentBackup.isDirectory()) { // plain sdcard storage // remove old backup /Backup FileHelpers.delete(currentBackup); } if (mSharedPrefsDir.isDirectory() && !FileHelpers.isEmpty(mSharedPrefsDir)) { File destination = new File(currentBackup, mSharedPrefsDir.getName()); FileHelpers.copy(mSharedPrefsDir, destination, fileName -> Helpers.endsWithAny(fileName.toString(), Utils.BACKUP_PATTERNS)); // Don't store unique id FileHelpers.delete(new File(destination, HiddenPrefs.SHARED_PREFERENCES_NAME + ".xml")); } if (hasAccessOnlyToAppFolders()) { mHelper.exportAppMediaFolder(); } else { RxHelper.disposeActions(mZipAction); mZipAction = RxHelper.runAsync(this::saveDataToZip); } } private void restoreData(String backupName) { Log.d(TAG, "App just updated. Restoring data..."); File currentBackup = getBackupCheck(backupName); File sourceBackupDir = new File(currentBackup, SHARED_PREFS_SUBDIR); if (FileHelpers.isEmpty(sourceBackupDir)) { Log.d(TAG, "Oops. Backup folder is empty."); MessageHelpers.showLongMessage(mContext, "Oops. Backup folder is empty."); return; } if (mSharedPrefsDir.isDirectory()) { // remove old data FileHelpers.delete(mSharedPrefsDir); } FileHelpers.copy(sourceBackupDir, mSharedPrefsDir, fileName -> Helpers.endsWithAny(fileName.toString(), Utils.BACKUP_PATTERNS)); fixFileNames(mSharedPrefsDir); MessageHelpers.showMessage(mContext, R.string.msg_done); // NOTE: Don't restart the app, just kill. The reboot will broke the files. // To apply settings we need to kill the app new Handler(mContext.getMainLooper()).postDelayed(() -> Runtime.getRuntime().exit(0), 1_000); } /** * Fix file names from other app versions */ private void fixFileNames(File dataDir) { Collection files = FileHelpers.listFileTree(dataDir); String suffix = "_preferences.xml"; String targetName = mContext.getPackageName() + suffix; for (File file : files) { if (file.getName().endsWith(suffix) && !file.getName().endsWith(targetName)) { FileHelpers.copy(file, new File(file.getParentFile(), targetName)); FileHelpers.delete(file); } } } private void verifyStoragePermissionsAndReturn() { if (mContext instanceof MotherActivity) { ((MotherActivity) mContext).addOnPermissions(this); PermissionHelpers.verifyStoragePermissions(mContext); } } private File getBackup() { File currentBackup = null; for (File backupDir : getBackupDirs()) { currentBackup = backupDir; break; } return currentBackup; } private File getBackupCheck(String backupName) { File currentBackup = null; for (File backupDir : getBackupDirs()) { File parentFile = backupDir.getParentFile(); // backupDir: /data//Backup if (parentFile == null) { continue; } if (backupDir.exists() && Helpers.equals(parentFile.getName(), backupName)) { currentBackup = backupDir; break; } } return currentBackup; } private File getBackupCheck() { for (File backupDir : getBackupDirs()) { if (backupDir.exists()) { return backupDir.getParentFile(); } } return null; } @Override public void onPermissions(int requestCode, String[] permissions, int[] grantResults) { if (requestCode == PermissionHelpers.REQUEST_EXTERNAL_STORAGE) { if (grantResults.length >= 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Log.d(TAG, "REQUEST_EXTERNAL_STORAGE permission has been granted"); if (mPendingHandler != null) { mPendingHandler.run(); mPendingHandler = null; } } } } public String getBackupPath() { File currentBackup = getBackup(); return currentBackup != null ? currentBackup.toString() : null; } public String getBackupRootPath() { // NOTE: Android 11+ only backup through the file manager (no shared dir) return String.format("%s/data", getExternalStorageDirectory()); } public String getBackupPathCheck() { File currentBackup = getBackupCheck(); return currentBackup != null ? currentBackup.toString() : null; } public void getBackupNames(OnBackupNames callback) { if (FileHelpers.isExternalStorageReadable()) { if (hasStoragePermissions(mContext)) { if (hasAccessOnlyToAppFolders()) { // Try to restore externally copied backup zip (if any) unpackBackupZip(); } callback.onBackupNames(getBackupNames()); } else { mPendingHandler = () -> callback.onBackupNames(getBackupNames()); verifyStoragePermissionsAndReturn(); } } } private List getBackupNames() { List names = new ArrayList<>(); for (File backupDir : getBackupDirs()) { File parentFile = backupDir.getParentFile(); if (parentFile == null) { continue; } if (backupDir.exists()) { names.add(parentFile.getName()); } } return names; } private List getBackupDirs() { initBackupDirs(); return mBackupDirs; } private boolean hasStoragePermissions(Context context) { return hasAccessOnlyToAppFolders() || PermissionHelpers.hasStoragePermissions(context); } public boolean hasBackup() { return getBackupCheck() != null; } // Android 11+: only backup through the file manager (no shared dir) private boolean hasAccessOnlyToAppFolders() { return AppInfoHelpers.getRealSdkVersion(mContext) > 29 || mForceApi30; } private void saveDataToZip() { File mediaDir = getExternalStorageDirectory(); File dataDir = new File(mediaDir, "data"); if (dataDir.exists()) { File zipFile = new File(mediaDir, "SmartTubeBackup.zip"); ZipHelper2.zipDirectory(dataDir, zipFile); } } private void saveDataToZip(File currentBackup) { // /data//Backup if (!FileHelpers.isEmpty(currentBackup)) { File source = currentBackup.getParentFile(); if (source != null) { File zipFile = new File(source.getParentFile(), source.getName() + ".zip"); ZipHelper2.zipDirectory(source, zipFile); } } } private void unpackBackupZip() { File[] files = FileHelpers.getExternalMediaDirectory(mContext).listFiles(); if (files != null) { for (File file : files) { if (file.getName().endsWith(".zip")) { mHelper.unpackTempZip(file); break; } } } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/BackupReceiverActivity.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; import android.os.Build.VERSION; import android.os.Bundle; import androidx.annotation.NonNull; import com.liskovsoft.sharedutils.helpers.AppInfoHelpers; import com.liskovsoft.sharedutils.helpers.PermissionHelpers; public class BackupReceiverActivity extends Activity { private BackupAndRestoreHelper mRestoreHelper; private Runnable mPendingHandler; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mRestoreHelper = new BackupAndRestoreHelper(this); // Android 11+ (API 30+) removed the need for storage permission // when accessing files via content:// URIs. Apps are granted temporary // access to the URI by the sender, so you can read the file without // READ_EXTERNAL_STORAGE. if (PermissionHelpers.hasStoragePermissions(this) || AppInfoHelpers.getRealSdkVersion(this) > 29) { restoreData(); finish(); } else { mPendingHandler = this::restoreData; PermissionHelpers.verifyStoragePermissions(this); } } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); mRestoreHelper.handleIncomingZip(intent); } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (requestCode == PermissionHelpers.REQUEST_EXTERNAL_STORAGE) { if (grantResults.length >= 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (mPendingHandler != null) { mPendingHandler.run(); mPendingHandler = null; } } finish(); } } private void restoreData() { mRestoreHelper.handleIncomingZip(getIntent()); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/BrowseProcessor.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.app.models.data.VideoGroup; public interface BrowseProcessor { interface OnItemReady { void onItemReady(Video video); } void process(VideoGroup videoGroup); void dispose(); } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/BrowseProcessorManager.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import com.liskovsoft.smartyoutubetv2.common.app.models.data.VideoGroup; import java.util.ArrayList; public class BrowseProcessorManager implements BrowseProcessor { private final ArrayList mProcessors; public BrowseProcessorManager(Context context, OnItemReady onItemReady) { mProcessors = new ArrayList<>(); mProcessors.add(new DeArrowProcessor(context, onItemReady)); mProcessors.add(new UnlocalizedTitleProcessor(context, onItemReady)); } @Override public void process(VideoGroup videoGroup) { for (BrowseProcessor processor : mProcessors) { processor.process(videoGroup); } } @Override public void dispose() { for (BrowseProcessor processor : mProcessors) { processor.dispose(); } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/CrashRestorer.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.os.Bundle; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.service.VideoStateService; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.service.VideoStateService.State; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.app.views.ViewManager; public class CrashRestorer { private static final String SELECTED_HEADER_INDEX = "SelectedHeaderIndex"; private static final String SELECTED_VIDEO = "SelectedVideo"; private static final String IS_PLAYER_IN_FOREGROUND = "IsPlayerInForeground"; private int mSelectedHeaderIndex = -1; private Video mSelectedVideo; private boolean mIsPlayerInForeground; private final Context mContext; public interface OnRestoreHeader { void onRestore(int selectedHeaderIndex, Video selectedVideo); } public CrashRestorer(Context context, Bundle savedState) { mContext = context.getApplicationContext(); init(savedState); } private void init(Bundle savedState) { if (savedState == null) { return; } mSelectedHeaderIndex = savedState.getInt(SELECTED_HEADER_INDEX, -1); mSelectedVideo = Video.fromString(savedState.getString(SELECTED_VIDEO)); mIsPlayerInForeground = savedState.getBoolean(IS_PLAYER_IN_FOREGROUND, false); } public void persistHeaderIndex(Bundle outState, int selectedPosition) { if (selectedPosition == -1) { // multiple crashes without user interaction selectedPosition = mSelectedHeaderIndex; } outState.putInt(SELECTED_HEADER_INDEX, selectedPosition); } public void persistVideo(Bundle outState, Video currentVideo) { if (currentVideo == null) { // multiple crashes without user interaction currentVideo = mSelectedVideo; } if (currentVideo != null) { outState.putString(SELECTED_VIDEO, currentVideo.toString()); } outState.putBoolean(IS_PLAYER_IN_FOREGROUND, ViewManager.instance(mContext).isPlayerInForeground()); } public void restorePlayback() { if (mIsPlayerInForeground && PlaybackPresenter.instance(mContext).getPlayer() == null) { VideoStateService stateService = VideoStateService.instance(mContext); boolean isVideoStateSynced = mSelectedVideo == null || stateService.getByVideoId(mSelectedVideo.videoId) != null; State lastState = stateService.getLastState(); PlaybackPresenter.instance(mContext).openVideo(lastState != null && isVideoStateSynced ? lastState.video : mSelectedVideo); } // Restore can be called only once mIsPlayerInForeground = false; } public void restoreHeader(OnRestoreHeader onRestoreHeader) { if (mSelectedHeaderIndex != -1) { onRestoreHeader.onRestore(mSelectedHeaderIndex, mSelectedVideo); } // Restore can be called only once mSelectedHeaderIndex = -1; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/DeArrowProcessor.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import com.liskovsoft.mediaserviceinterfaces.ServiceManager; import com.liskovsoft.mediaserviceinterfaces.MediaItemService; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.app.models.data.VideoGroup; import com.liskovsoft.smartyoutubetv2.common.prefs.DeArrowData; import com.liskovsoft.smartyoutubetv2.common.prefs.common.DataChangeBase.OnDataChange; import com.liskovsoft.youtubeapi.service.YouTubeServiceManager; import java.util.ArrayList; import java.util.List; import io.reactivex.disposables.Disposable; public class DeArrowProcessor implements OnDataChange, BrowseProcessor { private static final String TAG = DeArrowProcessor.class.getSimpleName(); private final OnItemReady mOnItemReady; private final MediaItemService mItemService; private final DeArrowData mDeArrowData; private boolean mIsReplaceTitlesEnabled; private boolean mIsReplaceThumbnailsEnabled; private Disposable mResult; public DeArrowProcessor(Context context, OnItemReady onItemReady) { mOnItemReady = onItemReady; ServiceManager service = YouTubeServiceManager.instance(); mItemService = service.getMediaItemService(); mDeArrowData = DeArrowData.instance(context); mDeArrowData.setOnChange(this); initData(); } @Override public void onDataChange() { initData(); } private void initData() { mIsReplaceTitlesEnabled = mDeArrowData.isReplaceTitlesEnabled(); mIsReplaceThumbnailsEnabled = mDeArrowData.isReplaceThumbnailsEnabled(); } @Override public void process(VideoGroup videoGroup) { if ((!mIsReplaceTitlesEnabled && !mIsReplaceThumbnailsEnabled) || videoGroup == null || videoGroup.isEmpty()) { return; } List videoIds = getVideoIds(videoGroup); mResult = mItemService.getDeArrowDataObserve(videoIds) .subscribe(deArrowData -> { Video video = videoGroup.findVideoById(deArrowData.getVideoId()); if (mIsReplaceTitlesEnabled) { video.deArrowTitle = deArrowData.getTitle(); } if (mIsReplaceThumbnailsEnabled) { video.altCardImageUrl = deArrowData.getThumbnailUrl(); } mOnItemReady.onItemReady(video); }, error -> { Log.d(TAG, "DeArrow cannot process the video"); }); } @Override public void dispose() { RxHelper.disposeActions(mResult); } private List getVideoIds(VideoGroup videoGroup) { List result = new ArrayList<>(); for (Video video : videoGroup.getVideos()) { if (video.deArrowProcessed) { continue; } video.deArrowProcessed = true; result.add(video.videoId); } return result; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/GDriveBackupManager.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.annotation.SuppressLint; import android.content.Context; import android.net.Uri; import android.os.Build; import android.os.Handler; import com.liskovsoft.googleapi.service.DriveService; import com.liskovsoft.googleapi.oauth2.impl.GoogleSignInService; import com.liskovsoft.sharedutils.helpers.FileHelpers; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.helpers.MessageHelpers; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.ui.OptionItem; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.ui.UiOptionItem; import com.liskovsoft.smartyoutubetv2.common.app.presenters.AppDialogPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.GoogleSignInPresenter; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.utils.AppDialogUtil; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.List; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; public class GDriveBackupManager { @SuppressLint("StaticFieldLeak") private static GDriveBackupManager sInstance; private final Context mContext; private static final String SHARED_PREFS_SUBDIR = "shared_prefs"; private static final String BACKUP_NAME = "backup.zip"; private final GoogleSignInService mSignInService; private final String mDataDir; private final String mBackupDir; private final String mRootBackupDir; private final GeneralData mGeneralData; private Disposable mBackupAction; private Disposable mRestoreAction; private boolean mIsBlocking; private GDriveBackupManager(Context context) { mContext = context; mGeneralData = GeneralData.instance(context); mDataDir = String.format("%s/%s", mContext.getApplicationInfo().dataDir, SHARED_PREFS_SUBDIR); mBackupDir = String.format("SmartTubeBackup/%s", context.getPackageName()); mRootBackupDir = "SmartTubeBackup"; mSignInService = GoogleSignInService.instance(); } public static GDriveBackupManager instance(Context context) { if (sInstance == null) { sInstance = new GDriveBackupManager(context); } return sInstance; } public static void unhold() { sInstance = null; } public void backup() { mIsBlocking = false; backupInt(); } public void backupBlocking() { mIsBlocking = true; backupInt(); } private void backupInt() { if (mIsBlocking && !mSignInService.isSigned()) { return; } if (RxHelper.isAnyActionRunning(mBackupAction, mRestoreAction)) { if (!mIsBlocking) MessageHelpers.showMessage(mContext, R.string.wait_data_loading); return; } if (mSignInService.isSigned()) { startBackupConfirm(); } else { logIn(this::startBackupConfirm); } } public void restore() { if (RxHelper.isAnyActionRunning(mBackupAction, mRestoreAction)) { MessageHelpers.showMessage(mContext, R.string.wait_data_loading); return; } if (mSignInService.isSigned()) { startRestoreConfirm(); } else { logIn(this::startRestoreConfirm); } } private void startBackupConfirm() { if (!mIsBlocking) { AppDialogUtil.showConfirmationDialog(mContext, mContext.getString(R.string.app_backup), this::startBackupWrapper); } else { startBackupWrapper(); } } private void startBackupWrapper() { String backupDir = getBackupDir(); startBackup(backupDir, mDataDir); } private void startBackupOld(String backupDir, String dataDir) { Collection files = FileHelpers.listFileTree(new File(dataDir)); Consumer backupConsumer = file -> { if (file.isFile()) { if (checkFileName(file.getName())) { if (!mIsBlocking) MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_backup) + "\n" + file.getName()); RxHelper.runBlocking(DriveService.uploadFile(file, Uri.parse(String.format("%s%s", backupDir, file.getAbsolutePath().replace(dataDir, ""))))); } } }; if (mIsBlocking) { Observable.fromIterable(files) .blockingSubscribe(backupConsumer); } else { mBackupAction = Observable.fromIterable(files) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) // run subscribe on separate thread .subscribe(backupConsumer, error -> MessageHelpers.showLongMessage(mContext, error.getMessage())); } } private void startBackup(String backupDir, String dataDir) { File source = new File(dataDir); File zipFile = new File(mContext.getCacheDir(), BACKUP_NAME); ZipHelper.zipFolder(source, zipFile, Utils.BACKUP_PATTERNS); Observable uploadFile = DriveService.uploadFile(zipFile, Uri.parse(String.format("%s/%s", backupDir, BACKUP_NAME))); if (mIsBlocking) { RxHelper.runBlocking(uploadFile); } else { MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_backup)); mBackupAction = uploadFile .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( unused -> {}, error -> { MessageHelpers.showLongMessage(mContext, error.getMessage()); if (Helpers.startsWith(error.getMessage(), "AuthError")) { logIn(this::startBackupConfirm); // auth data outdated (AuthError: invalid_grant) } }, () -> MessageHelpers.showMessage(mContext, R.string.msg_done) ); } } private void startRestoreConfirm() { //AppDialogUtil.showConfirmationDialog(mContext, mContext.getString(R.string.app_restore), this::startRestoreWrapper); showRestoreChooserDialog(); } private void startRestoreWrapper() { startRestore(getBackupDir(), mDataDir, () -> startRestore(getAltBackupDir(), mDataDir, () -> startRestoreOld(getBackupDir(), mDataDir, () -> startRestoreOld(getAltBackupDir(), mDataDir, null)))); } private void startRestoreOld(String backupDir, String dataDir, Runnable onError) { mRestoreAction = DriveService.getFileList(Uri.parse(backupDir)) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) // run subscribe on separate thread .subscribe(names -> { // remove old data FileHelpers.delete(dataDir); for (String name : names) { if (checkFileName(name)) { MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_restore) + "\n" + name); DriveService.getFile(Uri.parse(String.format("%s/%s", backupDir, name))) .blockingSubscribe(inputStream -> FileHelpers.copy(inputStream, new File(dataDir, fixAltPackageName(name)))); } } // NOTE: Don't restart the app, just kill. The reboot will broke the files. // To apply settings we need to kill the app new Handler(mContext.getMainLooper()).postDelayed(() -> Runtime.getRuntime().exit(0), 1_000); }, error -> { if (onError != null) onError.run(); else MessageHelpers.showLongMessage(mContext, error.getMessage()); }); } private void startRestore(String backupDir, String dataDir, Runnable onError) { MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_restore)); mRestoreAction = DriveService.getFile(Uri.parse(String.format("%s/%s", backupDir, BACKUP_NAME))) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(inputStream -> { File zipFile = new File(mContext.getCacheDir(), BACKUP_NAME); FileHelpers.copy(inputStream, zipFile); File out = new File(dataDir); // remove old data FileHelpers.delete(out); ZipHelper.unzipToFolder(zipFile, out); fixFileNames(out); // NOTE: Don't restart the app, just kill. The reboot will broke the files. // To apply settings we need to kill the app new Handler(mContext.getMainLooper()).postDelayed(() -> Runtime.getRuntime().exit(0), 1_000); }, error -> { if (onError != null) onError.run(); else MessageHelpers.showLongMessage(mContext, error.getMessage()); }, () -> MessageHelpers.showMessage(mContext, R.string.msg_done)); } private void logIn(Runnable onDone) { GoogleSignInPresenter.instance(mContext).start(onDone); } private boolean checkFileName(String name) { return Helpers.endsWithAny(name, Utils.BACKUP_PATTERNS); } private String fixAltPackageName(String name) { String altPackageName = getAltPackageName(); return name.replace(altPackageName, mContext.getPackageName()); } private String getAltPackageName() { String[] altPackages = Utils.KNOWN_PACKAGES; // TODO: don't hard code ids. show all existed. return mContext.getPackageName().equals(altPackages[0]) ? altPackages[1] : altPackages[0]; } private String getDeviceSuffix() { return mGeneralData.isDeviceSpecificBackupEnabled() ? "_" + Build.MODEL.replace(" ", "_") : ""; } private String getAltBackupDir() { String backupDir = getBackupDir(); String altPackageName = getAltPackageName(); return backupDir.replace(mContext.getPackageName(), altPackageName); } public String getBackupDir() { return mBackupDir + getDeviceSuffix(); } /** * Fix file names from other app versions */ private void fixFileNames(File dataDir) { Collection files = FileHelpers.listFileTree(dataDir); String suffix = "_preferences.xml"; String targetName = mContext.getPackageName() + suffix; for (File file : files) { if (file.getName().endsWith(suffix) && !file.getName().endsWith(targetName)) { FileHelpers.copy(file, new File(file.getParentFile(), targetName)); FileHelpers.delete(file); } } } private void showRestoreChooserDialog() { mRestoreAction = DriveService.getFolderList(Uri.parse(mRootBackupDir)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) // run subscribe on separate thread .subscribe( this::showLocalRestoreDialog, error -> { MessageHelpers.showLongMessage(mContext, error.getMessage()); if (Helpers.startsWith(error.getMessage(), "AuthError")) { logIn(this::startRestoreConfirm); // auth data outdated (AuthError: invalid_grant) } } ); } private void showLocalRestoreDialog(List backups) { if (backups != null && !backups.isEmpty()) { showLocalRestoreSelectorDialog(backups); } else { MessageHelpers.showLongMessage(mContext, R.string.nothing_found); } } private void showLocalRestoreSelectorDialog(List backups) { AppDialogPresenter dialog = AppDialogPresenter.instance(mContext); List options = new ArrayList<>(); for (String name : backups) { options.add(UiOptionItem.from(name, optionItem -> { AppDialogUtil.showConfirmationDialog(mContext, mContext.getString(R.string.app_restore), () -> { String backupDir = String.format("%s/%s", mRootBackupDir, name); startRestore(backupDir, mDataDir, () -> startRestoreOld(backupDir, mDataDir, null)); }); })); } dialog.appendStringsCategory(mContext.getString(R.string.app_restore), options); dialog.showDialog(); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/GDriveBackupManagerOld.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.annotation.SuppressLint; import android.content.Context; import android.net.Uri; import android.os.Build; import com.liskovsoft.googleapi.oauth2.impl.GoogleSignInService; import com.liskovsoft.googleapi.service.DriveService; import com.liskovsoft.sharedutils.helpers.FileHelpers; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.helpers.MessageHelpers; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.presenters.GoogleSignInPresenter; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.utils.AppDialogUtil; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.io.File; import java.util.Collection; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; public class GDriveBackupManagerOld { @SuppressLint("StaticFieldLeak") private static GDriveBackupManagerOld sInstance; private final Context mContext; private static final String SHARED_PREFS_SUBDIR = "shared_prefs"; private static final String BACKUP_NAME = "backup.zip"; private final GoogleSignInService mSignInService; private final String mDataDir; private final String mBackupDir; private final GeneralData mGeneralData; private Disposable mBackupAction; private Disposable mRestoreAction; private final String[] mBackupNames; private boolean mIsBlocking; private GDriveBackupManagerOld(Context context) { mContext = context; mGeneralData = GeneralData.instance(context); mDataDir = String.format("%s/%s", mContext.getApplicationInfo().dataDir, SHARED_PREFS_SUBDIR); mBackupDir = String.format("SmartTubeBackup/%s", context.getPackageName()); mSignInService = GoogleSignInService.instance(); mBackupNames = new String[] { "yt_service_prefs.xml", "com.liskovsoft.appupdatechecker2.preferences.xml", "com.liskovsoft.sharedutils.prefs.GlobalPreferences.xml", "_preferences.xml" // before _ should be the app package name }; } public static GDriveBackupManagerOld instance(Context context) { if (sInstance == null) { sInstance = new GDriveBackupManagerOld(context); } return sInstance; } public static void unhold() { sInstance = null; } public void backup() { mIsBlocking = false; backupInt(); } public void backupBlocking() { mIsBlocking = true; backupInt(); } private void backupInt() { if (mIsBlocking && !mSignInService.isSigned()) { return; } if (RxHelper.isAnyActionRunning(mBackupAction, mRestoreAction)) { if (!mIsBlocking) MessageHelpers.showMessage(mContext, R.string.wait_data_loading); return; } if (mSignInService.isSigned()) { startBackupConfirm(); } else { logIn(this::startBackupConfirm); } } public void restore() { if (RxHelper.isAnyActionRunning(mBackupAction, mRestoreAction)) { MessageHelpers.showMessage(mContext, R.string.wait_data_loading); return; } if (mSignInService.isSigned()) { startRestoreConfirm(); } else { logIn(this::startRestoreConfirm); } } private void startBackupConfirm() { if (!mIsBlocking) { AppDialogUtil.showConfirmationDialog(mContext, mContext.getString(R.string.app_backup), this::startBackup); } else { startBackup(); } } private void startBackup() { String backupDir = getBackupDir(); startBackup2(backupDir, mDataDir); } private void startBackup(String backupDir, String dataDir) { Collection files = FileHelpers.listFileTree(new File(dataDir)); Consumer backupConsumer = file -> { if (file.isFile()) { if (checkFileName(file.getName())) { if (!mIsBlocking) MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_backup) + "\n" + file.getName()); RxHelper.runBlocking(DriveService.uploadFile(file, Uri.parse(String.format("%s%s", backupDir, file.getAbsolutePath().replace(dataDir, ""))))); } } }; if (mIsBlocking) { Observable.fromIterable(files) .blockingSubscribe(backupConsumer); } else { mBackupAction = Observable.fromIterable(files) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) // run subscribe on separate thread .subscribe(backupConsumer, error -> MessageHelpers.showLongMessage(mContext, error.getMessage())); } } private void startBackup2(String backupDir, String dataDir) { File source = new File(dataDir); File zipFile = new File(mContext.getCacheDir(), BACKUP_NAME); ZipHelper.zipFolder(source, zipFile, mBackupNames); Observable uploadFile = DriveService.uploadFile(zipFile, Uri.parse(String.format("%s/%s", backupDir, BACKUP_NAME))); if (mIsBlocking) { RxHelper.runBlocking(uploadFile); } else { MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_backup)); mBackupAction = uploadFile .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( unused -> {}, error -> MessageHelpers.showLongMessage(mContext, error.getMessage()), () -> MessageHelpers.showMessage(mContext, R.string.msg_done) ); } } private void startRestoreConfirm() { AppDialogUtil.showConfirmationDialog(mContext, mContext.getString(R.string.app_restore), this::startRestore); } private void startRestore() { startRestore2(getBackupDir(), mDataDir, () -> startRestore2(getAltBackupDir(), mDataDir, () -> startRestore(getBackupDir(), mDataDir, () -> startRestore(getAltBackupDir(), mDataDir, null)))); } private void startRestore(String backupDir, String dataDir, Runnable onError) { mRestoreAction = DriveService.getFileList(Uri.parse(backupDir)) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) // run subscribe on separate thread .subscribe(names -> { // remove old data FileHelpers.delete(dataDir); for (String name : names) { if (checkFileName(name)) { MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_restore) + "\n" + name); DriveService.getFile(Uri.parse(String.format("%s/%s", backupDir, name))) .blockingSubscribe(inputStream -> FileHelpers.copy(inputStream, new File(dataDir, fixAltPackageName(name)))); } } Utils.restartTheApp(mContext); }, error -> { if (onError != null) onError.run(); else MessageHelpers.showLongMessage(mContext, R.string.nothing_found); }); } private void startRestore2(String backupDir, String dataDir, Runnable onError) { MessageHelpers.showLongMessage(mContext, mContext.getString(R.string.app_restore)); mRestoreAction = DriveService.getFile(Uri.parse(String.format("%s/%s", backupDir, BACKUP_NAME))) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(inputStream -> { File zipFile = new File(mContext.getCacheDir(), BACKUP_NAME); FileHelpers.copy(inputStream, zipFile); File out = new File(dataDir); // remove old data FileHelpers.delete(out); ZipHelper.unzipToFolder(zipFile, out); fixFileNames(out); Utils.restartTheApp(mContext); }, error -> { if (onError != null) onError.run(); else MessageHelpers.showLongMessage(mContext, R.string.nothing_found); }, () -> MessageHelpers.showMessage(mContext, R.string.msg_done)); } private void logIn(Runnable onDone) { GoogleSignInPresenter.instance(mContext).start(onDone); } private boolean checkFileName(String name) { return Helpers.endsWithAny(name, mBackupNames); } private String fixAltPackageName(String name) { String altPackageName = getAltPackageName(); return name.replace(altPackageName, mContext.getPackageName()); } private String getAltPackageName() { String[] altPackages = new String[] { "org.smarttube.beta", "org.smarttube.stable", "org.smarttube.fdroid", "com.liskovsoft.smarttubetv.beta", "com.teamsmart.videomanager.tv" }; return mContext.getPackageName().equals(altPackages[0]) ? altPackages[1] : altPackages[0]; } private String getDeviceSuffix() { return mGeneralData.isDeviceSpecificBackupEnabled() ? "_" + Build.MODEL.replace(" ", "_") : ""; } private String getAltBackupDir() { String backupDir = getBackupDir(); String altPackageName = getAltPackageName(); return backupDir.replace(mContext.getPackageName(), altPackageName); } public String getBackupDir() { return mBackupDir + getDeviceSuffix(); } /** * Fix file names from other app versions */ private void fixFileNames(File dataDir) { Collection files = FileHelpers.listFileTree(dataDir); String suffix = "_preferences.xml"; String targetName = mContext.getPackageName() + suffix; for (File file : files) { if (file.getName().endsWith(suffix) && !file.getName().endsWith(targetName)) { FileHelpers.copy(file, new File(file.getParentFile(), targetName)); FileHelpers.delete(file); } } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/GDriveBackupWorker.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.net.Uri; import android.os.Build.VERSION; import androidx.annotation.NonNull; import androidx.work.ExistingPeriodicWorkPolicy; import androidx.work.PeriodicWorkRequest; import androidx.work.WorkManager; import androidx.work.Worker; import androidx.work.WorkerParameters; import com.liskovsoft.googleapi.service.DriveService; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.concurrent.TimeUnit; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; /** * Work to synchronize the TV provider database with the desired list of channels and * programs. This sample app runs this once at install time to publish an initial set of channels * and programs, however in a real-world setting this might be run at other times to synchronize * a server's database with the TV provider database. * This code will ensure that the channels from "SampleClipApi.getDesiredPublishedChannelSet()" * appear in the TV provider database, and that these and all other programs are synchronized with * TV provider database. */ public class GDriveBackupWorker extends Worker { private static final String TAG = GDriveBackupWorker.class.getSimpleName(); private static final String WORK_NAME = TAG; private static final String BLOCKED_FILE_NAME = "blocked"; private static Disposable sAction; private final GDriveBackupManager mTask; public GDriveBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { super(context, workerParams); mTask = GDriveBackupManager.instance(context); } public static void schedule(Context context) { if (VERSION.SDK_INT >= 23 && GeneralData.instance(context).getGDriveBackupFreqDays() > 0) { WorkManager workManager = WorkManager.getInstance(context); // https://stackoverflow.com/questions/50943056/avoiding-duplicating-periodicworkrequest-from-workmanager workManager.enqueueUniquePeriodicWork( WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, // fix duplicates (when old worker is running) new PeriodicWorkRequest.Builder( GDriveBackupWorker.class, GeneralData.instance(context).getGDriveBackupFreqDays(), TimeUnit.DAYS).addTag(WORK_NAME) .build() ); } } public static void forceSchedule(Context context) { RxHelper.disposeActions(sAction); // get local id String id = Utils.getUniqueId(context); // get backup path String backupDir = GDriveBackupManager.instance(context).getBackupDir(); // then persist id to gdrive sAction = DriveService.uploadFile(id, Uri.parse(String.format("%s/%s", backupDir, BLOCKED_FILE_NAME))) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(unused -> { // NOP }, throwable -> { // NOP }, () -> { // then run schedule schedule(context); }); } public static void cancel(Context context) { RxHelper.disposeActions(sAction); if (VERSION.SDK_INT >= 23 && GeneralData.instance(context).getGDriveBackupFreqDays() > 0) { Log.d(TAG, "Unregistering worker job..."); WorkManager workManager = WorkManager.getInstance(context); workManager.cancelUniqueWork(WORK_NAME); } } @NonNull @Override public Result doWork() { Log.d(TAG, "Starting worker %s...", this); checkedRunBackup(); return Result.success(); } private void runBackup() { mTask.backupBlocking(); GDriveBackupManager.unhold(); } private void checkedRunBackup() { // get local id String id = Utils.getUniqueId(getApplicationContext()); // get backup path String backupDir = GDriveBackupManager.instance(getApplicationContext()).getBackupDir(); // get id form gdrive DriveService.getFile(Uri.parse(String.format("%s/%s", backupDir, BLOCKED_FILE_NAME))) .blockingSubscribe(inputStream -> { // if id match run work as usual String actualId = Helpers.toString(inputStream); if (Helpers.equals(id, actualId)) { runBackup(); } else { // if id not found then disable auto backup in settings GeneralData.instance(getApplicationContext()).setGDriveBackupFreqDays(-1); } }, error -> Log.e(TAG, error.getMessage())); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/GlobalKeyTranslator.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.view.KeyEvent; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.SearchPresenter; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import java.util.Map; public class GlobalKeyTranslator extends KeyTranslator { private final Context mContext; public GlobalKeyTranslator(Context context) { mContext = context; } @Override protected void initKeyMapping() { Map globalKeyMapping = getKeyMapping(); // Fix rare situations with some remotes. E.g. Shield. // NOTE: 'sendKey' won't work with Android 13 globalKeyMapping.put(KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BACK); // Fix for the unknown usb remote controller: https://smartyoutubetv.github.io/#comment-3742343397 globalKeyMapping.put(KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_BACK); // Could cause serious 'OK not working' bug (where Enter key is used as OK) // See: KeyHelpers#fixEnterKey //globalKeyMapping.put(KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER); // G20s fix: show keyboard on textview click //globalKeyMapping.put(KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_DPAD_CENTER); // G20s fix: show keyboard on textview click // May help on buggy firmwares (where Enter key is used as OK) if (!getPlaybackPresenter().isInPipMode()) { globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_DPAD_CENTER); } else { globalKeyMapping.remove(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); } // 4pda users have an issues with btn remapping //globalKeyMapping.put(KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_DPAD_UP); //globalKeyMapping.put(KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_DPAD_DOWN); //globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_REWIND, KeyEvent.KEYCODE_DPAD_LEFT); //globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, KeyEvent.KEYCODE_DPAD_RIGHT); } @Override protected void initActionMapping() { addSearchAction(); } private void addSearchAction() { Runnable searchAction = () -> getSearchPresenter().startSearch(null); Map actionMapping = getActionMapping(); actionMapping.put(KeyEvent.KEYCODE_AT, searchAction); if (getGeneralData().isRemapChannelUpToSearchEnabled()) { actionMapping.put(KeyEvent.KEYCODE_CHANNEL_UP, searchAction); actionMapping.put(KeyEvent.KEYCODE_CHANNEL_DOWN, searchAction); } } private GeneralData getGeneralData() { return GeneralData.instance(mContext); } private PlaybackPresenter getPlaybackPresenter() { return PlaybackPresenter.instance(mContext); } private SearchPresenter getSearchPresenter() { return SearchPresenter.instance(mContext); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/KeyTranslator.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.view.KeyEvent; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.app.presenters.AppDialogPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.HashMap; import java.util.Map; public abstract class KeyTranslator { private static final String TAG = KeyTranslator.class.getSimpleName(); private final Map mKeyMapping = new HashMap<>(); private final Map mActionMapping = new HashMap<>(); private boolean mIsChecked; /** * NOTE: 'sendKey' won't work with Android 13 */ public final boolean translateOld(KeyEvent event) { boolean handled = false; Runnable action = mActionMapping.get(event.getKeyCode()); if (action != null && checkEvent(event)) { if (event.getAction() == KeyEvent.ACTION_DOWN) { action.run(); } handled = true; } if (!handled) { Integer newKeyCode = mKeyMapping.get(event.getKeyCode()); KeyEvent newKeyEvent = translate(event, newKeyCode); if (newKeyEvent != event && checkEvent(event)) { RxHelper.runAsync(() -> Utils.sendKey(newKeyEvent)); handled = true; } } return handled; } public final KeyEvent translate(KeyEvent event) { Runnable action = mActionMapping.get(event.getKeyCode()); if (action != null && checkEvent(event)) { if (event.getAction() == KeyEvent.ACTION_DOWN) { action.run(); } return null; // handled } Integer newKeyCode = mKeyMapping.get(event.getKeyCode()); KeyEvent newKeyEvent = translate(event, newKeyCode); if (newKeyEvent != event && checkEvent(event)) { return newKeyEvent; } return event; } private KeyEvent translate(KeyEvent origin, Integer newKeyCode) { if (newKeyCode == null) { return origin; } KeyEvent newKey = new KeyEvent( origin.getDownTime(), origin.getEventTime(), origin.getAction(), newKeyCode, origin.getRepeatCount(), origin.getMetaState(), origin.getDeviceId(), origin.getScanCode(), origin.getFlags(), origin.getSource() ); Log.d(TAG, "Translating %s to %s", origin, newKey); return newKey; } private boolean checkEvent(KeyEvent event) { // Fix Volume binding when UI hide if (event.getAction() == KeyEvent.ACTION_UP) { return mIsChecked; } mIsChecked = (event.getKeyCode() != KeyEvent.KEYCODE_DPAD_UP && event.getKeyCode() != KeyEvent.KEYCODE_DPAD_DOWN && event.getKeyCode() != KeyEvent.KEYCODE_DPAD_LEFT && event.getKeyCode() != KeyEvent.KEYCODE_DPAD_RIGHT) || (PlaybackPresenter.instance(null).isPlaying() && !PlaybackPresenter.instance(null).isOverlayShown() && !AppDialogPresenter.instance(null).isDialogShown()); return mIsChecked; } protected abstract void initKeyMapping(); protected abstract void initActionMapping(); protected Map getKeyMapping() { return mKeyMapping; } protected Map getActionMapping() { return mActionMapping; } public void apply() { initKeyMapping(); initActionMapping(); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/LocalDriveBackupWorker.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.os.Build.VERSION; import androidx.annotation.NonNull; import androidx.work.ExistingPeriodicWorkPolicy; import androidx.work.PeriodicWorkRequest; import androidx.work.WorkManager; import androidx.work.Worker; import androidx.work.WorkerParameters; import com.liskovsoft.sharedutils.helpers.MessageHelpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.concurrent.TimeUnit; /** * Work to synchronize the TV provider database with the desired list of channels and * programs. This sample app runs this once at install time to publish an initial set of channels * and programs, however in a real-world setting this might be run at other times to synchronize * a server's database with the TV provider database. * This code will ensure that the channels from "SampleClipApi.getDesiredPublishedChannelSet()" * appear in the TV provider database, and that these and all other programs are synchronized with * TV provider database. */ public class LocalDriveBackupWorker extends Worker { private static final String TAG = LocalDriveBackupWorker.class.getSimpleName(); private static final String WORK_NAME = TAG; public LocalDriveBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { super(context, workerParams); } public static void schedule(Context context) { if (Utils.isSharedDirRestricted(context)) { return; } if (VERSION.SDK_INT >= 23 && GeneralData.instance(context).getLocalDriveBackupFreqDays() > 0) { WorkManager workManager = WorkManager.getInstance(context); // https://stackoverflow.com/questions/50943056/avoiding-duplicating-periodicworkrequest-from-workmanager workManager.enqueueUniquePeriodicWork( WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, // fix duplicates (when old worker is running) new PeriodicWorkRequest.Builder( LocalDriveBackupWorker.class, GeneralData.instance(context).getLocalDriveBackupFreqDays(), TimeUnit.DAYS).addTag(WORK_NAME) .build() ); } } public static void forceSchedule(Context context) { if (Utils.isSharedDirRestricted(context)) { MessageHelpers.showLongMessage(context, R.string.local_backup_not_supported); return; } new BackupAndRestoreManager(context).checkPermAndBackup(); schedule(context); } public static void cancel(Context context) { if (VERSION.SDK_INT >= 23 && GeneralData.instance(context).getLocalDriveBackupFreqDays() > 0) { Log.d(TAG, "Unregistering worker job..."); WorkManager workManager = WorkManager.getInstance(context); workManager.cancelUniqueWork(WORK_NAME); } } @NonNull @Override public Result doWork() { Log.d(TAG, "Starting worker %s...", this); new BackupAndRestoreManager(getApplicationContext()).backupData(); return Result.success(); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/MediaServiceManager.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.util.Pair; import com.liskovsoft.mediaserviceinterfaces.ContentService; import com.liskovsoft.mediaserviceinterfaces.MediaItemService; import com.liskovsoft.mediaserviceinterfaces.ServiceManager; import com.liskovsoft.mediaserviceinterfaces.NotificationsService; import com.liskovsoft.mediaserviceinterfaces.SignInService; import com.liskovsoft.mediaserviceinterfaces.SignInService.OnAccountChange; import com.liskovsoft.mediaserviceinterfaces.oauth.Account; import com.liskovsoft.mediaserviceinterfaces.data.MediaGroup; import com.liskovsoft.mediaserviceinterfaces.data.MediaItem; import com.liskovsoft.mediaserviceinterfaces.data.MediaItemFormatInfo; import com.liskovsoft.mediaserviceinterfaces.data.MediaItemMetadata; import com.liskovsoft.mediaserviceinterfaces.data.NotificationState; import com.liskovsoft.mediaserviceinterfaces.data.PlaylistInfo; import com.liskovsoft.sharedutils.helpers.MessageHelpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.app.models.data.VideoGroup; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.service.VideoStateService; import com.liskovsoft.smartyoutubetv2.common.app.presenters.ChannelPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.ChannelUploadsPresenter; import com.liskovsoft.smartyoutubetv2.common.prefs.AccountsData; import com.liskovsoft.smartyoutubetv2.common.prefs.AppPrefs; import com.liskovsoft.smartyoutubetv2.common.prefs.MainUIData; import com.liskovsoft.smartyoutubetv2.common.utils.LoadingManager; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import com.liskovsoft.youtubeapi.service.YouTubeServiceManager; import io.reactivex.Observable; import io.reactivex.disposables.Disposable; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; public class MediaServiceManager implements OnAccountChange { private static final String TAG = MediaServiceManager.class.getSimpleName(); private static MediaServiceManager sInstance; private final MediaItemService mItemService; private final ContentService mContentService; private final SignInService mSignInService; private final NotificationsService mNotificationsService; private Disposable mMetadataAction; private Disposable mUploadsAction; private Disposable mRowsAction; private Disposable mSubscribedChannelsAction; private Disposable mFormatInfoAction; private Disposable mPlaylistGroupAction; private Disposable mPlaylistInfosAction; private Disposable mHistoryAction; private static final int MIN_GRID_GROUP_SIZE = 13; private static final int MIN_ROW_GROUP_SIZE = 5; private static final int MIN_SCALED_GRID_GROUP_SIZE = 35; private static final int MIN_SCALED_ROW_GROUP_SIZE = 10; private final Map> mContinuations = new HashMap<>(); private final List mAccountListeners = new CopyOnWriteArrayList<>(); public interface OnMetadata { void onMetadata(MediaItemMetadata metadata); } public interface OnMediaGroup { void onMediaGroup(MediaGroup group); } public interface OnMediaGroupList { void onMediaGroupList(List groupList); } public interface OnFormatInfo { void onFormatInfo(MediaItemFormatInfo formatInfo); } public interface OnAccountList { void onAccountList(List accountList); } public interface OnPlaylistInfos { void onPlaylistInfos(List playlistInfos); } public interface AccountChangeListener { void onAccountChanged(Account account); } public interface OnError { void onError(Throwable error); } public interface OnComplete { void onComplete(); } private MediaServiceManager() { ServiceManager service = YouTubeServiceManager.instance(); mItemService = service.getMediaItemService(); mContentService = service.getContentService(); mSignInService = service.getSignInService(); mNotificationsService = service.getNotificationsService(); mSignInService.addOnAccountChange(this); } public static MediaServiceManager instance() { if (sInstance == null) { sInstance = new MediaServiceManager(); } return sInstance; } public void loadMetadata(MediaItem mediaItem, OnMetadata onMetadata) { loadMetadata(mediaItem, onMetadata, null, null); } /** * NOTE: Load suggestions from MediaItem isn't robust. Because playlistId may be initialized from RemoteControlManager. */ public void loadMetadata(MediaItem mediaItem, OnMetadata onMetadata, OnError onError, OnComplete onComplete) { loadMetadata(Video.from(mediaItem), onMetadata, onError, onComplete); } public void loadMetadata(Video video, OnMetadata onMetadata) { loadMetadata(video, onMetadata, null, null); } public void loadMetadata(Video video, OnMetadata onMetadata, OnError onError, OnComplete onComplete) { if (video == null) { return; } RxHelper.disposeActions(mMetadataAction); Observable observable; // NOTE: Load suggestions from mediaItem isn't robust. Because playlistId may be initialized from RemoteControlManager. // Video might be loaded from Channels section (has playlistParams) if (video.mediaItem != null) { // Use additional data like playlist id observable = mItemService.getMetadataObserve(video.mediaItem); } else { // Simply load observable = mItemService.getMetadataObserve(video.videoId, video.getPlaylistId(), video.playlistIndex, video.playlistParams); } mMetadataAction = observable .subscribe( onMetadata::onMetadata, error -> { Log.e(TAG, "loadMetadata error: %s", error.getMessage()); if (onError != null) { onError.onError(error); } }, () -> { if (onComplete != null) { onComplete.onComplete(); } } ); } public void loadChannelUploads(Video item, OnMediaGroup onMediaGroup) { if (item == null) { return; } loadChannelUploads(item.mediaItem, onMediaGroup); } public void loadChannelUploads(MediaItem item, OnMediaGroup onMediaGroup) { if (item == null) { return; } RxHelper.disposeActions(mUploadsAction); Observable observable = mContentService.getGroupObserve(item); mUploadsAction = observable .subscribe( onMediaGroup::onMediaGroup, error -> { onMediaGroup.onMediaGroup(null); Log.e(TAG, "loadChannelUploads error: %s", error.getMessage()); } ); } public void loadSubscribedChannels(OnMediaGroup onMediaGroup) { RxHelper.disposeActions(mSubscribedChannelsAction); Observable observable = mContentService.getSubscribedChannelsByNewContentObserve(); mSubscribedChannelsAction = observable .subscribe( onMediaGroup::onMediaGroup, error -> Log.e(TAG, "loadSubscribedChannels error: %s", error.getMessage()) ); } private void loadChannelRows(Video item, OnMediaGroupList onMediaGroupList) { loadChannelRows(item, onMediaGroupList, null, null); } private void loadChannelRows(Video item, OnMediaGroupList onMediaGroupList, OnError onError, OnComplete onComplete) { if (item == null) { return; } RxHelper.disposeActions(mRowsAction); Observable> observable = item.mediaItem != null ? mContentService.getChannelObserve(item.mediaItem) : mContentService.getChannelObserve(item.channelId); mRowsAction = observable .subscribe( onMediaGroupList::onMediaGroupList, error -> { Log.e(TAG, "loadChannelRows error: %s", error.getMessage()); if (onError != null) { onError.onError(error); } }, () -> { if (onComplete != null) { onComplete.onComplete(); } } ); } public void loadChannelPlaylist(Video item, OnMediaGroup callback) { loadChannelRows( item, mediaGroupList -> callback.onMediaGroup(mediaGroupList.get(0)) ); } public void loadFormatInfo(Video item, OnFormatInfo onFormatInfo) { if (item == null) { return; } RxHelper.disposeActions(mFormatInfoAction); Observable observable = mItemService.getFormatInfoObserve(item.videoId); mFormatInfoAction = observable .subscribe( onFormatInfo::onFormatInfo, error -> Log.e(TAG, "loadFormatInfo error: %s", error.getMessage()) ); } public void loadPlaylists(Video item, OnMediaGroup onPlaylistGroup) { if (item == null) { return; } RxHelper.disposeActions(mPlaylistGroupAction); Observable observable = mContentService.getPlaylistsObserve(); mPlaylistGroupAction = observable .subscribe( onPlaylistGroup::onMediaGroup, error -> Log.e(TAG, "loadPlaylists error: %s", error.getMessage()) ); } public void getPlaylistInfos(OnPlaylistInfos onPlaylistInfos) { RxHelper.disposeActions(mPlaylistInfosAction); Observable> observable = mItemService.getPlaylistsInfoObserve(null); mPlaylistInfosAction = observable .subscribe( onPlaylistInfos::onPlaylistInfos, error -> Log.e(TAG, "getPlaylistInfos error: %s", error.getMessage()) ); } public void loadAccounts(OnAccountList onAccountList) { onAccountList.onAccountList(mSignInService.getAccounts()); } public void authCheck(Runnable onSuccess, Runnable onError) { if (onSuccess == null && onError == null) { return; } if (mSignInService.isSigned()) { if (onSuccess != null) { onSuccess.run(); } } else { if (onError != null) { onError.run(); } } } public void disposeActions() { RxHelper.disposeActions(mMetadataAction, mUploadsAction, mRowsAction, mSubscribedChannelsAction); } /** * Most tiny ui has 8 cards in a row or 24 in grid. */ public boolean shouldContinueGridGroup(Context context, VideoGroup group) { return shouldContinueTheGroup(context, group, true); } public boolean shouldContinueRowGroup(Context context, VideoGroup group) { return shouldContinueTheGroup(context, group,false); } /** * Most tiny ui has 8 cards in a row or 24 in grid. */ public boolean shouldContinueTheGroup(Context context, VideoGroup group, boolean isGrid) { if (group == null || group.getMediaGroup() == null) { return false; } MediaGroup mediaGroup = group.getMediaGroup(); Pair sizeTimestamp = mContinuations.get(group.getId()); long currentTimeMillis = System.currentTimeMillis(); if (sizeTimestamp != null && currentTimeMillis - sizeTimestamp.second > 3_000) { // seems that section is refreshed sizeTimestamp = null; } int prevSize = sizeTimestamp != null ? sizeTimestamp.first : 0; int newSize = mediaGroup.getMediaItems() != null ? mediaGroup.getMediaItems().size() : 0; int totalSize = prevSize + newSize; MainUIData mainUIData = MainUIData.instance(context); boolean isScaledUIEnabled = mainUIData.getUIScale() < 0.8f || mainUIData.getVideoGridScale() < 0.8f; int minScaledSize = isGrid ? MIN_SCALED_GRID_GROUP_SIZE : MIN_SCALED_ROW_GROUP_SIZE; int minSize = isGrid ? MIN_GRID_GROUP_SIZE : MIN_ROW_GROUP_SIZE; boolean groupTooSmall = isScaledUIEnabled ? totalSize < minScaledSize : totalSize < minSize; mContinuations.put(group.getId(), new Pair<>(groupTooSmall ? totalSize : 0, currentTimeMillis)); return groupTooSmall; } public void enableHistory(boolean enable) { if (enable) { // don't disable history for other clients RxHelper.runAsyncUser(() -> mContentService.enableHistory(true)); } } public void clearHistory(Context context, Runnable onFinish) { RxHelper.runAsyncUser(mContentService::clearHistory, onFinish); VideoStateService.instance(context).clear(); // even for the logged users this needed too } public void clearSearchHistory() { RxHelper.runAsyncUser(mContentService::clearSearchHistory); } public void updateHistory(Video video, long positionMs) { if (video == null || RxHelper.isAnyActionRunning(mHistoryAction)) { return; } RxHelper.disposeActions(mHistoryAction); Observable historyObservable; if (video.mediaItem != null) { historyObservable = mItemService.updateHistoryPositionObserve(video.mediaItem, positionMs / 1_000f); } else { // video launched form ATV channels historyObservable = mItemService.updateHistoryPositionObserve(video.videoId, positionMs / 1_000f); } mHistoryAction = RxHelper.execute(historyObservable, error -> setHistoryBroken(true), () -> setHistoryBroken(false)); } public void hideNotification(Video item) { if (item != null && item.belongsToNotifications()) { RxHelper.execute(mNotificationsService.hideNotificationObserve(item.mediaItem)); } } public void setNotificationState(NotificationState state, OnError onError) { RxHelper.execute(mNotificationsService.setNotificationStateObserve(state), onError::onError); } public void removeFromWatchLaterPlaylist(Video video) { removeFromWatchLaterPlaylist(video, null); } public void removeFromWatchLaterPlaylist(Video video, Runnable onSuccess) { if (video == null || !mSignInService.isSigned()) { return; } Disposable playlistsInfoAction = mItemService.getPlaylistsInfoObserve(video.videoId) .subscribe( videoPlaylistInfos -> { PlaylistInfo watchLater = videoPlaylistInfos.get(0); if (watchLater.isSelected()) { Observable editObserve = mItemService.removeFromPlaylistObserve(watchLater.getPlaylistId(), video.videoId); RxHelper.execute(editObserve, () -> { if (onSuccess != null) { onSuccess.run(); } }); } }, error -> { // Fallback to something on error Log.e(TAG, "Get playlists error: %s", error.getMessage()); } ); } public void addAccountListener(AccountChangeListener listener) { if (!mAccountListeners.contains(listener)) { if (listener instanceof AccountsData || listener instanceof AppPrefs) { mAccountListeners.add(0, listener); // data classes should be called before regular listeners } else { mAccountListeners.add(listener); } } } public void removeAccountListener(AccountChangeListener listener) { mAccountListeners.remove(listener); } public Account getSelectedAccount() { return mSignInService.getSelectedAccount(); } public String printAccountDebugInfo() { return mSignInService.printDebugInfo(); } @Override public void onAccountChanged(Account account) { for (AccountChangeListener listener : mAccountListeners) { listener.onAccountChanged(account); } } /** * Selecting right presenter for the channel.
* Channels could be of two types: regular (usr channel) and playlist channel (contains single row, try search: 'Mon mix') */ public static void chooseChannelPresenter(Context context, Video item) { if (item.hasVideo() || item.hasReloadPageKey()) { // a channel item from Channels section ChannelPresenter.instance(context).openChannel(item); return; } LoadingManager.showLoading(context, true); AtomicInteger atomicIndex = new AtomicInteger(0); MediaServiceManager.instance().loadChannelRows(item, groups -> { LoadingManager.showLoading(context, false); if (groups == null || groups.isEmpty()) { return; } MediaGroup firstGroup = groups.get(0); int type = firstGroup.getType(); if (type == MediaGroup.TYPE_CHANNEL_UPLOADS) { if (atomicIndex.incrementAndGet() == 1) { ChannelUploadsPresenter.instance(context).clear(); ChannelUploadsPresenter.instance(context).setChannel(item); } // NOTE: Crashes RecycleView IndexOutOfBoundsException when doing add immediately after clear Utils.postDelayed(() -> ChannelUploadsPresenter.instance(context).update(firstGroup), 100); } else if (type == MediaGroup.TYPE_CHANNEL) { if (atomicIndex.incrementAndGet() == 1) { ChannelPresenter.instance(context).clear(); ChannelPresenter.instance(context).setChannel(item); } // NOTE: Crashes RecycleView IndexOutOfBoundsException when doing add immediately after clear Utils.postDelayed(() -> ChannelPresenter.instance(context).updateRows(groups), 100); } else { MessageHelpers.showMessage(context, "Unknown type of channel"); } }, error -> LoadingManager.showLoading(context, false), () -> LoadingManager.showLoading(context, false)); } private void setHistoryBroken(boolean isBroken) { VideoStateService stateService = VideoStateService.instance(null); if (stateService != null) { stateService.setHistoryBroken(isBroken); } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/MotherActivity.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.res.Configuration; import android.os.Build.VERSION; import android.os.Bundle; import android.util.DisplayMetrics; import android.view.KeyCharacterMap.UnavailableException; import android.view.KeyEvent; import android.view.MotionEvent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.helpers.KeyHelpers; import com.liskovsoft.sharedutils.locale.LocaleContextWrapper; import com.liskovsoft.sharedutils.locale.LocaleUpdater; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.app.views.ViewManager; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.prefs.MainUIData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import com.liskovsoft.youtubeapi.service.internal.MediaServiceData; import com.r0adkll.slidr.Slidr; import com.r0adkll.slidr.model.SlidrConfig; import com.r0adkll.slidr.model.SlidrListener; import com.r0adkll.slidr.model.SlidrPosition; import java.util.ArrayList; import java.util.List; public class MotherActivity extends FragmentActivity { private static final String TAG = MotherActivity.class.getSimpleName(); private static final float DEFAULT_DENSITY = 2.0f; // xhdpi private static final float DEFAULT_WIDTH = 1920f; // xhdpi private static DisplayMetrics sCachedDisplayMetrics; protected static boolean sIsInPipMode; private ScreensaverManager mScreensaverManager; // Make static in case Don't keep activities enabled in Developer settings private static List mOnPermissions; private static List mOnResults; private long mLastKeyDownTime; private boolean mEnableThrottleKeyDown; private boolean mIsOculusQuestFixEnabled; private boolean mIsFullscreenModeEnabled; public interface OnPermissions { void onPermissions(int requestCode, String[] permissions, int[] grantResults); } public interface OnResult { void onResult(int requestCode, int resultCode, Intent data); } @Override protected void onCreate(@Nullable Bundle savedInstanceState) { // Fixing: Only fullscreen opaque activities can request orientation (api 26) // NOTE: You should remove 'screenOrientation' from the manifest. // NOTE: Possible side effect: initDpi() won't work: "When you setRequestedOrientation() the view may be restarted" //if (VERSION.SDK_INT != 26) { // setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); //} super.onCreate(savedInstanceState); Log.d(TAG, "Starting %s...", this.getClass().getSimpleName()); mIsOculusQuestFixEnabled = PlayerTweaksData.instance(this).isOculusQuestFixEnabled(); mIsFullscreenModeEnabled = GeneralData.instance(this).isFullscreenModeEnabled(); initDpi(); initTheme(); // Search Fullscreen routine inside onPause() method if (!mIsFullscreenModeEnabled) { // There's no way to do this programmatically! setTheme(R.style.FitSystemWindows); // totally disabling the translucency or any color placed on the status bar and navigation bar //if (Build.VERSION.SDK_INT >= 19) { // Window w = getWindow(); // w.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); //} } if (mIsOculusQuestFixEnabled) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); } mScreensaverManager = new ScreensaverManager(this); // moved below the theme to fix side effects //Helpers.addFullscreenListener(this); initEdgeSlide(); } @Override public boolean dispatchGenericMotionEvent(MotionEvent event) { if (event.getAction() == KeyEvent.ACTION_DOWN) { mScreensaverManager.enable(); } return super.dispatchGenericMotionEvent(event); } @Override public boolean dispatchTouchEvent(MotionEvent event) { if (event.getAction() == KeyEvent.ACTION_DOWN) { mScreensaverManager.enable(); } try { return super.dispatchTouchEvent(event); } catch (NullPointerException | SecurityException | IllegalStateException | ArrayIndexOutOfBoundsException e) { // Attempt to invoke interface method 'boolean android.app.trust.ITrustManager.isDeviceLocked(int)' on a null object reference // Permission Denial: starting Intent // IllegalStateException: exitFreeformMode: You can only go fullscreen from freeform. e.printStackTrace(); return false; } } @SuppressLint("RestrictedApi") @Override public boolean dispatchKeyEvent(KeyEvent event) { if (event == null) { // handled return true; } if (event.getAction() == KeyEvent.ACTION_DOWN) { boolean isKeepScreenOff = mScreensaverManager.isScreenOff() && Helpers.equalsAny(event.getKeyCode(), new int[]{KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN}); if (!isKeepScreenOff) { mScreensaverManager.enable(); } } try { return super.dispatchKeyEvent(event); } catch (NullPointerException | IllegalArgumentException | IllegalStateException | SecurityException | UnavailableException e) { // NullPointerException: 'android.view.Window androidx.core.app.ComponentActivity.getWindow()' on a null object reference // IllegalArgumentException: View is not a direct child of HorizontalGridView // Fatal Exception: java.lang.IllegalStateException // android.permission.RECORD_AUDIO required for search (Android 5 mostly) // Fatal Exception: java.lang.SecurityException // Not allowed to bind to service Intent { act=android.speech.RecognitionService cmp=com.xgimi.duertts/com.baidu.duer.services.tvser e.printStackTrace(); return false; } } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_MEDIA_STOP) { // shortcut for closing PIP PlaybackPresenter.instance(this).forceFinish(); return true; } boolean result = super.onKeyDown(keyCode, event); // Fix buggy G20s menu key (focus lost on key press) return KeyHelpers.isMenuKey(keyCode) || throttleKeyDown(keyCode) || result; } public void finishReally() { try { if (VERSION.SDK_INT >= 21 && getViewManager().getTopView() != null) { // remain root activity in recents super.finishAndRemoveTask(); } else { super.finish(); } } catch (Exception e) { // TextView not attached to window manager (IllegalArgumentException) } } @Override protected void attachBaseContext(Context context) { Context contextWrapper = null; if (context != null) { // NOTE: Use only cached metrics. Because metrics creation involves using WindowManager, which isn't available at this stage. contextWrapper = LocaleContextWrapper.wrap(context, LocaleUpdater.getSavedLocale(context), sCachedDisplayMetrics); } super.attachBaseContext(contextWrapper); } @Override protected void onResume() { try { super.onResume(); } catch (IllegalArgumentException e) { e.printStackTrace(); } // 4K fix with AFR applyCustomConfig(); applyFullscreenModeIfNeeded(); // Remove screensaver from the previous activity when closing current one. // Called on player's next track. Reason unknown. mScreensaverManager.enable(); } @Override protected void onPause() { super.onPause(); // Remove screensaver from the previous activity when closing current one. // Called on player's next track. Reason unknown. mScreensaverManager.disable(); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); applyCustomConfig(); } public ScreensaverManager getScreensaverManager() { return mScreensaverManager; } protected void initTheme() { int rootThemeResId = MainUIData.instance(this).getColorScheme().browseThemeResId; if (rootThemeResId > 0) { setTheme(rootThemeResId); } } private void initDpi() { getResources().getDisplayMetrics().setTo(getDisplayMetrics(this)); } private DisplayMetrics getDisplayMetrics(Context context) { // BUG: adapt to resolution change (e.g. on AFR) // Don't disable caching or you will experience weird sizes on cards in video suggestions (e.g. after exit from PIP)! if (sCachedDisplayMetrics == null) { // NOTE: Don't replace with getResources().getDisplayMetrics(). Shows wrong metrics here! DisplayMetrics displayMetrics = new DisplayMetrics(); getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); float uiScale = MainUIData.instance(context).getUIScale(); // Take into the account screen orientation (e.g. when running on phone) int widthPixels = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels); float widthRatio = DEFAULT_WIDTH / widthPixels; float density = DEFAULT_DENSITY / widthRatio * uiScale; displayMetrics.density = density; displayMetrics.scaledDensity = density; sCachedDisplayMetrics = displayMetrics; } return sCachedDisplayMetrics; } private void applyCustomConfig() { // NOTE: dpi should come after locale update to prevent resources overriding. // Fix sudden language change. // Could happen when screen goes off or after PIP mode. LocaleUpdater.applySavedLocale(this); // Fix sudden dpi change. // Could happen when screen goes off or after PIP mode. initDpi(); } private void applyFullscreenModeIfNeeded() { if (mIsFullscreenModeEnabled) { // Most of the fullscreen tweaks could be performed in styles but not all. // E.g. Hide bottom navigation bar (couldn't be done in styles). Helpers.makeActivityFullscreen2(this); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (mOnPermissions != null) { for (OnPermissions callback : mOnPermissions) { callback.onPermissions(requestCode, permissions, grantResults); } mOnPermissions.clear(); mOnPermissions = null; } } @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); if (mOnResults != null) { for (OnResult callback : mOnResults) { callback.onResult(requestCode, resultCode, data); } mOnResults.clear(); mOnResults = null; } } @Override public void onBackPressed() { super.onBackPressed(); // Oculus Quest fix: back button not closing the activity if (mIsOculusQuestFixEnabled) { finish(); } } public void addOnPermissions(OnPermissions onPermissions) { if (mOnPermissions == null) { mOnPermissions = new ArrayList<>(); } mOnPermissions.remove(onPermissions); mOnPermissions.add(onPermissions); } public void addOnResult(OnResult onResult) { if (mOnResults == null) { mOnResults = new ArrayList<>(); } mOnResults.remove(onResult); mOnResults.add(onResult); } /** * Use this method only upon exiting from the app.
* Big troubles with AFR resolution switch! */ public static void invalidate() { sCachedDisplayMetrics = null; sIsInPipMode = false; } public static DisplayMetrics getCachedDisplayMetrics() { return sCachedDisplayMetrics; } /** * Comments focus fix
* https://stackoverflow.com/questions/34277425/recyclerview-items-lose-focus */ private boolean throttleKeyDown(int keyCode) { if (mEnableThrottleKeyDown && keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { long current = System.currentTimeMillis(); if (current - mLastKeyDownTime < 100) { return true; } mLastKeyDownTime = current; } return false; } /** * Comments focus fix
* https://stackoverflow.com/questions/34277425/recyclerview-items-lose-focus */ public void enableThrottleKeyDown(boolean enable) { mEnableThrottleKeyDown = enable; } //@Override //public void setTheme(int resid) { // super.setTheme(resid); // // // No way to do this programmatically! // if (!GeneralData.instance(this).isFullscreenModeEnabled()) { // super.setTheme(R.style.FitSystemWindows); // } //} protected ViewManager getViewManager() { return ViewManager.instance(this); } protected GeneralData getGeneralData() { return GeneralData.instance(this); } protected PlayerTweaksData getPlayerTweaksData() { return PlayerTweaksData.instance(this); } protected PlayerData getPlayerData() { return PlayerData.instance(this); } protected MainUIData getMainUIData() { return MainUIData.instance(this); } protected MediaServiceData getMediaServiceData() { return MediaServiceData.instance(); } private void initEdgeSlide() { if (VERSION.SDK_INT < 21 || !Helpers.isTouchSupported(this) || Utils.isSystemGestureArrowEnabled(this)) { return; } SlidrConfig config = new SlidrConfig.Builder() .position(SlidrPosition.LEFT) // Swipe from the left .edge(true) // Only trigger from the screen edge .edgeSize(0.18f) // Grab 18% of the screen (good for cars) .scrimStartAlpha(0f) // Don't dim the background screen .scrimEndAlpha(0f) // Background clear when finished .distanceThreshold(0.1f) // Set drag distance to minimum .partial(true) // Don't do full slide animation .listener(new SlidrListener() { @Override public void onSlideStateChanged(int state) {} @Override public void onSlideChange(float percent) {} @Override public void onSlideOpened() {} @Override public boolean onSlideClosed() { // This replaces the default finish() with your back logic onBackPressed(); return true; // Tells the library we handled the close } }) .build(); // Attach to this activity Slidr.attach(this, config); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/PlayerKeyTranslator.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.view.KeyEvent; import com.liskovsoft.sharedutils.helpers.MessageHelpers; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.manager.PlayerUI; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.Arrays; import java.util.Map; public class PlayerKeyTranslator extends GlobalKeyTranslator { private final GeneralData mGeneralData; private final Context mContext; private final Runnable likeAction = () -> { PlaybackPresenter playbackPresenter = getPlaybackPresenter(); if (playbackPresenter != null && playbackPresenter.getView() != null) { playbackPresenter.onButtonClicked(R.id.action_thumbs_up, PlayerUI.BUTTON_ON); playbackPresenter.getView().setButtonState(R.id.action_thumbs_up, PlayerUI.BUTTON_ON); playbackPresenter.getView().setButtonState(R.id.action_thumbs_down, PlayerUI.BUTTON_OFF); MessageHelpers.showMessage(getContext(), R.string.action_like); } }; private final Runnable dislikeAction = () -> { PlaybackPresenter playbackPresenter = getPlaybackPresenter(); if (playbackPresenter != null && playbackPresenter.getView() != null) { playbackPresenter.onButtonClicked(R.id.action_thumbs_down, PlayerUI.BUTTON_ON); playbackPresenter.getView().setButtonState(R.id.action_thumbs_up, PlayerUI.BUTTON_OFF); playbackPresenter.getView().setButtonState(R.id.action_thumbs_down, PlayerUI.BUTTON_ON); MessageHelpers.showMessage(getContext(), R.string.action_dislike); } }; private final Runnable speedUpAction = () -> speedUp(true); private final Runnable speedDownAction = () -> speedUp(false); private final Runnable volumeUpAction = () -> volumeUp(true); private final Runnable volumeDownAction = () -> volumeUp(false); private final Runnable speedToggleAction = () -> { PlaybackPresenter playbackPresenter = getPlaybackPresenter(); if (playbackPresenter != null && playbackPresenter.getView() != null) { float currentSpeed = playbackPresenter.getView().getSpeed(); playbackPresenter.onButtonClicked(R.id.action_video_speed, currentSpeed != 1.0f ? PlayerUI.BUTTON_ON : PlayerUI.BUTTON_OFF); } }; public PlayerKeyTranslator(Context context) { super(context); mContext = context; mGeneralData = GeneralData.instance(context); } @Override protected void initKeyMapping() { super.initKeyMapping(); Map globalKeyMapping = getKeyMapping(); // Reset global mapping to default globalKeyMapping.remove(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); globalKeyMapping.remove(KeyEvent.KEYCODE_MEDIA_REWIND); globalKeyMapping.remove(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD); if (mGeneralData.isRemapFastForwardToNextEnabled()) { globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, KeyEvent.KEYCODE_MEDIA_NEXT); globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_REWIND, KeyEvent.KEYCODE_MEDIA_PREVIOUS); } if (mGeneralData.isRemapNextToFastForwardEnabled()) { globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD); globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_REWIND); } if (mGeneralData.isRemapPageUpToNextEnabled()) { globalKeyMapping.put(KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_MEDIA_NEXT); globalKeyMapping.put(KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS); } if (mGeneralData.isRemapChannelUpToNextEnabled()) { globalKeyMapping.put(KeyEvent.KEYCODE_CHANNEL_UP, KeyEvent.KEYCODE_MEDIA_NEXT); globalKeyMapping.put(KeyEvent.KEYCODE_CHANNEL_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS); } if (!PlaybackPresenter.instance(mContext).isInPipMode() && mGeneralData.isRemapPlayToOKEnabled()) { globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_DPAD_CENTER); } else { globalKeyMapping.remove(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); } globalKeyMapping.put(KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_0); // reset position of the video (if enabled number key handling in the settings) // Toggle playback on PLAY/PAUSE. NOTE: cause troubles with IoT handlers!!! //globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); //globalKeyMapping.put(KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); } @Override protected void initActionMapping() { super.initActionMapping(); Map actionMapping = getActionMapping(); if (mGeneralData.isRemapSToSpeedToggleEnabled()) { // New mapping check actionMapping.put(KeyEvent.KEYCODE_S, speedToggleAction); } if (mGeneralData.isRemapPageUpToLikeEnabled()) { actionMapping.put(KeyEvent.KEYCODE_PAGE_UP, likeAction); actionMapping.put(KeyEvent.KEYCODE_PAGE_DOWN, dislikeAction); } if (mGeneralData.isRemapChannelUpToLikeEnabled()) { actionMapping.put(KeyEvent.KEYCODE_CHANNEL_UP, likeAction); actionMapping.put(KeyEvent.KEYCODE_CHANNEL_DOWN, dislikeAction); } if (mGeneralData.isRemapPageUpToSpeedEnabled()) { actionMapping.put(KeyEvent.KEYCODE_PAGE_UP, speedUpAction); actionMapping.put(KeyEvent.KEYCODE_PAGE_DOWN, speedDownAction); } if (mGeneralData.isRemapPageDownToSpeedEnabled()) { actionMapping.put(KeyEvent.KEYCODE_PAGE_UP, speedDownAction); actionMapping.put(KeyEvent.KEYCODE_PAGE_DOWN, speedUpAction); } if (mGeneralData.isRemapChannelUpToSpeedEnabled()) { actionMapping.put(KeyEvent.KEYCODE_CHANNEL_UP, speedUpAction); actionMapping.put(KeyEvent.KEYCODE_CHANNEL_DOWN, speedDownAction); } if (mGeneralData.isRemapFastForwardToSpeedToggleEnabled()) { actionMapping.put(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, speedToggleAction); actionMapping.put(KeyEvent.KEYCODE_MEDIA_REWIND, speedToggleAction); } else if (mGeneralData.isRemapFastForwardToSpeedEnabled()) { actionMapping.put(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, speedUpAction); actionMapping.put(KeyEvent.KEYCODE_MEDIA_REWIND, speedDownAction); } if (mGeneralData.isRemapNextToSpeedEnabled()) { actionMapping.put(KeyEvent.KEYCODE_MEDIA_NEXT, speedUpAction); actionMapping.put(KeyEvent.KEYCODE_MEDIA_PREVIOUS, speedDownAction); } if (mGeneralData.isRemapDpadUpToSpeedEnabled()) { actionMapping.put(KeyEvent.KEYCODE_DPAD_UP, speedUpAction); actionMapping.put(KeyEvent.KEYCODE_DPAD_DOWN, speedDownAction); } if (mGeneralData.isRemapNumbersToSpeedEnabled()) { actionMapping.put(KeyEvent.KEYCODE_3, speedUpAction); actionMapping.put(KeyEvent.KEYCODE_1, speedDownAction); } if (mGeneralData.isRemapChannelUpToVolumeEnabled()) { actionMapping.put(KeyEvent.KEYCODE_CHANNEL_UP, volumeUpAction); actionMapping.put(KeyEvent.KEYCODE_CHANNEL_DOWN, volumeDownAction); } if (mGeneralData.isRemapDpadUpToVolumeEnabled()) { actionMapping.put(KeyEvent.KEYCODE_DPAD_UP, volumeUpAction); actionMapping.put(KeyEvent.KEYCODE_DPAD_DOWN, volumeDownAction); } if (mGeneralData.isRemapDpadLeftToVolumeEnabled()) { actionMapping.put(KeyEvent.KEYCODE_DPAD_LEFT, volumeDownAction); actionMapping.put(KeyEvent.KEYCODE_DPAD_RIGHT, volumeUpAction); } } private void speedUp(boolean up) { PlayerTweaksData data = PlayerTweaksData.instance(mContext); float[] speedSteps = data.isLongSpeedListEnabled() ? Utils.SPEED_LIST_LONG : data.isExtraLongSpeedListEnabled() ? Utils.SPEED_LIST_EXTRA_LONG : Utils.SPEED_LIST_SHORT; PlaybackPresenter playbackPresenter = getPlaybackPresenter(); if (playbackPresenter != null && playbackPresenter.getView() != null) { float currentSpeed = playbackPresenter.getView().getSpeed(); int currentIndex = Arrays.binarySearch(speedSteps, currentSpeed); if (currentIndex < 0) { currentIndex = Arrays.binarySearch(speedSteps, 1.0f); } int newIndex = up ? currentIndex + 1 : currentIndex - 1; float speed = newIndex >= 0 && newIndex < speedSteps.length ? speedSteps[newIndex] : speedSteps[currentIndex]; PlayerData.instance(mContext).setSpeed(speed); playbackPresenter.getView().setSpeed(speed); MessageHelpers.showMessage(mContext, String.format("%sx", speed)); } } private void volumeUp(boolean up) { PlaybackPresenter playbackPresenter = getPlaybackPresenter(); if (playbackPresenter != null && playbackPresenter.getView() != null) { Utils.volumeUp(mContext, playbackPresenter.getView(), up); } } private PlaybackPresenter getPlaybackPresenter() { return PlaybackPresenter.instance(mContext); } private Context getContext() { return mContext; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/RemoteControlReceiver.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.annotation.SuppressLint; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; public class RemoteControlReceiver extends BroadcastReceiver { private static final String TAG = RemoteControlReceiver.class.getSimpleName(); @SuppressLint("UnsafeProtectedBroadcastReceiver") @Override public void onReceive(Context context, Intent intent) { Log.d(TAG, "Initializing remote control listener..."); // Fix unload from the memory on some devices? // NOTE: Starting from Android 12 (api 31) foreground service with type 'connectedDevice' not supported // Use 'mediaPlayback' type instead try { Utils.updateRemoteControlService(context); } catch (Exception e) { // ForegroundServiceStartNotAllowedException: startForegroundService() not allowed due to mAllowStartForeground false (Android 12) e.printStackTrace(); } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/RemoteControlService.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.app.Notification; import android.app.Service; import android.content.Intent; import android.content.pm.ServiceInfo; import android.os.Build.VERSION; import android.os.IBinder; import androidx.annotation.Nullable; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.app.views.ViewManager; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; public class RemoteControlService extends Service { private static final String TAG = RemoteControlService.class.getSimpleName(); private static final int NOTIFICATION_ID = RemoteControlService.class.hashCode(); @Nullable @Override public IBinder onBind(Intent intent) { Log.d(TAG, "onBind: %s", Helpers.toString(intent)); return null; } @Override public void onCreate() { super.onCreate(); // https://stackoverflow.com/questions/46445265/android-8-0-java-lang-illegalstateexception-not-allowed-to-start-service-inten // NOTE: it's impossible to hide notification on Android 9 and above // https://stackoverflow.com/questions/10962418/how-to-startforeground-without-showing-notification try { startForeground(NOTIFICATION_ID, createNotification()); } catch (Exception e) { // NullPointerException: Attempt to read from field 'int com.android.server.am.UidRecord.curProcState' on a null object reference // ForegroundServiceStartNotAllowedException: Service.startForeground() not allowed due to mAllowStartForeground false (Android 14) e.printStackTrace(); } } @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.d(TAG, "onStartCommand: %s", Helpers.toString(intent)); PlaybackPresenter.instance(getApplicationContext()); // init RemoteControlListener StreamReminderService.instance(getApplicationContext()).start(); // init reminder service return START_STICKY; } private Notification createNotification() { String remoteControl = getString(R.string.settings_remote_control); String serviceStarted = getString(R.string.background_service_started); return Utils.createNotification( getApplicationContext(), getApplicationInfo().icon, String.format("%s: %s", remoteControl, serviceStarted), ViewManager.instance(getApplicationContext()).getRootActivity()); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/RemoteControlWorker.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import androidx.annotation.NonNull; import androidx.work.Worker; import androidx.work.WorkerParameters; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; public class RemoteControlWorker extends Worker { private static final String TAG = RemoteControlWorker.class.getSimpleName(); public RemoteControlWorker( @NonNull Context context, @NonNull WorkerParameters workerParams) { super(context, workerParams); } @NonNull @Override public Result doWork() { Log.d(TAG, "Doing some work..."); PlaybackPresenter.instance(getApplicationContext()); // init RemoteControlListener return Result.success(); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/ScreensaverManager.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.app.Activity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.presenters.AddDevicePresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.AppDialogPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.app.presenters.SignInPresenter; import com.liskovsoft.smartyoutubetv2.common.app.views.PlaybackView; import com.liskovsoft.smartyoutubetv2.common.app.views.ViewManager; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import com.liskovsoft.sharedutils.misc.WeakHashSet; import java.lang.ref.WeakReference; public class ScreensaverManager { private static final String TAG = ScreensaverManager.class.getSimpleName(); private static final int MODE_SCREENSAVER = 0; private static final int MODE_SCREEN_OFF = 1; private static final WeakHashSet sInstances = new WeakHashSet<>(); private static boolean sLockInstance; private final WeakReference mActivity; private final WeakReference mDimContainer; private final Runnable mDimScreen = this::dimScreen; private final Runnable mUndimScreen = this::undimScreen; private final Runnable mUnlockInstance = () -> sLockInstance = false; private int mMode = MODE_SCREENSAVER; private boolean mIsScreenOff; private boolean mIsBlocked; private final Runnable mTimeoutHandler = () -> { // Playing the video and dialog overlay isn't shown if (getViewManager().getTopView() != PlaybackView.class || !getTweaksData().isScreenOffTimeoutEnabled()) { return; } if (!getAppDialogPresenter().isDialogShown()) { doScreenOff(); } else { // showing dialog... or recheck... enableTimeout(); } }; public ScreensaverManager(Activity activity) { mActivity = new WeakReference<>(activity); mDimContainer = new WeakReference<>(createDimContainer(activity)); enable(); addToRegistry(); } private View createDimContainer(Activity activity) { View rootView = activity.getWindow().getDecorView().getRootView(); View dimContainer = rootView.findViewById(R.id.dim_container); if (dimContainer == null) { LayoutInflater layoutInflater = activity.getLayoutInflater(); dimContainer = layoutInflater.inflate(R.layout.dim_container, null); if (rootView instanceof ViewGroup) { // NOTE: zoom will be bugged! Frames on top and bottom. // Add negative margin to fix un-proper viewport positioning on some devices // NOTE: below code is not working!!! // NOTE: comment out code below if you don't want this //LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( // LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT); //params.setMargins(-30, -30, -30, -30); //((ViewGroup) rootView).addView(dimContainer, params); ((ViewGroup) rootView).addView(dimContainer); } } return dimContainer; } /** * Screen off check */ public void enableChecked() { // Fix dialog dimming when using the play button on the remote controller. // NOTE: only the last activity will show dimming and in our case the last one is PlaybackActivity if (mMode == MODE_SCREEN_OFF || getAppDialogPresenter().isDialogShown()) { return; } enable(); } /** * Screen off check */ public void disableChecked() { if (mMode == MODE_SCREEN_OFF) { return; } disable(); } public void enable() { if (mIsBlocked) { Log.d(TAG, "Screensaver blocked!"); return; } Log.d(TAG, "Enable screensaver"); disable(); int delayMs = getGeneralData().getScreensaverTimeoutMs() == GeneralData.SCREENSAVER_TIMEOUT_NEVER ? 10_000 : getGeneralData().getScreensaverTimeoutMs(); Utils.postDelayed(mDimScreen, delayMs); } public void disable() { if (mIsBlocked) { Log.d(TAG, "Screensaver blocked!"); return; } Log.d(TAG, "Disable screensaver"); mMode = MODE_SCREENSAVER; Utils.removeCallbacks(mDimScreen); Utils.postDelayed(mUndimScreen, 0); } public void doScreenOff() { //if (mIsScreenOff) { // return; //} disable(); mMode = MODE_SCREEN_OFF; Utils.postDelayed(mDimScreen, 0); } public boolean isScreenOff() { return mIsScreenOff; } public void setBlocked(boolean blocked) { mIsBlocked = blocked; } private void enableTimeout() { // Playing the video and dialog overlay isn't shown if (getViewManager().getTopView() != PlaybackView.class || !getTweaksData().isScreenOffTimeoutEnabled()) { disableTimeout(); return; } Log.d(TAG, "Starting auto hide ui timer..."); disableTimeout(); Utils.postDelayed(mTimeoutHandler, getTweaksData().getScreenOffTimeoutSec() * 1_000L); } private void disableTimeout() { Log.d(TAG, "Stopping auto hide ui timer..."); Utils.removeCallbacks(mTimeoutHandler); } private void dimScreen() { showHide(true); } private void undimScreen() { showHide(false); } private void showHide(boolean show) { showHideScreensaver(show); showHideDimming(show); } private void showHideDimming(boolean show) { Activity activity = mActivity.get(); View dimContainer = mDimContainer.get(); if (activity == null || dimContainer == null) { return; } if (!show) { enableTimeout(); } // Disable dimming on certain circumstances if (show && mMode == MODE_SCREENSAVER && ( isPlaying() || isSigning() || getGeneralData().getScreensaverTimeoutMs() == GeneralData.SCREENSAVER_TIMEOUT_NEVER ) ) { return; } int screenOffColor = Utils.getColor(activity, R.color.black, getTweaksData().getScreenOffDimmingPercents()); //int screenOffColorResId = getPlayerTweaksData().getScreenOffDimmingPercents() == 50 ? DIM_50 : DIM_100; int screensaverColor = Utils.getColor(activity, R.color.black, getGeneralData().getScreensaverDimmingPercents()); //int screensaverColorResId = getGeneralData().getScreensaverMode() == GeneralData.SCREENSAVER_MODE_NORMAL ? DIM_50 : DIM_100; dimContainer.setBackgroundColor(mMode == MODE_SCREENSAVER ? screensaverColor : screenOffColor); //dimContainer.setBackgroundResource(mMode == MODE_SCREENSAVER ? screensaverColorResId : screenOffColorResId); dimContainer.setVisibility(show ? View.VISIBLE : View.GONE); mIsScreenOff = mMode == MODE_SCREEN_OFF && getTweaksData().getScreenOffDimmingPercents() == 100 && show; if (mIsScreenOff) { hidePlayerOverlay(); } notifyRegistry(); } private void showHideScreensaver(boolean show) { if (sLockInstance) { return; } Activity activity = mActivity.get(); if (activity == null) { return; } // Disable screensaver on certain circumstances // Fix screen off before the video started if (show && (isPlaying() || isSigning() || getGeneralData().isScreensaverDisabled() || (mMode == MODE_SCREEN_OFF && getPosition() == 0))) { Helpers.disableScreensaver(activity); return; } if (show) { Helpers.enableScreensaver(activity); } else { Helpers.disableScreensaver(activity); } } private boolean isPlaying() { Activity activity = mActivity.get(); if (activity == null) { return false; } PlaybackView playbackView = PlaybackPresenter.instance(activity).getView(); return playbackView != null && playbackView.isPlaying(); } private long getPosition() { Activity activity = mActivity.get(); if (activity == null) { return 0; } PlaybackView playbackView = PlaybackPresenter.instance(activity).getView(); // Fix screen off before the video started return playbackView != null ? playbackView.getPositionMs() : 0; } private boolean isSigning() { Activity activity = mActivity.get(); if (activity == null) { return false; } return SignInPresenter.instance(activity).getView() != null || AddDevicePresenter.instance(activity).getView() != null; } private void hidePlayerOverlay() { Activity activity = mActivity.get(); if (activity == null) { return; } PlaybackView playbackView = PlaybackPresenter.instance(activity).getView(); if (playbackView != null) { playbackView.showOverlay(false); } } private void addToRegistry() { sInstances.add(this); } private void notifyRegistry() { if (sLockInstance) { return; } sLockInstance = true; sInstances.forEach(item -> { if (item != this) { item.disableChecked(); } }); Utils.postDelayed(mUnlockInstance, 0); } private AppDialogPresenter getAppDialogPresenter() { return AppDialogPresenter.instance(mActivity.get()); } private ViewManager getViewManager() { return ViewManager.instance(mActivity.get()); } private PlayerTweaksData getTweaksData() { return PlayerTweaksData.instance(mActivity.get()); } private GeneralData getGeneralData() { return GeneralData.instance(mActivity.get()); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/SharedPreferencesHelper.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.content.SharedPreferences; import com.liskovsoft.sharedutils.mylogger.Log; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.Map; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; public class SharedPreferencesHelper { private static final String TAG = SharedPreferencesHelper.class.getSimpleName(); // Backup SharedPreferences to a file public static void backupSharedPrefs(Context context, File backupFile) { try { SharedPreferences prefs = context.getSharedPreferences("MyPrefs", Context.MODE_PRIVATE); Map allEntries = prefs.getAll(); try (FileOutputStream fos = new FileOutputStream(backupFile); ObjectOutputStream oos = new ObjectOutputStream(fos)) { oos.writeObject(allEntries); } Log.d(TAG, "Backup completed successfully."); } catch (Exception e) { Log.e(TAG, "Failed to backup SharedPreferences: " + e.getMessage()); } } // Restore SharedPreferences from a backup file @SuppressWarnings("unchecked") public static void restoreFromObj(Context context, InputStream backupFile, String preferenceName) { if (preferenceName.endsWith(".xml")) { preferenceName = preferenceName.replace(".xml", ""); } try { // Read the backup file Map restoredData; try (ObjectInputStream ois = new ObjectInputStream(backupFile)) { //noinspection unchecked restoredData = (Map) ois.readObject(); } // Get SharedPreferences instance (creates a new one if it doesn't exist) SharedPreferences prefs = context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); // Optional: Clear existing preferences before restoring editor.clear(); // Restore key-value pairs to SharedPreferences for (Map.Entry entry : restoredData.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); if (value instanceof String) { editor.putString(key, (String) value); } else if (value instanceof Integer) { editor.putInt(key, (Integer) value); } else if (value instanceof Boolean) { editor.putBoolean(key, (Boolean) value); } else if (value instanceof Float) { editor.putFloat(key, (Float) value); } else if (value instanceof Long) { editor.putLong(key, (Long) value); } else { Log.e(TAG, "Unsupported data type: " + key + " -> " + value); } } // Apply the changes editor.apply(); Log.d(TAG, "SharedPreferences restored successfully."); } catch (Exception e) { Log.e(TAG, "Failed to restore SharedPreferences: " + e.getMessage()); } } // Restore SharedPreferences from an XML file public static void restoreFromXml(Context context, InputStream backupFile, String preferenceName) { if (preferenceName.endsWith(".xml")) { preferenceName = preferenceName.replace(".xml", ""); } try { // Parse the XML file DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); Document doc = dBuilder.parse(backupFile); doc.getDocumentElement().normalize(); // Get SharedPreferences editor SharedPreferences prefs = context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); editor.clear(); // Optional: Clear existing preferences NodeList mapNode = doc.getElementsByTagName("map"); if (mapNode.getLength() == 0) { throw new IllegalStateException("Shared prefs format doesn't contain map item"); } if (mapNode.getLength() > 1) { throw new IllegalStateException("Shared prefs contains more than one map item"); } // Iterate over elements NodeList nodeList = mapNode.item(0).getChildNodes(); for (int i = 0; i < nodeList.getLength(); i++) { Node item = nodeList.item(i); if (item.getNodeType() != Node.ELEMENT_NODE) { continue; } Element element = (Element) item; String key = element.getAttribute("name"); String type = element.getNodeName(); String value = "string".equals(type) ? element.getTextContent() : element.getAttribute("value"); // Restore the value based on its type switch (type) { case "string": editor.putString(key, value); break; case "int": editor.putInt(key, Integer.parseInt(value)); break; case "boolean": editor.putBoolean(key, Boolean.parseBoolean(value)); break; case "float": editor.putFloat(key, Float.parseFloat(value)); break; case "long": editor.putLong(key, Long.parseLong(value)); break; default: Log.d(TAG, "Unsupported data type: " + type); } } editor.apply(); Log.d(TAG, "SharedPreferences restored successfully."); } catch (Exception e) { Log.e(TAG, "Failed to restore SharedPreferences: " + e.getMessage()); } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/StreamReminderService.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import com.liskovsoft.mediaserviceinterfaces.MediaItemService; import com.liskovsoft.mediaserviceinterfaces.ServiceManager; import com.liskovsoft.mediaserviceinterfaces.data.MediaItemFormatInfo; import com.liskovsoft.sharedutils.helpers.MessageHelpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Playlist; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.app.presenters.PlaybackPresenter; import com.liskovsoft.smartyoutubetv2.common.app.views.ViewManager; import com.liskovsoft.smartyoutubetv2.common.misc.TickleManager.TickleListener; import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.youtubeapi.service.YouTubeServiceManager; import io.reactivex.Observable; import io.reactivex.disposables.Disposable; import java.util.ArrayList; import java.util.List; public class StreamReminderService implements TickleListener { private static final String TAG = StreamReminderService.class.getSimpleName(); private static StreamReminderService sInstance; private final MediaItemService mMediaItemService; private final Context mContext; private final GeneralData mGeneralData; private Disposable mReminderAction; private StreamReminderService(Context context) { ServiceManager service = YouTubeServiceManager.instance(); mMediaItemService = service.getMediaItemService(); mContext = context.getApplicationContext(); mGeneralData = GeneralData.instance(context); } public static StreamReminderService instance(Context context) { if (sInstance == null) { sInstance = new StreamReminderService(context); } return sInstance; } public boolean isReminderSet(Video video) { return mGeneralData.containsPendingStream(video); } public void toggleReminder(Video video) { if (video.videoId == null || !video.isUpcoming) { return; } if (mGeneralData.containsPendingStream(video)) { mGeneralData.removePendingStream(video); } else { mGeneralData.addPendingStream(video); } start(); } public void start() { if (mGeneralData.getPendingStreams().isEmpty()) { TickleManager.instance().removeListener(this); sInstance = null; } else { TickleManager.instance().addListener(this); } } @Override public void onTickle() { if (mGeneralData.getPendingStreams().isEmpty()) { start(); return; } RxHelper.disposeActions(mReminderAction); List> observables = toObservables(); mReminderAction = Observable.mergeDelayError(observables) .subscribe( this::processMetadata, error -> Log.e(TAG, "loadMetadata error: %s", error.getMessage()) ); } private void processMetadata(MediaItemFormatInfo formatInfo) { String videoId = formatInfo.getVideoId(); if (formatInfo.containsMedia() && videoId != null) { Video video = new Video(); video.title = formatInfo.getTitle(); video.videoId = videoId; video.isPending = true; Playlist playlist = Playlist.instance(); Video current = playlist.getCurrent(); if (current != null && current.isPending && ViewManager.instance(mContext).isPlayerInForeground()) { playlist.add(video); } else { ViewManager.instance(mContext).movePlayerToForeground(); PlaybackPresenter.instance(mContext).openVideo(video); MessageHelpers.showLongMessage(mContext, R.string.starting_stream); } mGeneralData.removePendingStream(video); start(); } } /** * NOTE: don't use MediaItemMetadata because it has contains isLive and isUpcoming flags */ private List> toObservables() { List> result = new ArrayList<>(); for (Video item : mGeneralData.getPendingStreams()) { result.add(mMediaItemService.getFormatInfoObserve(item.videoId)); } return result; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/TickleManager.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import com.liskovsoft.sharedutils.misc.WeakHashSet; public class TickleManager { private static final String TAG = TickleManager.class.getSimpleName(); private static TickleManager sInstance; private final Runnable mUpdateHandler = this::updateTickle; // Usually listener is a view. So use weak refs to not hold it forever. private final WeakHashSet mListeners = new WeakHashSet<>(); private boolean mIsEnabled = true; public interface TickleListener { void onTickle(); } private TickleManager() { } public static TickleManager instance() { if (sInstance == null) { sInstance = new TickleManager(); } return sInstance; } public void addListener(TickleListener listener) { if (mListeners.add(listener)) { if (mListeners.size() == 1) { // periodic callback not started yet updateTickle(); } else if (isEnabled()) { listener.onTickle(); // first run } } } public void removeListener(TickleListener listener) { mListeners.remove(listener); } public void setEnabled(boolean enabled) { mIsEnabled = enabled; updateTickle(); } public boolean isEnabled() { return mIsEnabled; } public void clear() { mListeners.clear(); updateTickle(); } public void runTask(Runnable task, long delayMs) { Utils.removeCallbacks(task); Utils.postDelayed(task, delayMs); } private void updateTickle() { Utils.removeCallbacks(mUpdateHandler); if (isEnabled() && !mListeners.isEmpty()) { mListeners.forEach(TickleListener::onTickle); // Align tickle by clock minutes long timeMillis = System.currentTimeMillis(); long delayMs = 60_000 - timeMillis % 60_000; Log.d(TAG, "Updating tickle in %s ms...", delayMs); Utils.postDelayed(mUpdateHandler, delayMs); } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/UnlocalizedTitleProcessor.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import android.content.Context; import android.util.Pair; import com.liskovsoft.mediaserviceinterfaces.MediaItemService; import com.liskovsoft.mediaserviceinterfaces.ServiceManager; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.mylogger.Log; import com.liskovsoft.sharedutils.rx.RxHelper; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.app.models.data.VideoGroup; import com.liskovsoft.smartyoutubetv2.common.prefs.MainUIData; import com.liskovsoft.smartyoutubetv2.common.prefs.common.DataChangeBase.OnDataChange; import com.liskovsoft.youtubeapi.service.YouTubeServiceManager; import java.util.ArrayList; import java.util.List; import io.reactivex.Observable; import io.reactivex.disposables.Disposable; public class UnlocalizedTitleProcessor implements OnDataChange, BrowseProcessor { private static final String TAG = UnlocalizedTitleProcessor.class.getSimpleName(); private final OnItemReady mOnItemReady; private final MediaItemService mItemService; private final MainUIData mMainUIData; private boolean mIsUnlocalizedTitlesEnabled; private Disposable mResult; public UnlocalizedTitleProcessor(Context context, OnItemReady onItemReady) { mOnItemReady = onItemReady; ServiceManager service = YouTubeServiceManager.instance(); mItemService = service.getMediaItemService(); mMainUIData = MainUIData.instance(context); mMainUIData.setOnChange(this); initData(); } @Override public void onDataChange() { initData(); } private void initData() { mIsUnlocalizedTitlesEnabled = mMainUIData.isUnlocalizedTitlesEnabled(); } @Override public void process(VideoGroup videoGroup) { if (!mIsUnlocalizedTitlesEnabled || videoGroup == null || videoGroup.isEmpty()) { return; } List videoIds = getVideoIds(videoGroup); mResult = Observable.fromIterable(videoIds) .flatMap(videoId -> mItemService.getUnlocalizedTitleObserve(videoId) .map(newTitle -> new Pair<>(videoId, newTitle))) .subscribe(title -> { Video video = videoGroup.findVideoById(title.first); if (video == null || Helpers.equals(video.title, title.second)) { return; } video.deArrowTitle = title.second; mOnItemReady.onItemReady(video); }, error -> { Log.d(TAG, "Unlocalized title: Cannot process the video"); }); } @Override public void dispose() { RxHelper.disposeActions(mResult); } private List getVideoIds(VideoGroup videoGroup) { List result = new ArrayList<>(); for (Video video : videoGroup.getVideos()) { if (video.deArrowProcessed) { continue; } video.deArrowProcessed = true; result.add(video.videoId); } return result; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/ZipHelper.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import com.liskovsoft.sharedutils.helpers.Helpers; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; public class ZipHelper { public static boolean zipFolder(File sourceFolder, File zipFile, String[] backupPatterns) { try (ZipOutputStream zipOut = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(zipFile)))) { zipFolderRecursive(sourceFolder, sourceFolder, zipOut, backupPatterns); return true; } catch (IOException e) { e.printStackTrace(); return false; } } private static void zipFolderRecursive(File rootFolder, File currentFile, ZipOutputStream zipOut, String[] backupPatterns) throws IOException { String entryName = rootFolder.toURI().relativize(currentFile.toURI()).getPath(); if (currentFile.isDirectory()) { if (!entryName.isEmpty()) { zipOut.putNextEntry(new ZipEntry(entryName + "/")); zipOut.closeEntry(); } File[] children = currentFile.listFiles(); if (children != null) { for (File child : children) { if (Helpers.endsWithAny(child.getName(), backupPatterns)) zipFolderRecursive(rootFolder, child, zipOut, backupPatterns); } } } else { zipOut.putNextEntry(new ZipEntry(entryName)); try (FileInputStream input = new FileInputStream(currentFile)) { byte[] buffer = new byte[1024]; int length; while ((length = input.read(buffer)) >= 0) { zipOut.write(buffer, 0, length); } } zipOut.closeEntry(); } } public static boolean unzipToFolder(File zipFile, File outputFolder) { if (!outputFolder.exists()) { outputFolder.mkdirs(); } try (ZipInputStream zipIn = new ZipInputStream(new BufferedInputStream(new FileInputStream(zipFile)))) { String canonicalOutputPath = outputFolder.getCanonicalPath() + File.separator; ZipEntry entry; while ((entry = zipIn.getNextEntry()) != null) { File filePath = new File(outputFolder, entry.getName()); String canonicalFilePath = filePath.getCanonicalPath(); // Zip Slip protection if (!canonicalFilePath.startsWith(canonicalOutputPath)) { throw new IOException("Blocked Zip Slip entry: " + entry.getName()); } if (entry.isDirectory()) { filePath.mkdirs(); } else { if (filePath.getParentFile() != null) { filePath.getParentFile().mkdirs(); } try (FileOutputStream output = new FileOutputStream(filePath)) { byte[] buffer = new byte[1024]; int length; while ((length = zipIn.read(buffer)) > 0) { output.write(buffer, 0, length); } } } zipIn.closeEntry(); } return true; } catch (IOException e) { e.printStackTrace(); return false; } } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/misc/ZipHelper2.java ================================================ package com.liskovsoft.smartyoutubetv2.common.misc; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; public class ZipHelper2 { public static void zipDirectory(File sourceDir, File zipFile) { try { ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile)); zipFileRecursive(zos, sourceDir, sourceDir.getName() + "/"); zos.close(); } catch (Exception e) { e.printStackTrace(); } } private static void zipFileRecursive(ZipOutputStream zos, File file, String base) throws Exception { if (file.isDirectory()) { File[] children = file.listFiles(); if (children != null) { for (File child : children) { zipFileRecursive(zos, child, base + child.getName() + "/"); } } } else { FileInputStream fis = new FileInputStream(file); zos.putNextEntry(new ZipEntry(base.substring(0, base.length() -1))); // strip "/" at the end to mark as file byte[] buf = new byte[8192]; int len; while ((len = fis.read(buf)) > 0) zos.write(buf, 0, len); fis.close(); zos.closeEntry(); } } public static void unzip(File zipFile, File targetRoot) { try { ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile)); String canonicalRoot = targetRoot.getCanonicalPath() + File.separator; ZipEntry entry; byte[] buffer = new byte[8192]; while ((entry = zis.getNextEntry()) != null) { File out = new File(targetRoot, entry.getName()); String canonicalOut = out.getCanonicalPath(); // Zip Slip protection if (!canonicalOut.startsWith(canonicalRoot)) { throw new IOException("Blocked Zip Slip entry: " + entry.getName()); } if (entry.isDirectory()) { out.mkdirs(); } else { out.getParentFile().mkdirs(); FileOutputStream fos = new FileOutputStream(out); int len; while ((len = zis.read(buffer)) > 0) fos.write(buffer, 0, len); fos.close(); } zis.closeEntry(); } zis.close(); } catch (Exception e) { e.printStackTrace(); } } public static boolean hasRootDir(File zipFile, String rootDir) { boolean result = false; try { ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile)); ZipEntry entry; String prefix = rootDir + "/"; while ((entry = zis.getNextEntry()) != null) { if (entry.getName().startsWith(prefix)) { zis.closeEntry(); result = true; break; } zis.closeEntry(); } zis.close(); } catch (Exception e) { e.printStackTrace(); } return result; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/AccountsData.java ================================================ package com.liskovsoft.smartyoutubetv2.common.prefs; import android.annotation.SuppressLint; import android.content.Context; import androidx.annotation.NonNull; import com.liskovsoft.mediaserviceinterfaces.oauth.Account; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.smartyoutubetv2.common.misc.MediaServiceManager; import com.liskovsoft.smartyoutubetv2.common.misc.MediaServiceManager.AccountChangeListener; import java.util.HashMap; import java.util.Map; public class AccountsData implements AccountChangeListener { private static final String ACCOUNTS_DATA = "accounts_data"; @SuppressLint("StaticFieldLeak") private static AccountsData sInstance; private final Context mContext; private final AppPrefs mAppPrefs; private boolean mIsSelectAccountOnBootEnabled; private boolean mIsPasswordAccepted; private final Map mPasswords = new HashMap<>(); private static class PasswordItem { public String accountName; public String password; public PasswordItem(String accountName, String password) { this.accountName = accountName; this.password = password; } public static PasswordItem fromString(String specs) { String[] split = Helpers.splitObj(specs); if (split == null || split.length != 2) { return new PasswordItem(null, null); } return new PasswordItem(Helpers.parseStr(split[0]), Helpers.parseStr(split[1])); } @NonNull @Override public String toString() { return Helpers.mergeObj(accountName, password); } } private AccountsData(Context context) { mContext = context; mAppPrefs = AppPrefs.instance(mContext); MediaServiceManager.instance().addAccountListener(this); restoreState(); } public static AccountsData instance(Context context) { if (sInstance == null) { sInstance = new AccountsData(context.getApplicationContext()); } return sInstance; } public void selectAccountOnBoot(boolean select) { mIsSelectAccountOnBootEnabled = select; persistState(); } public boolean isSelectAccountOnBootEnabled() { return mIsSelectAccountOnBootEnabled; } public void setAccountPassword(String password) { mPasswords.put(getAccountName(), new PasswordItem(getAccountName(), password)); persistState(); } public String getAccountPassword() { if (getAccountName() == null) { return null; } PasswordItem passwordItem = mPasswords.get(getAccountName()); return passwordItem != null ? passwordItem.password : null; } public boolean isPasswordAccepted() { return mIsPasswordAccepted || getAccountPassword() == null; } public void setPasswordAccepted(boolean accepted) { mIsPasswordAccepted = accepted; } private void restoreState() { String data = mAppPrefs.getData(ACCOUNTS_DATA); String[] split = Helpers.splitData(data); mIsSelectAccountOnBootEnabled = Helpers.parseBoolean(split, 0, false); // mIsAccountProtectedWithPassword // mAccountPassword String[] passwords = Helpers.parseArray(split, 3); if (passwords != null) { for (String passwordSpec : passwords) { PasswordItem item = PasswordItem.fromString(passwordSpec); mPasswords.put(item.accountName, item); } } } private void persistState() { mAppPrefs.setData(ACCOUNTS_DATA, Helpers.mergeData( mIsSelectAccountOnBootEnabled, null, null, Helpers.mergeArray(mPasswords.values().toArray()) )); } private String getAccountName() { Account account = MediaServiceManager.instance().getSelectedAccount(); return account != null ? account.getName() : null; } @Override public void onAccountChanged(Account account) { mIsPasswordAccepted = false; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/AppPrefs.java ================================================ package com.liskovsoft.smartyoutubetv2.common.prefs; import android.annotation.SuppressLint; import android.content.Context; import android.text.TextUtils; import com.liskovsoft.mediaserviceinterfaces.oauth.Account; import com.liskovsoft.sharedutils.misc.WeakHashSet; import com.liskovsoft.sharedutils.prefs.SharedPreferencesBase; import com.liskovsoft.smartyoutubetv2.common.R; import com.liskovsoft.smartyoutubetv2.common.app.presenters.service.SidebarService; import com.liskovsoft.smartyoutubetv2.common.misc.MediaServiceManager; import com.liskovsoft.smartyoutubetv2.common.misc.MediaServiceManager.AccountChangeListener; import java.util.HashMap; import java.util.Map; public class AppPrefs extends SharedPreferencesBase implements AccountChangeListener { private static final String TAG = AppPrefs.class.getSimpleName(); @SuppressLint("StaticFieldLeak") private static AppPrefs sInstance; private static final String ANONYMOUS_PROFILE_NAME = "anonymous"; private static final String MULTI_PROFILES = "multi_profiles"; private static final String STATE_UPDATER_DATA = "state_updater_data"; private static final String CHANNEL_GROUP_DATA = "channel_group_data"; private static final String SIDEBAR_DATA = "sidebar_data"; private static final String VIEW_MANAGER_DATA = "view_manager_data"; private static final String WEB_PROXY_URI = "web_proxy_uri"; private static final String WEB_PROXY_ENABLED = "web_proxy_enabled"; private static final String LAST_PROFILE_NAME = "last_profile_name"; private String mBootResolution; private final Map mDataHashes = new HashMap<>(); private final WeakHashSet mListeners = new WeakHashSet<>(); public interface ProfileChangeListener { void onProfileChanged(); } private AppPrefs(Context context) { super(context, R.xml.app_prefs); initProfiles(); } private void initProfiles() { MediaServiceManager.instance().addAccountListener(this); } @Override public void onAccountChanged(Account account) { selectProfile(account); onProfileChanged(); } public static AppPrefs instance(Context context) { if (sInstance == null) { sInstance = new AppPrefs(context.getApplicationContext()); } return sInstance; } public void enableMultiProfiles(boolean enabled) { if (isMultiProfilesEnabled() == enabled) { return; } putBoolean(MULTI_PROFILES, enabled); onProfileChanged(); //selectAccount(enabled ? MediaServiceManager.instance().getSelectedAccount() : null); } public boolean isMultiProfilesEnabled() { return getBoolean(MULTI_PROFILES, false); } public void setBootResolution(String resolution) { mBootResolution = resolution; } public String getBootResolution() { return mBootResolution; } public String getStateUpdaterData() { // Always use multiple profiles for the history return getData(getProfileKey(STATE_UPDATER_DATA, true)); } public void setStateUpdaterData(String data) { // Always use multiple profiles for the history setData(getProfileKey(STATE_UPDATER_DATA, true), data); } public String getChannelGroupData() { // Always use multiple profiles return getData(getProfileKey(CHANNEL_GROUP_DATA, true)); } public void setChannelGroupData(String data) { // Always use multiple profiles setData(getProfileKey(CHANNEL_GROUP_DATA, true), data); } public String getSidebarData() { // Always use multiple profiles return getData(getProfileKey(SIDEBAR_DATA, true)); } public void setSidebarData(String data) { // Always use multiple profiles setData(getProfileKey(SIDEBAR_DATA, true), data); } public void setProfileData(String key, String data) { setData(getProfileKey(key, isMultiProfilesEnabled()), data); } public String getProfileData(String key) { //String data = getData(getProfileKey(key, isMultiProfilesEnabled())); // Fallback to non-profile settings //return data != null ? data : getData(key); return getData(getProfileKey(key, isMultiProfilesEnabled())); } public void setData(String key, String data) { if (checkData(key, data)) { putString(key, data); } } public String getData(String key) { // Don't sync hash here. Hashes won't match. return getString(key, null); } public String getWebProxyUri() { return getString(WEB_PROXY_URI, ""); } public void setWebProxyUri(String uri) { putString(WEB_PROXY_URI, uri); } public boolean isWebProxyEnabled() { return getBoolean(WEB_PROXY_ENABLED, false); } public void setWebProxyEnabled(boolean enabled) { putBoolean(WEB_PROXY_ENABLED, enabled); } private void setProfileName(String profileName) { putString(LAST_PROFILE_NAME, profileName); } private String getProfileName() { return getString(LAST_PROFILE_NAME, null); } private void selectProfile(Account account) { String profileName = account != null && account.getName() != null ? account.getName().replace(" ", "_") : ANONYMOUS_PROFILE_NAME; setProfileName(profileName); } private void onProfileChanged() { mListeners.forEach(ProfileChangeListener::onProfileChanged); } public void addListener(ProfileChangeListener listener) { if (!mListeners.contains(listener)) { if (listener instanceof GeneralData) { mListeners.add(0, listener); // data classes should be called before regular listeners } else if (listener instanceof SidebarService) { mListeners.add(mListeners.isEmpty() ? 0 : 1, listener); // data classes should be called before regular listeners } else { mListeners.add(listener); } } } public void removeListener(ProfileChangeListener listener) { mListeners.remove(listener); } /** * Check that the data has been modified. */ private boolean checkData(String key, String data) { Integer oldHashCode = mDataHashes.get(key); int newHashCode = data != null ? data.hashCode() : -1; if (oldHashCode != null && oldHashCode == newHashCode) { return false; } mDataHashes.put(key, newHashCode); return true; } //private String getProfileKey(String key) { // String profileName = getProfileName(); // if (!TextUtils.isEmpty(profileName)) { // key = profileName + "_" + key; // } // // return key; //} private String getProfileKey(String key, boolean isMultiProfilesEnabled) { String profileName = getProfileName(); if (!TextUtils.isEmpty(profileName) && isMultiProfilesEnabled) { key = profileName + "_" + key; } return key; } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/BlockedChannelData.java ================================================ package com.liskovsoft.smartyoutubetv2.common.prefs; import android.annotation.SuppressLint; import android.content.Context; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.smartyoutubetv2.common.prefs.AppPrefs.ProfileChangeListener; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; public class BlockedChannelData implements ProfileChangeListener { private static final String BLOCKED_CHANNEL_DATA = "blocked_channel_data"; @SuppressLint("StaticFieldLeak") private static BlockedChannelData sInstance; private final AppPrefs mPrefs; private Set mChannelIds; private Map mChannelIdsWithNames; private final Runnable mPersistStateInt = this::persistStateInt; private final List mListeners = new ArrayList<>(); public interface BlockedChannelListener { void onChanged(); } private BlockedChannelData(Context context) { mPrefs = AppPrefs.instance(context); mPrefs.addListener(this); restoreState(); } public static BlockedChannelData instance(Context context) { if (sInstance == null) { sInstance = new BlockedChannelData(context.getApplicationContext()); } return sInstance; } /** * Add a channel to the blacklist * * @param channelId The channel ID to blacklist * @param channelName The channel name (optional, for display purposes) */ public void addChannel(String channelId, String channelName) { if (channelId == null || channelId.isEmpty()) { return; } mChannelIds.add(channelId); if (channelName != null && !channelName.isEmpty()) { mChannelIdsWithNames.put(channelId, channelName); } persistState(); notifyListeners(); } /** * Remove a channel from the blacklist * * @param channelId The channel ID to remove */ public void removeChannel(String channelId) { if (channelId == null || channelId.isEmpty()) { return; } mChannelIds.remove(channelId); mChannelIdsWithNames.remove(channelId); persistState(); notifyListeners(); } /** * Check if a channel is blacklisted * * @param channelId The channel ID to check * @return true if the channel is blacklisted */ public boolean containsChannel(String channelId) { if (channelId == null || channelId.isEmpty()) { return false; } return mChannelIds.contains(channelId); } /** * Get all blacklisted channel IDs * * @return Unmodifiable set of blacklisted channel IDs */ public Set getChannelIds() { return Collections.unmodifiableSet(mChannelIds); } /** * Get the name of a blacklisted channel * * @param channelId The channel ID * @return The channel name, or null if not available */ public String getChannelName(String channelId) { return mChannelIdsWithNames.get(channelId); } /** * Get all blacklisted channels with their names * * @return Unmodifiable map of channelId -> channel name */ public Map getChannelIdsWithNames() { return Collections.unmodifiableMap(mChannelIdsWithNames); } /** * Get the count of blacklisted channels * * @return Number of blacklisted channels */ public int getChannelCount() { return mChannelIds.size(); } /** * Clear all blacklisted channels */ public void clear() { mChannelIds.clear(); mChannelIdsWithNames.clear(); persistState(); } private synchronized void restoreState() { String data = mPrefs.getProfileData(BLOCKED_CHANNEL_DATA); String[] split = Helpers.splitData(data); List channelIdList = Helpers.parseStrList(split, 0); mChannelIdsWithNames = Helpers.parseMap(split, 1, Helpers::parseStr, Helpers::parseStr); mChannelIds = new HashSet<>(channelIdList); } public void persistNow() { Utils.post(mPersistStateInt); } private void persistState() { Utils.postDelayed(mPersistStateInt, 10_000); } private void persistStateInt() { // Convert Set to List for persistence List channelIdList = new ArrayList<>(mChannelIds); mPrefs.setProfileData(BLOCKED_CHANNEL_DATA, Helpers.mergeData( channelIdList, mChannelIdsWithNames)); } public void addListener(BlockedChannelListener listener) { if (!mListeners.contains(listener)) { mListeners.add(listener); } } public void removeListener(BlockedChannelListener listener) { mListeners.remove(listener); } private void notifyListeners() { for (BlockedChannelListener listener : mListeners) { listener.onChanged(); } } @Override public void onProfileChanged() { restoreState(); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/DeArrowData.java ================================================ package com.liskovsoft.smartyoutubetv2.common.prefs; import android.content.Context; import com.liskovsoft.smartyoutubetv2.common.prefs.common.DataSaverBase; public class DeArrowData extends DataSaverBase { private static DeArrowData sInstance; private DeArrowData(Context context) { super(context); } public static DeArrowData instance(Context context) { if (sInstance == null) { sInstance = new DeArrowData(context); } return sInstance; } public boolean isDeArrowEnabled() { return getBoolean(0); } public void setDeArrowEnabled(boolean enable) { setBoolean(0, enable); } public boolean isReplaceTitlesEnabled() { return getBoolean(1); } public void setReplaceTitlesEnabled(boolean enable) { setBoolean(1, enable); } public boolean isReplaceThumbnailsEnabled() { return getBoolean(2); } public void setReplaceThumbnailsEnabled(boolean replace) { setBoolean(2, replace); } } ================================================ FILE: common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/GeneralData.java ================================================ package com.liskovsoft.smartyoutubetv2.common.prefs; import android.annotation.SuppressLint; import android.content.Context; import com.liskovsoft.sharedutils.helpers.Helpers; import com.liskovsoft.sharedutils.prefs.GlobalPreferences; import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; import com.liskovsoft.smartyoutubetv2.common.prefs.AppPrefs.ProfileChangeListener; import com.liskovsoft.smartyoutubetv2.common.utils.Utils; import java.util.Collections; import java.util.List; import java.util.Map; public class GeneralData implements ProfileChangeListener { public static final int SCREENSAVER_TIMEOUT_NEVER = 0; private static final String GENERAL_DATA = "general_data"; public static final int EXIT_NONE = 0; public static final int EXIT_DOUBLE_BACK = 1; public static final int EXIT_SINGLE_BACK = 2; public static final int BACKGROUND_PLAYBACK_SHORTCUT_HOME = 0; public static final int BACKGROUND_PLAYBACK_SHORTCUT_HOME_BACK = 1; public static final int BACKGROUND_PLAYBACK_SHORTCUT_BACK = 2; public static final int HISTORY_AUTO = 0; public static final int HISTORY_ENABLED = 1; public static final int HISTORY_DISABLED = 2; @SuppressLint("StaticFieldLeak") private static GeneralData sInstance; private final Context mContext; private final AppPrefs mPrefs; private int mAppExitShortcut; private int mPlayerExitShortcut; private int mSearchExitShortcut; private boolean mIsReturnToLauncherEnabled; private int mBackgroundShortcut; private boolean mIsHideShortsFromSubscriptionsEnabled; private boolean mIsHideUpcomingEnabled; private boolean mIsRemapFastForwardToNextEnabled; private int mScreensaverTimeoutMs; private int mScreensaverDimmingPercents; private boolean mIsProxyEnabled; private boolean mIsBridgeCheckEnabled; private boolean mIsOkButtonLongPressDisabled; private String mLastPlaylistId; private String mLastPlaylistTitle; private boolean mIsRemapPageUpToNextEnabled; private boolean mIsRemapPageUpToLikeEnabled; private boolean mIsRemapChannelUpToNextEnabled; private boolean mIsRemapChannelUpToLikeEnabled; private boolean mIsRemapChannelUpToVolumeEnabled; private boolean mIsRemapPageUpToSpeedEnabled; private boolean mIsRemapPageDownToSpeedEnabled; private boolean mIsRemapChannelUpToSpeedEnabled; private boolean mIsRemapFastForwardToSpeedEnabled; private boolean mIsRemapFastForwardToSpeedToggleEnabled; private boolean mIsRemapNextToFastForwardEnabled; private boolean mIsRemapNextToSpeedEnabled; private boolean mIsRemapNumbersToSpeedEnabled; private boolean mIsRemapPlayToOKEnabled; private boolean mIsRemapChannelUpToSearchEnabled; private boolean mIsHideShortsFromHomeEnabled; private boolean mIsHideShortsFromHistoryEnabled; private boolean mIsScreensaverDisabled; private boolean mIsVPNEnabled; private boolean mIsGlobalClockEnabled; private String mSettingsPassword; private String mMasterPassword; private boolean mIsChildModeEnabled; private boolean mIsHistoryEnabled; private int mHistoryState; private boolean mIsAltAppIconEnabled; private int mVersionCode; private boolean mIsSelectChannelSectionEnabled; private boolean mIsOldUpdateNotificationsEnabled; private boolean mIsRememberSubscriptionsPositionEnabled; private boolean mIsRememberPinnedPositionEnabled; private boolean mIsRemapDpadUpToSpeedEnabled; private boolean mIsRemapDpadUpToVolumeEnabled; private boolean mIsRemapDpadLeftToVolumeEnabled; private boolean mIsHideWatchedFromNotificationsEnabled; private List mChangelog; private Map mPlaylistOrder; private List