Repository: Android-Builds/barinsta Branch: master Commit: 52d53c5ad180 Files: 1055 Total size: 4.2 MB Directory structure: gitextract_v9zl8srd/ ├── .all-contributorsrc ├── .codebeatsettings ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── ban_report.md │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── issue_label_bot.yaml │ └── workflows/ │ ├── github_nightly_release.yml │ ├── github_pre_release.yml │ ├── label-bugs.yml │ └── label-duplicates.yml ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── .name │ ├── codeStyles │ ├── compiler.xml │ ├── gradle.xml │ ├── inspectionProfiles/ │ │ └── profiles_settings.xml │ ├── jarRepositories.xml │ ├── misc.xml │ ├── render.experimental.xml │ ├── runConfigurations/ │ │ └── app.xml │ ├── runConfigurations.xml │ └── vcs.xml ├── .project ├── .settings/ │ └── org.eclipse.buildship.core.prefs ├── CHANGELOG ├── LICENSE ├── README.md ├── SECURITY.md ├── app/ │ ├── .classpath │ ├── .gitignore │ ├── .project │ ├── .settings/ │ │ └── org.eclipse.buildship.core.prefs │ ├── build.gradle │ ├── lint.xml │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── awais.instagrabber.db.AppDatabase/ │ │ ├── 4.json │ │ ├── 5.json │ │ └── 6.json │ ├── sentry.gradle │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ ├── awais/ │ │ │ └── instagrabber/ │ │ │ └── db/ │ │ │ ├── MigrationTest.java │ │ │ └── dao/ │ │ │ └── RecentSearchDaoTest.kt │ │ └── awaisomereport/ │ │ └── CrashReporterHelperTest.kt │ ├── fdroid/ │ │ └── java/ │ │ ├── awais/ │ │ │ └── instagrabber/ │ │ │ ├── fragments/ │ │ │ │ └── settings/ │ │ │ │ └── FlavorSettings.java │ │ │ └── utils/ │ │ │ └── UpdateChecker.java │ │ └── awaisomereport/ │ │ └── CrashHandler.kt │ ├── github/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ ├── awais/ │ │ │ │ └── instagrabber/ │ │ │ │ ├── fragments/ │ │ │ │ │ └── settings/ │ │ │ │ │ └── FlavorSettings.java │ │ │ │ └── utils/ │ │ │ │ └── UpdateChecker.java │ │ │ └── awaisomereport/ │ │ │ └── CrashHandler.kt │ │ └── res/ │ │ ├── values/ │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ ├── values-cs/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-el/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-eu/ │ │ │ └── strings.xml │ │ ├── values-fa/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-hi/ │ │ │ └── strings.xml │ │ ├── values-in/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-ja/ │ │ │ └── strings.xml │ │ ├── values-ko/ │ │ │ └── strings.xml │ │ ├── values-mk/ │ │ │ └── strings.xml │ │ ├── values-nl/ │ │ │ └── strings.xml │ │ ├── values-or/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sk/ │ │ │ └── strings.xml │ │ ├── values-sv/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ └── values-zh-rTW/ │ │ └── strings.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ ├── awais/ │ │ │ │ └── instagrabber/ │ │ │ │ ├── InstaGrabberApplication.kt │ │ │ │ ├── activities/ │ │ │ │ │ ├── BaseLanguageActivity.kt │ │ │ │ │ ├── CameraActivity.kt │ │ │ │ │ ├── DirectorySelectActivity.kt │ │ │ │ │ ├── Login.kt │ │ │ │ │ └── MainActivity.kt │ │ │ │ ├── adapters/ │ │ │ │ │ ├── AccountSwitcherAdapter.java │ │ │ │ │ ├── CommentsAdapter.java │ │ │ │ │ ├── DirectItemsAdapter.java │ │ │ │ │ ├── DirectMessageInboxAdapter.java │ │ │ │ │ ├── DirectPendingUsersAdapter.java │ │ │ │ │ ├── DirectReactionsAdapter.java │ │ │ │ │ ├── DirectUsersAdapter.java │ │ │ │ │ ├── DirectoryFilesAdapter.java │ │ │ │ │ ├── DiscoverTopicsAdapter.java │ │ │ │ │ ├── FavoritesAdapter.java │ │ │ │ │ ├── FeedAdapterV2.java │ │ │ │ │ ├── FeedItemCallbackAdapter.java │ │ │ │ │ ├── FeedStoriesAdapter.java │ │ │ │ │ ├── FeedStoriesListAdapter.java │ │ │ │ │ ├── FiltersAdapter.java │ │ │ │ │ ├── FollowAdapter.java │ │ │ │ │ ├── GifItemsAdapter.java │ │ │ │ │ ├── HighlightStoriesListAdapter.java │ │ │ │ │ ├── HighlightsAdapter.java │ │ │ │ │ ├── KeywordsFilterAdapter.java │ │ │ │ │ ├── LikesAdapter.java │ │ │ │ │ ├── NotificationsAdapter.java │ │ │ │ │ ├── SavedCollectionsAdapter.java │ │ │ │ │ ├── SearchCategoryAdapter.java │ │ │ │ │ ├── SearchItemsAdapter.java │ │ │ │ │ ├── SliderCallbackAdapter.java │ │ │ │ │ ├── SliderItemsAdapter.java │ │ │ │ │ ├── StoriesAdapter.java │ │ │ │ │ ├── TabsAdapter.java │ │ │ │ │ ├── UserSearchResultsAdapter.java │ │ │ │ │ └── viewholder/ │ │ │ │ │ ├── CommentViewHolder.java │ │ │ │ │ ├── DiscoverViewHolder.java │ │ │ │ │ ├── FavoriteViewHolder.java │ │ │ │ │ ├── FeedGridItemViewHolder.java │ │ │ │ │ ├── FeedStoryViewHolder.java │ │ │ │ │ ├── FilterViewHolder.java │ │ │ │ │ ├── FollowsViewHolder.java │ │ │ │ │ ├── HighlightViewHolder.java │ │ │ │ │ ├── NotificationViewHolder.java │ │ │ │ │ ├── SearchItemViewHolder.java │ │ │ │ │ ├── SliderItemViewHolder.java │ │ │ │ │ ├── SliderPhotoViewHolder.java │ │ │ │ │ ├── SliderVideoViewHolder.java │ │ │ │ │ ├── StoryListViewHolder.java │ │ │ │ │ ├── TabViewHolder.java │ │ │ │ │ ├── TopicClusterViewHolder.java │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ └── KeywordsFilterDialogViewHolder.java │ │ │ │ │ ├── directmessages/ │ │ │ │ │ │ ├── DirectInboxItemViewHolder.java │ │ │ │ │ │ ├── DirectItemActionLogViewHolder.java │ │ │ │ │ │ ├── DirectItemAnimatedMediaViewHolder.java │ │ │ │ │ │ ├── DirectItemDefaultViewHolder.java │ │ │ │ │ │ ├── DirectItemLikeViewHolder.java │ │ │ │ │ │ ├── DirectItemLinkViewHolder.java │ │ │ │ │ │ ├── DirectItemMediaShareViewHolder.java │ │ │ │ │ │ ├── DirectItemMediaViewHolder.java │ │ │ │ │ │ ├── DirectItemPlaceholderViewHolder.java │ │ │ │ │ │ ├── DirectItemProfileViewHolder.java │ │ │ │ │ │ ├── DirectItemRavenMediaViewHolder.java │ │ │ │ │ │ ├── DirectItemReelShareViewHolder.java │ │ │ │ │ │ ├── DirectItemStoryShareViewHolder.java │ │ │ │ │ │ ├── DirectItemTextViewHolder.java │ │ │ │ │ │ ├── DirectItemVideoCallEventViewHolder.java │ │ │ │ │ │ ├── DirectItemViewHolder.java │ │ │ │ │ │ ├── DirectItemVoiceMediaViewHolder.java │ │ │ │ │ │ ├── DirectItemXmaViewHolder.java │ │ │ │ │ │ ├── DirectPendingUserViewHolder.java │ │ │ │ │ │ ├── DirectReactionViewHolder.java │ │ │ │ │ │ ├── DirectUserViewHolder.java │ │ │ │ │ │ └── RecipientThreadViewHolder.java │ │ │ │ │ └── feed/ │ │ │ │ │ ├── FeedItemViewHolder.java │ │ │ │ │ ├── FeedPhotoViewHolder.java │ │ │ │ │ ├── FeedSliderViewHolder.java │ │ │ │ │ └── FeedVideoViewHolder.java │ │ │ │ ├── animations/ │ │ │ │ │ ├── CubicBezierInterpolator.java │ │ │ │ │ ├── FabAnimation.java │ │ │ │ │ ├── ResizeAnimation.java │ │ │ │ │ ├── RevealOutlineAnimation.java │ │ │ │ │ ├── RoundedRectRevealOutlineProvider.java │ │ │ │ │ └── ScaleAnimation.java │ │ │ │ ├── asyncs/ │ │ │ │ │ ├── DiscoverPostFetchService.java │ │ │ │ │ ├── FeedPostFetchService.java │ │ │ │ │ ├── HashtagPostFetchService.java │ │ │ │ │ ├── LocationPostFetchService.java │ │ │ │ │ ├── ProfilePostFetchService.java │ │ │ │ │ └── SavedPostFetchService.java │ │ │ │ ├── backup/ │ │ │ │ │ └── BarinstaBackupAgent.kt │ │ │ │ ├── broadcasts/ │ │ │ │ │ └── DMRefreshBroadcastReceiver.java │ │ │ │ ├── customviews/ │ │ │ │ │ ├── BarinstaFragmentNavigator.kt │ │ │ │ │ ├── BarinstaNavHostFragment.kt │ │ │ │ │ ├── ChatMessageLayout.java │ │ │ │ │ ├── CircularImageView.java │ │ │ │ │ ├── CommentMentionClickSpan.java │ │ │ │ │ ├── DirectItemContextMenu.java │ │ │ │ │ ├── DirectItemFrameLayout.java │ │ │ │ │ ├── FixedImageView.java │ │ │ │ │ ├── FormattedNumberTextView.java │ │ │ │ │ ├── InsetsAnimationLinearLayout.java │ │ │ │ │ ├── InsetsNotifyingCoordinatorLayout.java │ │ │ │ │ ├── InsetsNotifyingLinearLayout.java │ │ │ │ │ ├── KeyNotifyingEmojiEditText.java │ │ │ │ │ ├── MouseDrawer.java │ │ │ │ │ ├── PostsRecyclerView.java │ │ │ │ │ ├── PrimaryActionModeCallback.java │ │ │ │ │ ├── ProfilePicView.java │ │ │ │ │ ├── RamboTextViewV2.java │ │ │ │ │ ├── ReactionEmojiTextView.java │ │ │ │ │ ├── RecordButton.java │ │ │ │ │ ├── RecordView.java │ │ │ │ │ ├── SharedElementTransitionDialogFragment.java │ │ │ │ │ ├── SquareImageView.java │ │ │ │ │ ├── TextViewDrawableSize.java │ │ │ │ │ ├── Tooltip.java │ │ │ │ │ ├── UsernameTextView.java │ │ │ │ │ ├── VerticalDragHelper.java │ │ │ │ │ ├── VerticalImageSpan.java │ │ │ │ │ ├── VideoPlayerCallbackAdapter.java │ │ │ │ │ ├── VideoPlayerViewHelper.java │ │ │ │ │ ├── drawee/ │ │ │ │ │ │ ├── AbstractAnimatedZoomableController.java │ │ │ │ │ │ ├── AnimatedZoomableController.java │ │ │ │ │ │ ├── DefaultZoomableController.java │ │ │ │ │ │ ├── DoubleTapGestureListener.java │ │ │ │ │ │ ├── DraggableZoomableDraweeView.java │ │ │ │ │ │ ├── GestureListenerWrapper.java │ │ │ │ │ │ ├── MultiGestureListener.java │ │ │ │ │ │ ├── MultiPointerGestureDetector.java │ │ │ │ │ │ ├── MultiZoomableControllerListener.java │ │ │ │ │ │ ├── TransformGestureDetector.java │ │ │ │ │ │ ├── ZoomableController.java │ │ │ │ │ │ └── ZoomableDraweeView.java │ │ │ │ │ ├── emoji/ │ │ │ │ │ │ ├── Emoji.java │ │ │ │ │ │ ├── EmojiBottomSheetDialog.java │ │ │ │ │ │ ├── EmojiCategory.java │ │ │ │ │ │ ├── EmojiCategoryPageViewHolder.java │ │ │ │ │ │ ├── EmojiCategoryType.java │ │ │ │ │ │ ├── EmojiGridAdapter.java │ │ │ │ │ │ ├── EmojiPicker.java │ │ │ │ │ │ ├── EmojiPickerPageAdapter.java │ │ │ │ │ │ ├── EmojiVariantManager.java │ │ │ │ │ │ ├── EmojiVariantPopup.java │ │ │ │ │ │ ├── GoogleCompatEmojiDrawable.java │ │ │ │ │ │ └── ReactionsManager.java │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── ChangeText.java │ │ │ │ │ │ ├── ControlFocusInsetsAnimationCallback.java │ │ │ │ │ │ ├── CustomHideBottomViewOnScrollBehavior.java │ │ │ │ │ │ ├── EmojiPickerInsetsAnimationCallback.java │ │ │ │ │ │ ├── GridAutofitLayoutManager.java │ │ │ │ │ │ ├── GridSpacingItemDecoration.java │ │ │ │ │ │ ├── HeaderItemDecoration.java │ │ │ │ │ │ ├── HeightProvider.java │ │ │ │ │ │ ├── ImageResizingControllerListener.java │ │ │ │ │ │ ├── NestedCoordinatorLayout.java │ │ │ │ │ │ ├── NestedScrollableHost.java │ │ │ │ │ │ ├── PostFetcher.java │ │ │ │ │ │ ├── RecordViewAnimationHelper.java │ │ │ │ │ │ ├── RecyclerLazyLoader.java │ │ │ │ │ │ ├── RecyclerLazyLoaderAtEdge.java │ │ │ │ │ │ ├── RootViewDeferringInsetsCallback.java │ │ │ │ │ │ ├── SimpleImeAnimationController.java │ │ │ │ │ │ ├── SwipeAndRestoreItemTouchHelperCallback.java │ │ │ │ │ │ ├── SwipeGestureListener.java │ │ │ │ │ │ ├── TextWatcherAdapter.java │ │ │ │ │ │ ├── TranslateDeferringInsetsAnimationCallback.java │ │ │ │ │ │ ├── VerticalSpaceItemDecoration.java │ │ │ │ │ │ └── VideoAwareRecyclerScroller.java │ │ │ │ │ └── masoudss_waveform/ │ │ │ │ │ ├── SoundParser.java │ │ │ │ │ ├── WaveFormProgressChangeListener.java │ │ │ │ │ ├── WaveGravity.java │ │ │ │ │ └── WaveformSeekBar.java │ │ │ │ ├── db/ │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ ├── Converters.kt │ │ │ │ │ ├── dao/ │ │ │ │ │ │ ├── AccountDao.kt │ │ │ │ │ │ ├── DMLastNotifiedDao.kt │ │ │ │ │ │ ├── FavoriteDao.kt │ │ │ │ │ │ └── RecentSearchDao.kt │ │ │ │ │ ├── datasources/ │ │ │ │ │ │ ├── AccountDataSource.kt │ │ │ │ │ │ ├── DMLastNotifiedDataSource.kt │ │ │ │ │ │ ├── FavoriteDataSource.kt │ │ │ │ │ │ └── RecentSearchDataSource.kt │ │ │ │ │ ├── entities/ │ │ │ │ │ │ ├── Account.kt │ │ │ │ │ │ ├── DMLastNotified.kt │ │ │ │ │ │ ├── Favorite.kt │ │ │ │ │ │ └── RecentSearch.kt │ │ │ │ │ └── repositories/ │ │ │ │ │ ├── AccountRepository.kt │ │ │ │ │ ├── DMLastNotifiedRepository.kt │ │ │ │ │ ├── FavoriteRepository.kt │ │ │ │ │ └── RecentSearchRepository.kt │ │ │ │ ├── dialogs/ │ │ │ │ │ ├── AccountSwitcherDialogFragment.java │ │ │ │ │ ├── ConfirmDialogFragment.java │ │ │ │ │ ├── CreateBackupDialogFragment.java │ │ │ │ │ ├── DirectItemReactionDialogFragment.java │ │ │ │ │ ├── EditTextDialogFragment.java │ │ │ │ │ ├── GifPickerBottomDialogFragment.java │ │ │ │ │ ├── KeywordsFilterDialog.java │ │ │ │ │ ├── MultiOptionDialogFragment.java │ │ │ │ │ ├── PostLoadingDialogFragment.kt │ │ │ │ │ ├── PostsLayoutPreferencesDialogFragment.kt │ │ │ │ │ ├── ProfilePicDialogFragment.java │ │ │ │ │ ├── RestoreBackupDialogFragment.java │ │ │ │ │ ├── TabOrderPreferenceDialogFragment.java │ │ │ │ │ └── TimeSettingsDialog.java │ │ │ │ ├── fragments/ │ │ │ │ │ ├── CollectionPostsFragment.java │ │ │ │ │ ├── FavoritesFragment.kt │ │ │ │ │ ├── FollowViewerFragment.kt │ │ │ │ │ ├── HashTagFragment.java │ │ │ │ │ ├── LikesViewerFragment.java │ │ │ │ │ ├── LocationFragment.java │ │ │ │ │ ├── NotificationsViewerFragment.java │ │ │ │ │ ├── PostViewV2Fragment.java │ │ │ │ │ ├── SavedCollectionsFragment.java │ │ │ │ │ ├── SavedViewerFragment.java │ │ │ │ │ ├── StoryListViewerFragment.java │ │ │ │ │ ├── StoryViewerFragment.kt │ │ │ │ │ ├── UserSearchFragment.kt │ │ │ │ │ ├── UserSearchMode.kt │ │ │ │ │ ├── comments/ │ │ │ │ │ │ ├── CommentsViewerFragment.java │ │ │ │ │ │ ├── Helper.java │ │ │ │ │ │ └── RepliesFragment.java │ │ │ │ │ ├── directmessages/ │ │ │ │ │ │ ├── DirectMessageInboxFragment.kt │ │ │ │ │ │ ├── DirectMessageSettingsFragment.kt │ │ │ │ │ │ ├── DirectMessageThreadFragment.java │ │ │ │ │ │ └── DirectPendingInboxFragment.kt │ │ │ │ │ ├── imageedit/ │ │ │ │ │ │ ├── FiltersFragment.java │ │ │ │ │ │ ├── ImageEditFragment.java │ │ │ │ │ │ └── filters/ │ │ │ │ │ │ ├── FiltersHelper.java │ │ │ │ │ │ ├── custom/ │ │ │ │ │ │ │ ├── GPUImage1977Filter.java │ │ │ │ │ │ │ ├── GPUImageAdenFilter.java │ │ │ │ │ │ │ └── GPUImageClarendonFilter.java │ │ │ │ │ │ ├── filters/ │ │ │ │ │ │ │ ├── AdenFilter.java │ │ │ │ │ │ │ ├── BilateralBlurFilter.java │ │ │ │ │ │ │ ├── BoxBlurFilter.java │ │ │ │ │ │ │ ├── BrightnessFilter.java │ │ │ │ │ │ │ ├── ClarendonFilter.java │ │ │ │ │ │ │ ├── ContrastFilter.java │ │ │ │ │ │ │ ├── ExposureFilter.java │ │ │ │ │ │ │ ├── Filter.java │ │ │ │ │ │ │ ├── FilterFactory.java │ │ │ │ │ │ │ ├── NormalFilter.java │ │ │ │ │ │ │ ├── One977Filter.java │ │ │ │ │ │ │ ├── SaturationFilter.java │ │ │ │ │ │ │ ├── SepiaToneFilter.java │ │ │ │ │ │ │ ├── SharpenFilter.java │ │ │ │ │ │ │ ├── VibranceFilter.java │ │ │ │ │ │ │ └── VignetteFilter.java │ │ │ │ │ │ └── properties/ │ │ │ │ │ │ ├── ColorProperty.java │ │ │ │ │ │ ├── FloatProperty.java │ │ │ │ │ │ ├── PointFProperty.java │ │ │ │ │ │ └── Property.java │ │ │ │ │ ├── main/ │ │ │ │ │ │ ├── DiscoverFragment.java │ │ │ │ │ │ ├── FeedFragment.java │ │ │ │ │ │ └── ProfileFragment.kt │ │ │ │ │ ├── search/ │ │ │ │ │ │ ├── SearchCategoryFragment.java │ │ │ │ │ │ └── SearchFragment.java │ │ │ │ │ └── settings/ │ │ │ │ │ ├── AboutFragment.java │ │ │ │ │ ├── BackupPreferencesFragment.java │ │ │ │ │ ├── BasePreferencesFragment.java │ │ │ │ │ ├── DMPreferencesFragment.java │ │ │ │ │ ├── DownloadsPreferencesFragment.java │ │ │ │ │ ├── GeneralPreferencesFragment.java │ │ │ │ │ ├── IFlavorSettings.java │ │ │ │ │ ├── LocalePreferencesFragment.java │ │ │ │ │ ├── MorePreferencesFragment.java │ │ │ │ │ ├── NotificationsPreferencesFragment.java │ │ │ │ │ ├── PostPreferencesFragment.java │ │ │ │ │ ├── PreferenceHelper.java │ │ │ │ │ ├── PreferenceKeys.kt │ │ │ │ │ ├── SettingCategory.java │ │ │ │ │ ├── SettingsPreferencesFragment.java │ │ │ │ │ ├── StoriesPreferencesFragment.java │ │ │ │ │ └── ThemePreferencesFragment.java │ │ │ │ ├── interfaces/ │ │ │ │ │ ├── FetchListener.java │ │ │ │ │ ├── LazyLoadListener.java │ │ │ │ │ ├── OnGroupClickListener.java │ │ │ │ │ └── SwipeEvent.java │ │ │ │ ├── managers/ │ │ │ │ │ ├── DirectMessagesManager.kt │ │ │ │ │ ├── InboxManager.kt │ │ │ │ │ └── ThreadManager.kt │ │ │ │ ├── models/ │ │ │ │ │ ├── Comment.kt │ │ │ │ │ ├── IntentModel.kt │ │ │ │ │ ├── PostsLayoutPreferences.java │ │ │ │ │ ├── Resource.kt │ │ │ │ │ ├── SavedImageEditState.kt │ │ │ │ │ ├── Tab.kt │ │ │ │ │ ├── UploadPhotoOptions.kt │ │ │ │ │ ├── UploadVideoOptions.kt │ │ │ │ │ └── enums/ │ │ │ │ │ ├── BroadcastItemType.kt │ │ │ │ │ ├── DirectItemType.kt │ │ │ │ │ ├── FavoriteType.kt │ │ │ │ │ ├── FollowingType.kt │ │ │ │ │ ├── IntentModelType.kt │ │ │ │ │ ├── MediaItemType.kt │ │ │ │ │ ├── NotificationType.kt │ │ │ │ │ ├── PostItemType.kt │ │ │ │ │ ├── RavenMediaViewMode.kt │ │ │ │ │ └── StoryPaginationType.kt │ │ │ │ ├── repositories/ │ │ │ │ │ ├── CollectionRepository.java │ │ │ │ │ ├── CommentRepository.java │ │ │ │ │ ├── DirectMessagesService.kt │ │ │ │ │ ├── DiscoverRepository.java │ │ │ │ │ ├── FeedRepository.java │ │ │ │ │ ├── FriendshipService.kt │ │ │ │ │ ├── GifRepository.java │ │ │ │ │ ├── GraphQLService.kt │ │ │ │ │ ├── LocationRepository.java │ │ │ │ │ ├── MediaService.kt │ │ │ │ │ ├── NewsRepository.java │ │ │ │ │ ├── ProfileService.kt │ │ │ │ │ ├── SearchService.kt │ │ │ │ │ ├── StoriesService.kt │ │ │ │ │ ├── TagsRepository.java │ │ │ │ │ ├── UserService.kt │ │ │ │ │ ├── requests/ │ │ │ │ │ │ ├── StoryViewerOptions.java │ │ │ │ │ │ ├── UploadFinishOptions.kt │ │ │ │ │ │ └── directmessages/ │ │ │ │ │ │ ├── AnimatedMediaBroadcastOptions.kt │ │ │ │ │ │ ├── BroadcastOptions.kt │ │ │ │ │ │ ├── LinkBroadcastOptions.kt │ │ │ │ │ │ ├── MediaShareBroadcastOptions.kt │ │ │ │ │ │ ├── PhotoBroadcastOptions.kt │ │ │ │ │ │ ├── ProfileBroadcastOptions.kt │ │ │ │ │ │ ├── ReactionBroadcastOptions.kt │ │ │ │ │ │ ├── StoryBroadcastOptions.kt │ │ │ │ │ │ ├── StoryReplyBroadcastOptions.kt │ │ │ │ │ │ ├── TextBroadcastOptions.kt │ │ │ │ │ │ ├── ThreadIdsOrUserIds.kt │ │ │ │ │ │ ├── VideoBroadcastOptions.kt │ │ │ │ │ │ └── VoiceBroadcastOptions.kt │ │ │ │ │ ├── responses/ │ │ │ │ │ │ ├── AnimatedMediaFixedHeight.kt │ │ │ │ │ │ ├── AnimatedMediaImages.kt │ │ │ │ │ │ ├── Audio.kt │ │ │ │ │ │ ├── AymlResponse.kt │ │ │ │ │ │ ├── Caption.kt │ │ │ │ │ │ ├── ChildCommentsFetchResponse.kt │ │ │ │ │ │ ├── CommentsFetchResponse.kt │ │ │ │ │ │ ├── FriendshipChangeResponse.kt │ │ │ │ │ │ ├── FriendshipListFetchResponse.kt │ │ │ │ │ │ ├── FriendshipRestrictResponse.kt │ │ │ │ │ │ ├── FriendshipStatus.kt │ │ │ │ │ │ ├── GraphQLUserListFetchResponse.java │ │ │ │ │ │ ├── Hashtag.kt │ │ │ │ │ │ ├── ImageUrl.kt │ │ │ │ │ │ ├── ImageVersions2.kt │ │ │ │ │ │ ├── LikersResponse.kt │ │ │ │ │ │ ├── Location.java │ │ │ │ │ │ ├── LocationFeedResponse.kt │ │ │ │ │ │ ├── Media.kt │ │ │ │ │ │ ├── MediaCandidate.kt │ │ │ │ │ │ ├── MediaInfoResponse.kt │ │ │ │ │ │ ├── NewsInboxResponse.kt │ │ │ │ │ │ ├── Place.kt │ │ │ │ │ │ ├── PostsFetchResponse.kt │ │ │ │ │ │ ├── TagFeedResponse.kt │ │ │ │ │ │ ├── User.kt │ │ │ │ │ │ ├── UserFeedResponse.kt │ │ │ │ │ │ ├── UserProfileContextLink.kt │ │ │ │ │ │ ├── UserSearchResponse.kt │ │ │ │ │ │ ├── UsertagIn.java │ │ │ │ │ │ ├── Usertags.java │ │ │ │ │ │ ├── WrappedFeedResponse.java │ │ │ │ │ │ ├── WrappedMedia.kt │ │ │ │ │ │ ├── WrappedUser.kt │ │ │ │ │ │ ├── directmessages/ │ │ │ │ │ │ │ ├── DirectBadgeCount.kt │ │ │ │ │ │ │ ├── DirectInbox.kt │ │ │ │ │ │ │ ├── DirectInboxResponse.kt │ │ │ │ │ │ │ ├── DirectItem.kt │ │ │ │ │ │ │ ├── DirectItemActionLog.kt │ │ │ │ │ │ │ ├── DirectItemAnimatedMedia.kt │ │ │ │ │ │ │ ├── DirectItemClip.kt │ │ │ │ │ │ │ ├── DirectItemEmojiReaction.kt │ │ │ │ │ │ │ ├── DirectItemFelixShare.kt │ │ │ │ │ │ │ ├── DirectItemLink.kt │ │ │ │ │ │ │ ├── DirectItemLinkContext.kt │ │ │ │ │ │ │ ├── DirectItemPlaceholder.kt │ │ │ │ │ │ │ ├── DirectItemReactions.kt │ │ │ │ │ │ │ ├── DirectItemReelShare.kt │ │ │ │ │ │ │ ├── DirectItemReelShareReactionInfo.kt │ │ │ │ │ │ │ ├── DirectItemSeenResponse.kt │ │ │ │ │ │ │ ├── DirectItemSeenResponsePayload.kt │ │ │ │ │ │ │ ├── DirectItemStoryShare.kt │ │ │ │ │ │ │ ├── DirectItemVideoCallEvent.kt │ │ │ │ │ │ │ ├── DirectItemVisualMedia.kt │ │ │ │ │ │ │ ├── DirectItemVoiceMedia.kt │ │ │ │ │ │ │ ├── DirectItemXma.kt │ │ │ │ │ │ │ ├── DirectThread.kt │ │ │ │ │ │ │ ├── DirectThreadBroadcastResponse.kt │ │ │ │ │ │ │ ├── DirectThreadBroadcastResponseMessageMetadata.kt │ │ │ │ │ │ │ ├── DirectThreadBroadcastResponsePayload.kt │ │ │ │ │ │ │ ├── DirectThreadDetailsChangeResponse.kt │ │ │ │ │ │ │ ├── DirectThreadDirectStory.kt │ │ │ │ │ │ │ ├── DirectThreadFeedResponse.kt │ │ │ │ │ │ │ ├── DirectThreadLastSeenAt.kt │ │ │ │ │ │ │ ├── DirectThreadParticipantRequestsResponse.kt │ │ │ │ │ │ │ ├── RankedRecipient.kt │ │ │ │ │ │ │ ├── RankedRecipientsResponse.kt │ │ │ │ │ │ │ ├── RavenExpiringMediaActionSummary.kt │ │ │ │ │ │ │ ├── TextRange.kt │ │ │ │ │ │ │ └── ThreadContext.kt │ │ │ │ │ │ ├── discover/ │ │ │ │ │ │ │ ├── TopicCluster.kt │ │ │ │ │ │ │ └── TopicalExploreFeedResponse.kt │ │ │ │ │ │ ├── feed/ │ │ │ │ │ │ │ ├── EndOfFeedDemarcator.java │ │ │ │ │ │ │ ├── EndOfFeedGroup.java │ │ │ │ │ │ │ ├── EndOfFeedGroupSet.java │ │ │ │ │ │ │ └── FeedFetchResponse.java │ │ │ │ │ │ ├── giphy/ │ │ │ │ │ │ │ ├── GiphyGif.java │ │ │ │ │ │ │ ├── GiphyGifImage.java │ │ │ │ │ │ │ ├── GiphyGifImages.java │ │ │ │ │ │ │ ├── GiphyGifResponse.java │ │ │ │ │ │ │ └── GiphyGifResults.java │ │ │ │ │ │ ├── notification/ │ │ │ │ │ │ │ ├── Notification.kt │ │ │ │ │ │ │ ├── NotificationArgs.java │ │ │ │ │ │ │ ├── NotificationCounts.kt │ │ │ │ │ │ │ └── NotificationImage.kt │ │ │ │ │ │ ├── saved/ │ │ │ │ │ │ │ ├── CollectionsListResponse.kt │ │ │ │ │ │ │ └── SavedCollection.kt │ │ │ │ │ │ ├── search/ │ │ │ │ │ │ │ ├── SearchItem.java │ │ │ │ │ │ │ └── SearchResponse.kt │ │ │ │ │ │ └── stories/ │ │ │ │ │ │ ├── ArchiveResponse.kt │ │ │ │ │ │ ├── Broadcast.kt │ │ │ │ │ │ ├── CoverMedia.kt │ │ │ │ │ │ ├── PollSticker.kt │ │ │ │ │ │ ├── QuestionSticker.kt │ │ │ │ │ │ ├── QuizSticker.kt │ │ │ │ │ │ ├── ReelsMediaResponse.kt │ │ │ │ │ │ ├── ReelsResponse.kt │ │ │ │ │ │ ├── ReelsTrayResponse.kt │ │ │ │ │ │ ├── SliderSticker.kt │ │ │ │ │ │ ├── Story.kt │ │ │ │ │ │ ├── StoryAppAttribution.kt │ │ │ │ │ │ ├── StoryCta.kt │ │ │ │ │ │ ├── StoryMedia.kt │ │ │ │ │ │ ├── StoryMediaResponse.kt │ │ │ │ │ │ ├── StorySticker.kt │ │ │ │ │ │ ├── StoryStickerResponse.kt │ │ │ │ │ │ └── Tally.kt │ │ │ │ │ └── serializers/ │ │ │ │ │ └── CaptionDeserializer.java │ │ │ │ ├── services/ │ │ │ │ │ ├── ActivityCheckerService.java │ │ │ │ │ ├── BootCompletedReceiver.java │ │ │ │ │ ├── DMSyncAlarmReceiver.java │ │ │ │ │ ├── DMSyncService.java │ │ │ │ │ └── DeleteImageIntentService.java │ │ │ │ ├── utils/ │ │ │ │ │ ├── AppExecutors.kt │ │ │ │ │ ├── BarinstaDeepLinkHelper.kt │ │ │ │ │ ├── BitmapUtils.kt │ │ │ │ │ ├── CombinedDrawable.kt │ │ │ │ │ ├── ConcurrencyHelpers.kt │ │ │ │ │ ├── Constants.kt │ │ │ │ │ ├── CookieUtils.kt │ │ │ │ │ ├── CoroutineUtils.kt │ │ │ │ │ ├── CubicInterpolation.kt │ │ │ │ │ ├── DMUtils.java │ │ │ │ │ ├── DateUtils.kt │ │ │ │ │ ├── Debouncer.java │ │ │ │ │ ├── DeepLinkParser.kt │ │ │ │ │ ├── DirectItemFactory.kt │ │ │ │ │ ├── DownloadUtils.kt │ │ │ │ │ ├── Event.kt │ │ │ │ │ ├── ExoplayerUtils.kt │ │ │ │ │ ├── ExportImportUtils.java │ │ │ │ │ ├── FlavorTown.java │ │ │ │ │ ├── IntentUtils.kt │ │ │ │ │ ├── KeywordsFilterUtils.kt │ │ │ │ │ ├── LocaleUtils.kt │ │ │ │ │ ├── MediaUploadHelper.kt │ │ │ │ │ ├── MediaUploader.kt │ │ │ │ │ ├── MediaUtils.java │ │ │ │ │ ├── NavigationExtensions.java │ │ │ │ │ ├── NavigationHelper.kt │ │ │ │ │ ├── NetworkUtils.java │ │ │ │ │ ├── NullSafePair.kt │ │ │ │ │ ├── NumberUtils.kt │ │ │ │ │ ├── PasswordUtils.kt │ │ │ │ │ ├── PermissionUtils.kt │ │ │ │ │ ├── ProcessPhoenix.java │ │ │ │ │ ├── RankedRecipientsCache.kt │ │ │ │ │ ├── ResponseBodyUtils.java │ │ │ │ │ ├── SerializablePair.kt │ │ │ │ │ ├── SettingsHelper.kt │ │ │ │ │ ├── SingleLiveEvent.kt │ │ │ │ │ ├── SingletonHolder.kt │ │ │ │ │ ├── TextUtils.kt │ │ │ │ │ ├── ThemeUtils.kt │ │ │ │ │ ├── UpdateCheckCommon.kt │ │ │ │ │ ├── UserAgentUtils.kt │ │ │ │ │ ├── Utils.java │ │ │ │ │ ├── ViewUtils.kt │ │ │ │ │ ├── VoiceRecorder.java │ │ │ │ │ ├── emoji/ │ │ │ │ │ │ ├── EmojiCategoryDeserializer.kt │ │ │ │ │ │ ├── EmojiDeserializer.kt │ │ │ │ │ │ └── EmojiParser.kt │ │ │ │ │ └── extensions/ │ │ │ │ │ ├── AnyExtensions.kt │ │ │ │ │ ├── StringExtensions.kt │ │ │ │ │ └── UserExtensions.kt │ │ │ │ ├── viewmodels/ │ │ │ │ │ ├── AppStateViewModel.java │ │ │ │ │ ├── ArchivesViewModel.java │ │ │ │ │ ├── CommentsViewerViewModel.java │ │ │ │ │ ├── DirectInboxViewModel.kt │ │ │ │ │ ├── DirectPendingInboxViewModel.kt │ │ │ │ │ ├── DirectSettingsViewModel.kt │ │ │ │ │ ├── DirectThreadViewModel.kt │ │ │ │ │ ├── DirectorySelectActivityViewModel.kt │ │ │ │ │ ├── FavoritesViewModel.kt │ │ │ │ │ ├── FeedStoriesViewModel.java │ │ │ │ │ ├── FileListViewModel.java │ │ │ │ │ ├── FiltersFragmentViewModel.java │ │ │ │ │ ├── FollowViewModel.kt │ │ │ │ │ ├── GifPickerViewModel.java │ │ │ │ │ ├── ImageEditViewModel.java │ │ │ │ │ ├── MediaViewModel.java │ │ │ │ │ ├── NotificationViewModel.java │ │ │ │ │ ├── PostViewV2ViewModel.kt │ │ │ │ │ ├── ProfileFragmentViewModel.kt │ │ │ │ │ ├── SavedCollectionsViewModel.java │ │ │ │ │ ├── SearchFragmentViewModel.kt │ │ │ │ │ ├── StoryFragmentViewModel.kt │ │ │ │ │ ├── TopicClusterViewModel.java │ │ │ │ │ ├── UserSearchViewModel.java │ │ │ │ │ └── factories/ │ │ │ │ │ ├── DirectSettingsViewModelFactory.java │ │ │ │ │ └── DirectThreadViewModelFactory.java │ │ │ │ ├── webservices/ │ │ │ │ │ ├── CollectionService.java │ │ │ │ │ ├── CommentService.java │ │ │ │ │ ├── DirectMessagesRepository.kt │ │ │ │ │ ├── DiscoverService.java │ │ │ │ │ ├── FeedService.java │ │ │ │ │ ├── FriendshipRepository.kt │ │ │ │ │ ├── GifService.java │ │ │ │ │ ├── GraphQLRepository.kt │ │ │ │ │ ├── LocationService.java │ │ │ │ │ ├── MediaRepository.kt │ │ │ │ │ ├── NewsService.java │ │ │ │ │ ├── ProfileRepository.kt │ │ │ │ │ ├── RetrofitFactory.kt │ │ │ │ │ ├── SearchRepository.kt │ │ │ │ │ ├── ServiceCallback.java │ │ │ │ │ ├── StoriesRepository.kt │ │ │ │ │ ├── TagsService.java │ │ │ │ │ ├── UserRepository.kt │ │ │ │ │ └── interceptors/ │ │ │ │ │ ├── AddCookiesInterceptor.java │ │ │ │ │ ├── IgErrorsInterceptor.java │ │ │ │ │ └── LoggingInterceptor.java │ │ │ │ └── workers/ │ │ │ │ └── DownloadWorker.kt │ │ │ ├── awaisomereport/ │ │ │ │ ├── CrashReporter.kt │ │ │ │ ├── CrashReporterHelper.kt │ │ │ │ ├── ErrorReporterActivity.kt │ │ │ │ └── ICrashHandler.kt │ │ │ └── thoughtbot/ │ │ │ └── expandableadapter/ │ │ │ ├── ExpandableGroup.java │ │ │ ├── ExpandableList.java │ │ │ ├── ExpandableListPosition.java │ │ │ └── GroupViewHolder.java │ │ └── res/ │ │ ├── anim/ │ │ │ ├── dialog_anim_in.xml │ │ │ ├── dialog_anim_out.xml │ │ │ ├── slide_in_right.xml │ │ │ ├── slide_left.xml │ │ │ ├── slide_out_left.xml │ │ │ └── slide_right.xml │ │ ├── animator/ │ │ │ ├── basket_path.xml │ │ │ └── delete_mic_animation.xml │ │ ├── color/ │ │ │ ├── emoji_picker_tab_color.xml │ │ │ ├── filter_name_color.xml │ │ │ ├── ic_circle_check_tint.xml │ │ │ ├── ic_read_button_tint.xml │ │ │ └── image_edit_tab_tint.xml │ │ ├── drawable/ │ │ │ ├── avd_mic_to_send_anim.xml │ │ │ ├── avd_send_to_mic_anim.xml │ │ │ ├── background_grey_ripple.xml │ │ │ ├── bg_dm_date_header.xml │ │ │ ├── bg_dm_time.xml │ │ │ ├── bg_indicator.xml │ │ │ ├── bg_input.xml │ │ │ ├── bg_media_share_bottom.xml │ │ │ ├── bg_media_share_top_incoming.xml │ │ │ ├── bg_media_share_top_outgoing.xml │ │ │ ├── bg_quote_line.xml │ │ │ ├── bg_reply_text.xml │ │ │ ├── bg_rounded_corner.xml │ │ │ ├── bg_speech_bubble_incoming.xml │ │ │ ├── bg_speech_bubble_outgoing.xml │ │ │ ├── bg_user_search_input.xml │ │ │ ├── ic_account_clock_24.xml │ │ │ ├── ic_account_multiple_remove_24.xml │ │ │ ├── ic_add.xml │ │ │ ├── ic_archive.xml │ │ │ ├── ic_arrow_drop_down_24.xml │ │ │ ├── ic_arrow_upward_24.xml │ │ │ ├── ic_baseline_check_circle_24.xml │ │ │ ├── ic_block_24.xml │ │ │ ├── ic_bookmark.xml │ │ │ ├── ic_border_style_flipped_24.xml │ │ │ ├── ic_camera_24.xml │ │ │ ├── ic_cancel.xml │ │ │ ├── ic_check_24.xml │ │ │ ├── ic_check_all_24.xml │ │ │ ├── ic_checkbox_multiple_blank.xml │ │ │ ├── ic_checkbox_multiple_blank_stroke.xml │ │ │ ├── ic_circle_check.xml │ │ │ ├── ic_class_24.xml │ │ │ ├── ic_clock_alert_outline_24.xml │ │ │ ├── ic_close_24.xml │ │ │ ├── ic_cloud_download_24.xml │ │ │ ├── ic_dashboard_24.xml │ │ │ ├── ic_delete.xml │ │ │ ├── ic_download.xml │ │ │ ├── ic_download_circle_24.xml │ │ │ ├── ic_explore_24.xml │ │ │ ├── ic_face_24.xml │ │ │ ├── ic_file_24.xml │ │ │ ├── ic_folder_24.xml │ │ │ ├── ic_forward_5_24.xml │ │ │ ├── ic_forward_5_24_a50.xml │ │ │ ├── ic_forward_5_24_states.xml │ │ │ ├── ic_highlight_off_24.xml │ │ │ ├── ic_home_24.xml │ │ │ ├── ic_image_24.xml │ │ │ ├── ic_keyboard_24.xml │ │ │ ├── ic_keyboard_arrow_down_24.xml │ │ │ ├── ic_keyboard_arrow_up_24.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_like.xml │ │ │ ├── ic_logout_24.xml │ │ │ ├── ic_message_24.xml │ │ │ ├── ic_more_horiz_24.xml │ │ │ ├── ic_more_vert_24.xml │ │ │ ├── ic_not_liked.xml │ │ │ ├── ic_notes_24.xml │ │ │ ├── ic_notif.xml │ │ │ ├── ic_open_in_new_24.xml │ │ │ ├── ic_outline_class_24.xml │ │ │ ├── ic_outline_comments_24.xml │ │ │ ├── ic_outline_info_24.xml │ │ │ ├── ic_outline_map_24.xml │ │ │ ├── ic_outline_person_add_24.xml │ │ │ ├── ic_outline_person_add_disabled_24.xml │ │ │ ├── ic_outline_person_pin_24.xml │ │ │ ├── ic_outline_settings_24.xml │ │ │ ├── ic_outline_star_plus_24.xml │ │ │ ├── ic_outline_views_24.xml │ │ │ ├── ic_pause_24.xml │ │ │ ├── ic_person_24.xml │ │ │ ├── ic_photo_filter.xml │ │ │ ├── ic_play_arrow_24.xml │ │ │ ├── ic_play_arrow_24_a50.xml │ │ │ ├── ic_play_circle_outline_24.xml │ │ │ ├── ic_play_states.xml │ │ │ ├── ic_profile_24.xml │ │ │ ├── ic_profile_40.xml │ │ │ ├── ic_profile_48.xml │ │ │ ├── ic_radio_button_unchecked_24.xml │ │ │ ├── ic_refresh_24.xml │ │ │ ├── ic_replay_5_24.xml │ │ │ ├── ic_replay_5_24_a50.xml │ │ │ ├── ic_replay_5_24_states.xml │ │ │ ├── ic_round_add_circle_24.xml │ │ │ ├── ic_round_arrow_back_24.xml │ │ │ ├── ic_round_attach_file_rot45_24.xml │ │ │ ├── ic_round_backspace_24.xml │ │ │ ├── ic_round_bookmark_border_24.xml │ │ │ ├── ic_round_check_circle_24.xml │ │ │ ├── ic_round_crop_24.xml │ │ │ ├── ic_round_drag_handle_24.xml │ │ │ ├── ic_round_edit_24.xml │ │ │ ├── ic_round_emoji_emotions_24.xml │ │ │ ├── ic_round_emoji_events_24.xml │ │ │ ├── ic_round_emoji_flags_24.xml │ │ │ ├── ic_round_emoji_food_beverage_24.xml │ │ │ ├── ic_round_emoji_nature_24.xml │ │ │ ├── ic_round_emoji_objects_24.xml │ │ │ ├── ic_round_emoji_symbols_24.xml │ │ │ ├── ic_round_emoji_transportation_24.xml │ │ │ ├── ic_round_flip_camera_24.xml │ │ │ ├── ic_round_gif_24.xml │ │ │ ├── ic_round_location_on_24.xml │ │ │ ├── ic_round_mode_comment_24.xml │ │ │ ├── ic_round_pause_24.xml │ │ │ ├── ic_round_play_arrow_24.xml │ │ │ ├── ic_round_remove_circle_24.xml │ │ │ ├── ic_round_reply_24.xml │ │ │ ├── ic_round_send_24.xml │ │ │ ├── ic_round_tune_24.xml │ │ │ ├── ic_round_unknown_24.xml │ │ │ ├── ic_rounded_corner_24.xml │ │ │ ├── ic_search_24.xml │ │ │ ├── ic_settings_backup_restore_24.xml │ │ │ ├── ic_shutter.xml │ │ │ ├── ic_shutter_focused.xml │ │ │ ├── ic_shutter_normal.xml │ │ │ ├── ic_shutter_pressed.xml │ │ │ ├── ic_slider_24.xml │ │ │ ├── ic_star_24.xml │ │ │ ├── ic_star_check_24.xml │ │ │ ├── ic_sticker_curved_outlines.xml │ │ │ ├── ic_story_list.xml │ │ │ ├── ic_story_viewer_list.xml │ │ │ ├── ic_submit.xml │ │ │ ├── ic_suggested_users.xml │ │ │ ├── ic_unread_indicator_24.xml │ │ │ ├── ic_video_24.xml │ │ │ ├── ic_view_agenda_24.xml │ │ │ ├── ic_view_grid_24.xml │ │ │ ├── ic_volume_off_24.xml │ │ │ ├── ic_volume_off_24_a50.xml │ │ │ ├── ic_volume_off_24_states.xml │ │ │ ├── ic_volume_up_24.xml │ │ │ ├── ic_volume_up_24_a50.xml │ │ │ ├── ic_volume_up_24_states.xml │ │ │ ├── ic_warning.xml │ │ │ ├── launch.xml │ │ │ ├── launch_dark.xml │ │ │ ├── launch_screen.xml │ │ │ ├── lock.xml │ │ │ ├── popup_background_exoplayer.xml │ │ │ ├── pref_list_divider_material.xml │ │ │ ├── recv_basket_animated.xml │ │ │ ├── recv_ic_arrow.xml │ │ │ ├── recv_ic_delete.xml │ │ │ ├── recv_ic_mic.xml │ │ │ ├── rounder_corner_bg.xml │ │ │ ├── rounder_corner_semi_black_bg.xml │ │ │ ├── shape_oval_light.xml │ │ │ ├── sl_favourite_24.xml │ │ │ └── speed_text_color_states.xml │ │ ├── layout/ │ │ │ ├── activity_camera.xml │ │ │ ├── activity_crash_error.xml │ │ │ ├── activity_directory_select.xml │ │ │ ├── activity_login.xml │ │ │ ├── activity_main.xml │ │ │ ├── dialog_account_switcher.xml │ │ │ ├── dialog_create_backup.xml │ │ │ ├── dialog_keywords_filter.xml │ │ │ ├── dialog_opening_post.xml │ │ │ ├── dialog_post_layout_preferences.xml │ │ │ ├── dialog_post_view.xml │ │ │ ├── dialog_profilepic.xml │ │ │ ├── dialog_restore_backup.xml │ │ │ ├── dialog_time_settings.xml │ │ │ ├── fragment_collection_posts.xml │ │ │ ├── fragment_comments.xml │ │ │ ├── fragment_direct_messages_inbox.xml │ │ │ ├── fragment_direct_messages_settings.xml │ │ │ ├── fragment_direct_messages_thread.xml │ │ │ ├── fragment_direct_pending_inbox.xml │ │ │ ├── fragment_discover.xml │ │ │ ├── fragment_favorites.xml │ │ │ ├── fragment_feed.xml │ │ │ ├── fragment_filters.xml │ │ │ ├── fragment_followers_viewer.xml │ │ │ ├── fragment_hashtag.xml │ │ │ ├── fragment_image_edit.xml │ │ │ ├── fragment_likes.xml │ │ │ ├── fragment_location.xml │ │ │ ├── fragment_notifications_viewer.xml │ │ │ ├── fragment_profile.xml │ │ │ ├── fragment_saved.xml │ │ │ ├── fragment_saved_collections.xml │ │ │ ├── fragment_search.xml │ │ │ ├── fragment_story_list_viewer.xml │ │ │ ├── fragment_story_viewer.xml │ │ │ ├── fragment_topic_posts.xml │ │ │ ├── fragment_user_search.xml │ │ │ ├── header_follow.xml │ │ │ ├── item_comment.xml │ │ │ ├── item_dir_list.xml │ │ │ ├── item_discover_topic.xml │ │ │ ├── item_emoji_grid.xml │ │ │ ├── item_fav_section_header.xml │ │ │ ├── item_feed_grid.xml │ │ │ ├── item_feed_photo.xml │ │ │ ├── item_feed_slider.xml │ │ │ ├── item_feed_top.xml │ │ │ ├── item_feed_video.xml │ │ │ ├── item_filter.xml │ │ │ ├── item_follow.xml │ │ │ ├── item_highlight.xml │ │ │ ├── item_keyword.xml │ │ │ ├── item_media.xml │ │ │ ├── item_notification.xml │ │ │ ├── item_post.xml │ │ │ ├── item_pref_divider.xml │ │ │ ├── item_search_result.xml │ │ │ ├── item_slider_photo.xml │ │ │ ├── item_story.xml │ │ │ ├── item_tab_order_pref.xml │ │ │ ├── layout_controls.xml │ │ │ ├── layout_direct_item_options.xml │ │ │ ├── layout_directory_chooser.xml │ │ │ ├── layout_dm_action_log.xml │ │ │ ├── layout_dm_animated_media.xml │ │ │ ├── layout_dm_base.xml │ │ │ ├── layout_dm_header.xml │ │ │ ├── layout_dm_inbox_item.xml │ │ │ ├── layout_dm_like.xml │ │ │ ├── layout_dm_link.xml │ │ │ ├── layout_dm_media.xml │ │ │ ├── layout_dm_media_share.xml │ │ │ ├── layout_dm_pending_user_item.xml │ │ │ ├── layout_dm_profile.xml │ │ │ ├── layout_dm_raven_media.xml │ │ │ ├── layout_dm_reel_share.xml │ │ │ ├── layout_dm_story_share.xml │ │ │ ├── layout_dm_text.xml │ │ │ ├── layout_dm_user_item.xml │ │ │ ├── layout_dm_voice_media.xml │ │ │ ├── layout_emoji_variant_popup.xml │ │ │ ├── layout_exo_custom_controls.xml │ │ │ ├── layout_gif_picker.xml │ │ │ ├── layout_hashtag_details.xml │ │ │ ├── layout_include_custom_format_info.xml │ │ │ ├── layout_include_toolbar.xml │ │ │ ├── layout_location_details.xml │ │ │ ├── layout_post_view_bottom.xml │ │ │ ├── layout_profile_details.xml │ │ │ ├── layout_searchview.xml │ │ │ ├── layout_video_player_with_thumbnail.xml │ │ │ ├── pref_account_switcher.xml │ │ │ ├── pref_auto_refresh_dm_freq.xml │ │ │ ├── pref_more_header.xml │ │ │ └── record_view_layout.xml │ │ ├── layout-land/ │ │ │ └── activity_camera.xml │ │ ├── menu/ │ │ │ ├── bottom_nav_menu.xml │ │ │ ├── collection_posts_menu.xml │ │ │ ├── comment_options_menu.xml │ │ │ ├── dm_inbox_menu.xml │ │ │ ├── dm_thread_menu.xml │ │ │ ├── feed_menu.xml │ │ │ ├── follow.xml │ │ │ ├── hashtag_menu.xml │ │ │ ├── location_menu.xml │ │ │ ├── main_menu.xml │ │ │ ├── menu.xml │ │ │ ├── multi_select_download_menu.xml │ │ │ ├── post_view_menu.xml │ │ │ ├── profile_menu.xml │ │ │ ├── saved.xml │ │ │ ├── saved_collection_menu.xml │ │ │ ├── saved_collection_select_menu.xml │ │ │ ├── saved_viewer_menu.xml │ │ │ ├── search.xml │ │ │ ├── speed_menu.xml │ │ │ ├── story_menu.xml │ │ │ └── topic_posts_menu.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── navigation/ │ │ │ ├── direct_messages_nav_graph.xml │ │ │ ├── discover_nav_graph.xml │ │ │ ├── favorites_nav_graph.xml │ │ │ ├── feed_nav_graph.xml │ │ │ ├── more_nav_graph.xml │ │ │ ├── notification_viewer_nav_graph.xml │ │ │ ├── profile_nav_graph.xml │ │ │ ├── root_nav_graph.xml │ │ │ └── settings_nav_graph.xml │ │ ├── raw/ │ │ │ └── emojis.json │ │ ├── values/ │ │ │ ├── arrays.xml │ │ │ ├── attrs.xml │ │ │ ├── bool.xml │ │ │ ├── color.xml │ │ │ ├── dimens.xml │ │ │ ├── drawables.xml │ │ │ ├── font_certs.xml │ │ │ ├── ids.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ └── themes.xml │ │ ├── values-ar/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-cs/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-el/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-eu/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-fa/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-hi/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-in/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-ja/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-kn/ │ │ │ └── arrays.xml │ │ ├── values-ko/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-land/ │ │ │ └── dimens.xml │ │ ├── values-mk/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-night/ │ │ │ ├── bool.xml │ │ │ ├── color.xml │ │ │ └── styles.xml │ │ ├── values-nl/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-or/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-sk/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-sv/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-zh-rCN/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ ├── arrays.xml │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── backup_descriptor.xml │ │ └── provider_paths.xml │ └── test/ │ └── java/ │ └── awais/ │ └── instagrabber/ │ ├── LiveDataTestUtil.kt │ ├── MainCoroutineScopeRule.kt │ ├── common/ │ │ └── Adapters.kt │ ├── utils/ │ │ ├── CubicInterpolationTest.java │ │ ├── DownloadUtilsTest.java │ │ ├── IntentUtilsTest.java │ │ └── TextUtilsTest.java │ └── viewmodels/ │ └── ProfileFragmentViewModelTest.kt ├── build.gradle ├── crowdin.yml ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── en-US/ │ │ ├── changelogs/ │ │ │ ├── 32.txt │ │ │ ├── 33.txt │ │ │ ├── 36.txt │ │ │ ├── 37.txt │ │ │ ├── 38.txt │ │ │ ├── 39.txt │ │ │ ├── 40.txt │ │ │ ├── 41.txt │ │ │ ├── 42.txt │ │ │ ├── 43.txt │ │ │ ├── 44.txt │ │ │ ├── 45.txt │ │ │ ├── 46.txt │ │ │ ├── 47.txt │ │ │ ├── 48.txt │ │ │ ├── 49.txt │ │ │ ├── 52.txt │ │ │ ├── 53.txt │ │ │ ├── 54.txt │ │ │ ├── 55.txt │ │ │ ├── 56.txt │ │ │ ├── 57.txt │ │ │ ├── 60.txt │ │ │ ├── 61.txt │ │ │ ├── 62.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ └── 65.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ └── fr-FR/ │ ├── full_description.txt │ └── short_description.txt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── renovate.json └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "files": [ "README.md" ], "imageSize": 100, "commit": false, "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg)](#contributors)", "contributors": [ { "login": "austinhuang0131", "name": "Austin Huang", "avatar_url": "https://avatars1.githubusercontent.com/u/16656689", "profile": "https://austinhuang.me", "contributions": [ "code", "doc", "question", "translation", "ideas" ] }, { "login": "ammargitham", "name": "Ammar Githam", "avatar_url": "https://avatars0.githubusercontent.com/u/8017365", "profile": "https://github.com/ammargitham", "contributions": [ "code", "design", "ideas", "maintenance", "question" ] }, { "login": "zerrium", "name": "Zerrium", "avatar_url": "https://avatars.githubusercontent.com/u/58355441?v=4", "profile": "https://github.com/zerrium", "contributions": [ "code" ] }, { "login": "junhuicoding", "name": "Chua Jun Hui", "avatar_url": "https://avatars.githubusercontent.com/u/54289027?v=4", "profile": "https://github.com/junhuicoding", "contributions": [ "code" ] }, { "login": "andersonvom", "name": "Anderson Mesquita", "avatar_url": "https://avatars3.githubusercontent.com/u/69922?v=4", "profile": "https://github.com/andersonvom", "contributions": [ "code", "bug" ] }, { "login": "MeLlamoPablo", "name": "Pablo Rodríguez", "avatar_url": "https://avatars.githubusercontent.com/u/11708035?v=4", "profile": "https://github.com/MeLlamoPablo", "contributions": [ "code" ] }, { "login": "tcely", "name": "tcely", "avatar_url": "https://avatars.githubusercontent.com/u/138864?v=4", "profile": "https://github.com/tcely", "contributions": [ "code" ] }, { "login": "Vonter", "name": "Vonter", "avatar_url": "https://avatars.githubusercontent.com/u/25414711?v=4", "profile": "https://github.com/Vonter", "contributions": [ "code" ] }, { "login": "raniapl", "name": "Rania Pilioura", "avatar_url": "https://avatars.githubusercontent.com/u/56370534?v=4", "profile": "https://github.com/raniapl", "contributions": [ "code" ] }, { "login": "stamatiap", "name": "Stamatia Papageorgiou", "avatar_url": "https://avatars.githubusercontent.com/u/57223967?v=4", "profile": "https://github.com/stamatiap", "contributions": [ "code", "translation" ] }, { "login": "Zopieux", "name": "Alexandre Macabies", "avatar_url": "https://avatars.githubusercontent.com/u/81353?v=4", "profile": "https://github.com/Zopieux", "contributions": [ "code" ] }, { "login": "vojta-horanek", "name": "Vojtěch Hořánek", "avatar_url": "https://avatars.githubusercontent.com/u/12630566?v=4", "profile": "https://vojtechh.eu/", "contributions": [ "code", "translation" ] }, { "login": "The-EDev", "name": "Farook Al-Sammarraie", "avatar_url": "https://avatars.githubusercontent.com/u/60552923?v=4", "profile": "https://github.com/The-EDev", "contributions": [ "code" ] }, { "login": "snajdovski", "name": "Stefan Najdovski", "avatar_url": "https://avatars2.githubusercontent.com/u/42580385?v=4", "profile": "https://snajdovski.github.io", "contributions": [ "design", "translation" ] }, { "login": "CrazyMarvin", "name": "CrazyMarvin", "avatar_url": "https://avatars3.githubusercontent.com/u/15004217?v=4", "profile": "https://github.com/CrazyMarvin", "contributions": [ "financial" ] }, { "login": "KevinNThomas", "name": "Kevin Thomas", "avatar_url": "https://avatars2.githubusercontent.com/u/15370181", "profile": "http://kevinthomas.dev", "contributions": [ "financial" ] }, { "login": "Martin5001", "name": "Martin Krejčí", "avatar_url": "https://avatars.githubusercontent.com/u/35201200?v=4", "profile": "https://github.com/Martin5001", "contributions": [ "bug", "ideas", "translation" ] }, { "login": "Shadowspear123", "name": "Shadowspear123", "avatar_url": "https://avatars1.githubusercontent.com/u/50462281", "profile": "https://github.com/Shadowspear123", "contributions": [ "blog", "bug", "ideas", "question" ] }, { "login": "RickyM7", "name": "Ricardo", "avatar_url": "https://avatars3.githubusercontent.com/u/24703825?v=4", "profile": "https://github.com/RickyM7", "contributions": [ "bug", "translation" ] }, { "login": "Akrai", "name": "Akrai", "avatar_url": "https://avatars1.githubusercontent.com/u/5624597?v=4", "profile": "https://github.com/Akrai", "contributions": [ "ideas", "translation" ] }, { "login": "avtkal", "name": "avtkal", "avatar_url": "https://avatars.githubusercontent.com/u/63205014?v=4", "profile": "https://github.com/avtkal", "contributions": [ "translation" ] }, { "login": "cizordj", "name": "Cézar Augusto", "avatar_url": "https://avatars2.githubusercontent.com/u/32869222?v=4", "profile": "https://github.com/cizordj", "contributions": [ "translation" ] }, { "login": "dimitrist19", "name": "Dimitris T", "avatar_url": "https://avatars.githubusercontent.com/u/56406468?v=4", "profile": "https://github.com/dimitrist19", "contributions": [ "translation" ] }, { "login": "farzadx", "name": "farzadx", "avatar_url": "https://avatars2.githubusercontent.com/u/70059397?v=4", "profile": "https://github.com/farzadx", "contributions": [ "translation" ] }, { "login": "faydin", "name": "Fatih Aydın", "avatar_url": "https://avatars2.githubusercontent.com/u/22706676?v=4", "profile": "https://github.com/faydin", "contributions": [ "translation" ] }, { "login": "fouze555", "name": "fouze555", "avatar_url": "https://avatars3.githubusercontent.com/u/71935341?v=4", "profile": "https://github.com/fouze555", "contributions": [ "translation" ] }, { "login": "Galang23", "name": "Galang23", "avatar_url": "https://avatars3.githubusercontent.com/u/13700948", "profile": "https://github.com/Galang23", "contributions": [ "translation" ] }, { "login": "initdebugs", "name": "Initdebugs", "avatar_url": "https://avatars0.githubusercontent.com/u/75781464?v=4", "profile": "https://github.com/initdebugs", "contributions": [ "translation" ] }, { "login": "CrafterSvK", "name": "Jakub Janek", "avatar_url": "https://avatars3.githubusercontent.com/u/8365659?v=4", "profile": "https://janek.xyz/", "contributions": [ "translation" ] }, { "login": "GenosseFlosse", "name": "GenosseFlosse", "avatar_url": "https://avatars.githubusercontent.com/u/59205524?v=4", "profile": "https://github.com/GenosseFlosse", "contributions": [ "translation" ] }, { "login": "kernoeb", "name": "kernoeb", "avatar_url": "https://avatars3.githubusercontent.com/u/24623168", "profile": "https://becauseofprog.fr/", "contributions": [ "translation" ] }, { "login": "Lego8486", "name": "Ten_Lego", "avatar_url": "https://avatars1.githubusercontent.com/u/47414485", "profile": "https://github.com/Lego8486", "contributions": [ "translation" ] }, { "login": "MoaufmKlo", "name": "MoaufmKlo", "avatar_url": "https://avatars1.githubusercontent.com/u/45636897", "profile": "https://github.com/MoaufmKlo", "contributions": [ "translation" ] }, { "login": "nalinalini", "name": "nalinalini", "avatar_url": "https://avatars0.githubusercontent.com/u/65640431?v=4", "profile": "https://github.com/nalinalini", "contributions": [ "translation" ] }, { "login": "peterge1998", "name": "peterge1998", "avatar_url": "https://avatars2.githubusercontent.com/u/47355238", "profile": "https://github.com/peterge1998", "contributions": [ "translation" ] }, { "login": "PierreM0", "name": "PierreM0", "avatar_url": "https://avatars3.githubusercontent.com/u/71077853?v=4", "profile": "https://github.com/PierreM0", "contributions": [ "translation" ] }, { "login": "Pyrobauve", "name": "Pyrobauve", "avatar_url": "https://avatars.githubusercontent.com/u/48654473?v=4", "profile": "https://github.com/Pyrobauve", "contributions": [ "translation" ] }, { "login": "RAMAR-RAR", "name": "RAMAR-RAR", "avatar_url": "https://avatars3.githubusercontent.com/u/47423745", "profile": "https://github.com/RAMAR-RAR", "contributions": [ "translation" ] }, { "login": "rohang02", "name": "rohang02", "avatar_url": "https://avatars3.githubusercontent.com/u/47921164?v=4", "profile": "https://github.com/rohang02", "contributions": [ "translation" ] }, { "login": "retiolus", "name": "retiolus", "avatar_url": "https://avatars1.githubusercontent.com/u/65604466?v=4", "profile": "https://github.com/retiolus", "contributions": [ "translation" ] }, { "login": "rex07", "name": "Rex_sa", "avatar_url": "https://avatars.githubusercontent.com/u/13156001?v=4", "profile": "https://github.com/rex07", "contributions": [ "translation" ] }, { "login": "rikishi0071", "name": "rikishi0071", "avatar_url": "https://avatars3.githubusercontent.com/u/18183855?v=4", "profile": "https://github.com/rikishi0071", "contributions": [ "translation" ] }, { "login": "sandboiii", "name": "Alexey Peschany", "avatar_url": "https://avatars.githubusercontent.com/u/17468894?v=4", "profile": "https://gitlab.com/sandboiii", "contributions": [ "translation" ] }, { "login": "Sitavi", "name": "Sitavi", "avatar_url": "https://avatars.githubusercontent.com/u/80586127?v=4", "profile": "https://github.com/Sitavi", "contributions": [ "translation" ] }, { "login": "Still34", "name": "Still Hsu", "avatar_url": "https://avatars2.githubusercontent.com/u/5843208?v=4", "profile": "https://stillu.cc/", "contributions": [ "translation" ] }, { "login": "Umeaboy", "name": "Kristoffer Grundström", "avatar_url": "https://avatars.githubusercontent.com/u/714473?v=4", "profile": "https://github.com/Umeaboy", "contributions": [ "translation" ] }, { "login": "wagnim", "name": "wagnim", "avatar_url": "https://avatars0.githubusercontent.com/u/30241419", "profile": "https://github.com/wagnim", "contributions": [ "translation" ] }, { "login": "wokija", "name": "wokija", "avatar_url": "https://avatars.githubusercontent.com/u/14982166?v=4", "profile": "https://github.com/wokija", "contributions": [ "translation" ] }, { "login": "ysakamoto", "name": "ysakamoto", "avatar_url": "https://avatars3.githubusercontent.com/u/1331642?v=4", "profile": "https://github.com/ysakamoto", "contributions": [ "translation" ] }, { "login": "ZDVokoun", "name": "ZDVokoun", "avatar_url": "https://avatars.githubusercontent.com/u/76393152?v=4", "profile": "https://github.com/ZDVokoun", "contributions": [ "translation" ] }, { "login": "2hot2exist", "name": "2hot2exist", "avatar_url": "https://avatars.githubusercontent.com/u/84233003?v=4", "profile": "https://github.com/2hot2exist", "contributions": [ "translation" ] } ], "contributorsPerLine": 6, "projectName": "barinsta", "projectOwner": "austinhuang0131", "repoType": "github", "repoHost": "https://github.com", "skipCi": true, "types": { "translation": { "symbol": "🌍", "description": "Translation", "link": "https://crowdin.com/project/instagrabber" } }, "commitConvention": "none" } ================================================ FILE: .codebeatsettings ================================================ { "JAVA": { "TOO_MANY_IVARS": [8, 10, 20, 30] } } ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # Contributor Code of Conduct This project adheres to No Code of Conduct. We are all adults. We accept anyone's contributions. Nothing else matters. For more information please visit the [No Code of Conduct](https://github.com/domgetter/NCoC) homepage. ================================================ FILE: .github/CONTRIBUTING.md ================================================ ## WARNING * All forks must respect [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html). Please report violations in Issues or [confidentially](https://austinhuang.me/#hey-you-look-cool). * Some people have asked me about publishing a "commercial" fork that serves ads. However, if you do properly comply with GPLv3, users would realize that the original non-commercial version exists and, in turn, abandon your fork. And if you don't comply, you get copystriked. * Although publishing a fork is allowed by license, it is strongly discouraged to do so as it divides the effort and creates confusion (as well as for the reason above). It is, therefore, recommended to send a pull request back to us, so that the larger community can enjoy your improvement. (This does not apply if you're adapting this app for a different platform other than Instagram.) ## Contributing Thank you for your interest in Barinsta! Our vision is an open source true alternative of the official Instagram app. It is Austin's pursuit of a libre life that lead him to this app during its abandonment, and it was one unresolved bug that made him have the enthusiasm in implementing numerous features for this app, despite having 0 knowledge of Java beforehand. As we grow in popularity, it becomes apparent that we are short on hands. Every contribution counts! ## I want to help coding it! Great! Generally, we want to imitate features in the actual Instagram app. There are many Instagram private API repositories on GitHub for you to refer to. Note that you should minimize POST: If a job should be done with GET, then there has to be a GET endpoint. (Indeed, sometimes you need multiple repositories for reference.) As long as you have tested your version (please indicate device and API version) and make sure it works, then you can submit a PR! Large UI changes have to be voted on by the community, so it would be helpful to upload some screenshots. Check errors are for reference only. Try to minimize them, but usually they don't make a big difference. **NEVER touch the l10n-master branch.** It's automatically managed by Crowdin. The legacy branch is no longer maintained. ### I can't code Java, but I want to! Fun fact: Austin took over this project and learned Java on the fly (I'm not joking, I only do JavaScript before taking this over). Even though Java is quite annoying, it is still possible to learn it by trying to understand what these code do (Easier if you have coding experience in other languages). If you have questions, don't be afraid to ask for help from any current maintainer! ## I found a bug! **Please read [FAQ](https://barinsta.austinhuang.me/en/latest/faq.html) first.** Bugs are inevitable during active development, as nobody can cover all the possible test cases. You can either email your crash dump to `barinsta@austinhuang.me` (The crash reporter will fill in this address for you) or create a GitHub issue. If you're on GitHub, please follow the template. If you're reporting by email, your email address will be published in the GitHub issue. You can contact me [privately](https://austinhuang.me/#hey-you-look-cool) or [through support channels](https://barinsta.austinhuang.me/en/latest/#contact-us) to remove it. Generally, reporting bugs directly in support channels is not recommended, as they can be difficult to find. ### I want to help... in some other way. You can... * translate it [![badge](https://badges.crowdin.net/instagrabber/localized.svg)](https://crowdin.com/project/instagrabber) * promote it (reddit [r/AndroidApps](https://www.reddit.com/r/androidapps/comments/i30tpp/instagrabber_an_open_source_instagram_client/), YouTube [HowToMen](https://www.youtube.com/watch?v=1Um2ZJG_mB4), [Ekşi Sözlük](https://eksisozluk.com/instagrabber--6643143)) * star it [![stars](https://img.shields.io/github/stars/austinhuang0131/instagrabber.svg?style=social&label=Star)](https://github.com/austinhuang0131/barinsta/stargazers) Happy contributing! ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: austinhuang0131 patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: austinhuang 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: austinhuang issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: ["https://austinhuang.me/donate"] ================================================ FILE: .github/ISSUE_TEMPLATE/ban_report.md ================================================ --- name: Ban report about: If you suspect a ban by Instagram due to your usage of this app, you MUST report it. title: "[BAN]" labels: BAN REPORT assignees: 'austinhuang0131' --- ## Answer honestly. All boxes must be checked for this report to be considered. - [ ] My app is on the latest version available on GitHub or F-Droid. I understand that any other version is not supported. - [ ] I certify that all actions I have performed on the app constitute human behaviour. I am using the app responsibly and in a way identical to using the official app. Specifically, I did not perform botting or automated key clicks. - [ ] I have considered other possible reasons for my ban, and I cannot find any substantial claim other than the usage of this app. - [ ] Instagram has indicated that this is a permanent ban, not a required password change or a temporary lock. ## Answer honestly. Check accordingly to your situation. - [ ] I had prior rule violations on Instagram, specifically: - [ ] I have admitted the use of Barinsta on Instagram. - [ ] I have admitted the use of Barinsta to a friend who uses Instagram. - [ ] I have modified the source code of the app that I use, other than what is present in this repo. Specifically: ## Describe your case, including your usage pattern, but without private information. ## Provide your communication with Instagram, without private information of you. ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: App crashing? You seeing a confusing error message? Report them here! title: "[BUG]" labels: bug assignees: '' --- - [ ] My app is *at least* at the current release version. I understand that any versions before that is not supported. - [ ] I have read [the FAQ](https://barinsta.austinhuang.me/en/latest/faq.html). ## Steps ## Environment - Device: - Android version: ## Additional info ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Community Chatrooms url: https://barinsta.austinhuang.me/en/latest/chat.html about: Chat with developers and users alike! - name: /r/Barinsta url: https://reddit.com/r/barinsta about: Start a discussion on our subreddit! - name: Repository Discussions url: https://github.com/austinhuang0131/barinsta/discussions about: Start a discussion in this repo! ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Want new features? Ask them here! title: "[FTR]" labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/issue_label_bot.yaml ================================================ label-alias: bug: 'bug' feature_request: 'enhancement' question: 'question' ================================================ FILE: .github/workflows/github_nightly_release.yml ================================================ name: Github nightly on: schedule: # * is a special character in YAML so you have to quote this string - cron: '27 10 * * *' # Everyday at 10:27:00 jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: set up JDK 1.8 uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: '8' - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build Github unsigned apk run: ./gradlew assembleGithubRelease --stacktrace --project-prop pre --project-prop split - name: Sign APK uses: ammargitham/sign-android-release@v1.1.1 # ID used to access action output id: sign_app with: releaseDirectory: app/build/outputs/apk/github/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} alias: ${{ secrets.ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} - name: Get current date and time id: date run: echo "::set-output name=date::$(date +'%Y%m%d_%H%M%S')" # Create artifact - name: Create apk artifact uses: actions/upload-artifact@v2 with: name: barinsta_nightly_${{ steps.date.outputs.date }} # path: ${{steps.sign_app.outputs.signedReleaseFile}} path: app/build/outputs/apk/github/release/*-signed.apk # Send success notification - name: Send success Telegram notification if: ${{ success() }} uses: appleboy/telegram-action@master with: to: ${{ secrets.TELEGRAM_BUILDS_CHANNEL_TO }} token: ${{ secrets.TELEGRAM_BUILDS_BOT_TOKEN }} message: "${{ github.workflow }} ${{ github.job }} #${{ github.run_number }} completed successfully.\nhttps://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" # document: ${{steps.sign_app.outputs.signedReleaseFile}} document: app/build/outputs/apk/github/release/*-signed.apk # Send failure notification - name: Send failure Telegram notification if: ${{ failure() }} uses: appleboy/telegram-action@master with: to: ${{ secrets.TELEGRAM_BUILDS_CHANNEL_TO }} token: ${{ secrets.TELEGRAM_BUILDS_BOT_TOKEN }} message: "${{ github.workflow }} ${{ github.job }} #${{ github.run_number }} failed.\nhttps://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" ================================================ FILE: .github/workflows/github_pre_release.yml ================================================ name: Github pre-release on: workflow_dispatch # push: # branches: [ master ] # pull_request: # branches: [ master ] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: set up JDK 1.8 uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: '8' - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build Github unsigned pre-release apk run: ./gradlew assembleGithubRelease --stacktrace --project-prop pre --project-prop split - name: Sign APK uses: ammargitham/sign-android-release@v1.1.1 # ID used to access action output id: sign_app with: releaseDirectory: app/build/outputs/apk/github/release signingKeyBase64: ${{ secrets.SIGNING_KEY }} alias: ${{ secrets.ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} - name: Get current date and time id: date run: echo "::set-output name=date::$(date +'%Y%m%d_%H%M%S')" # Create artifact - name: Create apk artifact uses: actions/upload-artifact@v2 with: name: barinsta_pre-release_${{ steps.date.outputs.date }} # path: ${{steps.sign_app.outputs.signedReleaseFile}} path: app/build/outputs/apk/github/release/*-signed.apk # Send success notification - name: Send success Telegram notification if: ${{ success() }} uses: appleboy/telegram-action@master with: to: ${{ secrets.TELEGRAM_BUILDS_CHANNEL_TO }} token: ${{ secrets.TELEGRAM_BUILDS_BOT_TOKEN }} message: "${{ github.workflow }} ${{ github.job }} #${{ github.run_number }} completed successfully.\nURL: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" # document: ${{steps.sign_app.outputs.signedReleaseFile}} document: app/build/outputs/apk/github/release/*-signed.apk # Send failure notification - name: Send failure Telegram notification if: ${{ failure() }} uses: appleboy/telegram-action@master with: to: ${{ secrets.TELEGRAM_BUILDS_CHANNEL_TO }} token: ${{ secrets.TELEGRAM_BUILDS_BOT_TOKEN }} message: "${{ github.workflow }} ${{ github.job }} #${{ github.run_number }} failed.\nURL: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" ================================================ FILE: .github/workflows/label-bugs.yml ================================================ name: Label bugs on: issues: types: [opened] jobs: add-labels: runs-on: ubuntu-latest if: contains(github.event.issue.body, 'New Trace collected:') == true steps: - name: Add labels uses: actions-cool/issues-helper@v2.2.1 with: actions: 'add-labels' token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} labels: 'bug' - name: Remove runs uses: GongT/cancel-previous-workflows@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DELETE: 'yes' ================================================ FILE: .github/workflows/label-duplicates.yml ================================================ name: Label duplicates on: issue_comment: types: [created] jobs: add-labels: runs-on: ubuntu-latest if: contains(github.event.comment.body, 'Duplicate of') == true steps: - name: Add labels uses: actions-cool/issues-helper@v2.2.1 with: actions: 'add-labels' token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} labels: 'duplicate' - name: Remove runs uses: GongT/cancel-previous-workflows@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DELETE: 'yes' ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/markdown-navigator.xml /.idea/markdown-navigator-enh.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/git_toolbox_prj.xml /.idea/dbnavigator.xml .DS_Store /build /captures .externalNativeBuild .cxx app/release /sentry.properties /app/fdroid/ /app/github/ /repo /.fdroid.yml ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /dictionaries/ ================================================ FILE: .idea/.name ================================================ Barinsta ================================================ FILE: .idea/codeStyles ================================================
xmlns:android ^$
xmlns:.* ^$ BY_NAME
.*:id http://schemas.android.com/apk/res/android
.*:name http://schemas.android.com/apk/res/android
name ^$
style ^$
.* ^$ BY_NAME
.* http://schemas.android.com/apk/res/android ANDROID_ATTRIBUTE_ORDER
.* .* BY_NAME
================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/profiles_settings.xml ================================================ ================================================ FILE: .idea/jarRepositories.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/render.experimental.xml ================================================ ================================================ FILE: .idea/runConfigurations/app.xml ================================================ ================================================ FILE: .idea/runConfigurations.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: .project ================================================ Barinsta Project instagrabber created by Buildship. org.eclipse.buildship.core.gradleprojectbuilder org.eclipse.buildship.core.gradleprojectnature 0 30 org.eclipse.core.resources.regexFilterMatcher node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ ================================================ FILE: .settings/org.eclipse.buildship.core.prefs ================================================ arguments= auto.sync=false build.scans.enabled=false connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) connection.project.dir= eclipse.preferences.version=1 gradle.user.home= java.home=/Library/Java/JavaVirtualMachines/jdk-12.0.1.jdk/Contents/Home jvm.arguments= offline.mode=false override.workspace.settings=true show.console.view=true show.executions.view=true ================================================ FILE: CHANGELOG ================================================ v15.9 to v16.5: https://gitlab.com/AwaisKing/instagrabber/-/releases From v16.6: https://github.com/austinhuang0131/barinsta/releases v15.9 note: there will be no F-Droid updates from this version (v15.9) and onward, download updates from repo's Releases page. + added user stories in Feed + added frame to currently showing slider item + removed tap to pause/resume from Post viewer, replaced with controller + fixed swipe not working when posts are opened from stories + fixed comments not showing for slider items v15.8 + added user's website in profile + fixed caption mentions length (@kernoeb) + fixed some translations (@kernoeb) + fixd feed captions merging with "... more" v15.4 + ADDED FRENCH AND SPANISH!!! +-> Huge thanks to @kernoeb (Telegram) for French translation and @sguinetti (GitLab) for Spanish translation!! + added custom post time format support! + fixed flickering after changing settings + fixed posts not showing after searching from a private profile + fixed stories and profile pictures not downloading in user folders even when option was enabled + fixed issues with feed, discover and post viewer + fixed search suggestions crashes v15.2 + fixed feed video not pausing when opened in post viewer + added 1 new profile picture view mode + added fields in LogCollector to better understand how things went wrong + better comments, story, feed, discover and suggestion fetchers v15.0 + added support for Instagram.com urls! (: + added "Downloaded" check on posts if they've already been downloaded (as per suggestion) + fixed highlights scrolling issues + fixed comments issues + fixed posts weren't showing after searching anything from a private account + fixed suggestions not showing sometimes + fixed Stories viewer swipe issues + fixed Import/Export dialog not showing on old phones + added user's name in suggestions search list + added comment likes in Comments viewer + sending logs won't add empty logs to archive anymore! + fixed no new line in logs + handled Login WebView lifecycles + a better way of handling highlight swipes + removed useless code parts v14.5 + added changelog after update + added swipe support in both Discover/Explore and Feed pages + added Send Logs button in Settings to send logs when something doesn't work + added clickable user profile in Post Viewer + fixed weirdly collapsing toolbar when toolbar is shown at the top v14.0 + added theme selection support + added import/export settings, favorites and logins functionality (thanks to Airikr [@edgren] on Telegram) + added support for downloading slider items from User Feed page + added support for usernames and hashtags in user's biography/about text + added multiple selection in Discover/Explore page + added post date for feed items + added some more date formats + copyable feed item caption (long tap) + fixed late refresh indicator in Followers/Following comparison mode + changed feed item size to squares + fixed some caption text issues having mentions and hashtags + removed clipboard listener (stopped working after some changes) + added log collector for different crash scenarios v13.7 + fixed custom download folder selection issues v13.3 + added discover/explore page (only for logged in users) + added function to remove IPTC tracking data from downloaded pictures (thanks to Airikr [@edgren] on Telegram) + added multiple accounts support (quick access) and favorites (a suggestion from Saurabh on Telegram) + added custom download folder option, you can select where to download posts (a suggestion from Airikr) + added desktop mode toggle in Login activity (a suggestion from Eymen on Telegram) + added post date in post viewer (a suggestion from W on Telegram) + added post time format settings [ Settings > Post Time Settings ] + fixed some icons and layouts + removed color from slider items in feed + removed useless methods and properties + better way of handling multiple stories and posts (sliders) in post viewer + tried to make notifications grouped together.. hope they work + some other fixes and additions which i probably forgot, cause i'm a human ffs v13.0 + fixed crash when searching hashtags + added lazy loading for hashtags + added Show Feed option in Settings (feed may crash app on some phones) + added null check in Download async + better/adapatable icon + fixed sheet dialog themes v12.7 + (probably) fixed inflating issue in some devices v12.5 + some small performance improvements v12.0 + added feed!! (only for logged in users) + fixed multiple hashtags with no spaces between them + stopped activity from recreating when nothing changed + changed highlights to RecyclerView instead of HorizontalScrollView + changed some numbers and precisions cause she left me on read v11.0 + added crash reporting library + added mute/unmute for session + better profile picture viewer + profile picture info + better swipe gesture + fixed mention and hashtag issues v10.0 NOTE: YOU MAY NEED TO LOGIN TO VIEW PROFILES CAUSE OF INSTAGRAM CHANGES. + added direct download multiple posts dialog + fixed notification problems + fixed some direct download problems + fixed batch download and username folder not creating in some activities + fixed update checker v9.0 + added search in comments viewer + added settings to auto or lazy load posts + added user & hashtag stack when back pressed + added loading icon when loading posts + profile info bar sticks to top + fixed highlights and profile picture size in portrait and landscape mode + fixed posts loading from other user when restarting app + fixed posts size changing when scrolled + users & hashtag search shows only users or hashtag when first char is @ or # + scrolls to top when back pressed v8.0 + added pull-to-refresh layout in main posts, followers/following viewer + added search in followers/following viewer + added video views in post viewer + added animation when showing highlights (if available) + fixed long usernames not animating (marquee) + fixed padding and size in portrait and landscape modes + fixed accounts showing personal followers/following when account has 0 followers/following + fixed double ripple when tapped on profile picture + smaller story border around profile picture v7.0 + added comments viewer!! + added highest quality post fetcher + added loading indicator where it was missing before + fixed highlight name alignment + fixed swiping on posts opened via Share or link v6.0 + added story highlights!! (issue #5) + added button to view posts posted in stories + added verified badge for accounts + fixed posts & story swiping issues + fixed slow loading and stuff + fixed different margins and sizes + fixed activity not recreating after settings dialogs closed v5.0 + added followers / following checker + added search view suggestions for usernames & hashtags + added sliding profile container + fixed batch download permission issue (issue #4) + fixed some small screen panning issues + fixed search view width + fixed update checker v4.0 + fixed Login and Visit project page button codes. v3.0 + fixed posts merged from different accounts when searched while posts are loading! + view stories (only if you're logged in) + directly download posts + choose between two pfp (profile picture) viewer methods + fixed search box not showing up when toolbar is at bottom + automatically checks for updates v2.0 + fixed Login crashes v1.0 + first ever changelog + basic stuff like downloading profile pics, posts and copying captions and bio. + batch download posts ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS ================================================ FILE: README.md ================================================ ### THERE ARE CURRENTLY NO OFFICIAL GOOGLE PLAY RELEASES. PLEASE REPORT ANY OCCURRENCES TO US. Barinsta logo # Barinsta [![Awesome Humane Tech](https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true)](https://github.com/humanetech-community/awesome-humane-tech) [![Open Source Love](https://badges.frapsoft.com/os/v3/open-source.svg?v=103)](https://github.com/ellerbrock/open-source-badges/) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://makeapullrequest.com) [![GPLv3 license](https://img.shields.io/badge/License-GPLv3-blue.svg)](./LICENSE) [![GitHub stars](https://img.shields.io/github/stars/austinhuang0131/instagrabber.svg?style=social&label=Star)](https://GitHub.com/austinhuang0131/barinsta/stargazers/) [![All Contributors](https://img.shields.io/badge/all_contributors-51-orange.svg)](#contributors) Instagram client; previously known as InstaGrabber. For documentation, visit [Barinsta.AustinHuang.me](https://barinsta.austinhuang.me). ## Download **By installing, you indicate your acceptance of [Terms of Service](https://barinsta.austinhuang.me/en/latest/tos.html) and [Privacy Policy](https://barinsta.austinhuang.me/en/latest/privacy.html).** Version status: ![F-Droid](https://img.shields.io/f-droid/v/me.austinhuang.instagrabber.svg) vs. ![GitHub](https://img.shields.io/github/release/austinhuang0131/barinsta.svg?logo=github) ## Screenshots Profile Post Comments Story Hashtag Discover Topics ## We need maintainers! To speed up development, we need more hands on deck. If you are proficient in Java and Android development, and are willing to perform such a public service, please [contact us](https://t.me/austinhuang). ## Contact us * Use [GitHub issues](https://github.com/austinhuang0131/instagrabber/issues) when possible. * Email: [Barinsta@AustinHuang.me](mailto:barinsta@austinhuang.me?body=Please%20note%20that%20your%20email%20address%20and%20the%20entire%20content%20will%20be%20published%20onto%20GitHub%20issues.%20If%20you%20do%20not%20wish%20to%20do%20that%2C%20use%20other%20contact%20methods%20instead.) (Synced to GitHub issues) * Reddit: [![r/Barinsta](https://img.shields.io/reddit/subreddit-subscribers/Barinsta?style=social)](https://reddit.com/r/barinsta) * Chat (Bridged to each other): [![Matrix](https://img.shields.io/badge/Matrix-%23Barinsta:matrix.org-000000?logo=matrix)](https://matrix.to/#/#barinsta:matrix.org) [![Telegram](https://img.shields.io/badge/Telegram-@Barinsta__App-2CA5E0?logo=telegram)](https://t.me/barinsta_app) [![Discord](https://img.shields.io/badge/Discord-YtEDzN2-7289da?logo=discord&logoColor=white)](https://discord.gg/YtEDzN2) ## Contributors Prominent contributors are listed here in the [all-contributors](https://allcontributors.org/) specifications, see [emoji key](https://allcontributors.org/docs/en/emoji-key). [Want to contribute to Barinsta?](https://github.com/austinhuang0131/barinsta/blob/master/.github/CONTRIBUTING.md)

Austin Huang

💻 📖 💬 🌍 🤔

Ammar Githam

💻 🎨 🤔 🚧 💬

Zerrium

💻

Chua Jun Hui

💻

Anderson Mesquita

💻 🐛

Pablo Rodríguez

💻

tcely

💻

Vonter

💻

Rania Pilioura

💻

Stamatia Papageorgiou

💻 🌍

Alexandre Macabies

💻

Vojtěch Hořánek

💻 🌍

Farook Al-Sammarraie

💻

Stefan Najdovski

🎨 🌍

CrazyMarvin

💵

Kevin Thomas

💵

Martin Krejčí

🐛 🤔 🌍

Shadowspear123

📝 🐛 🤔 💬

Ricardo

🐛 🌍

Akrai

🤔 🌍

avtkal

🌍

Cézar Augusto

🌍

Dimitris T

🌍

farzadx

🌍

Fatih Aydın

🌍

fouze555

🌍

Galang23

🌍

Initdebugs

🌍

Jakub Janek

🌍

GenosseFlosse

🌍

kernoeb

🌍

Ten_Lego

🌍

MoaufmKlo

🌍

nalinalini

🌍

peterge1998

🌍

PierreM0

🌍

Pyrobauve

🌍

RAMAR-RAR

🌍

rohang02

🌍

retiolus

🌍

Rex_sa

🌍

rikishi0071

🌍

Alexey Peschany

🌍

Sitavi

🌍

Still Hsu

🌍

Kristoffer Grundström

🌍

wagnim

🌍

wokija

🌍

ysakamoto

🌍

ZDVokoun

🌍

2hot2exist

🌍
## License This app's predecessor, InstaGrabber, was originally made by [@AwaisKing](https://github.com/AwaisKing) on [GitLab](https://gitlab.com/AwaisKing/instagrabber). Barinsta Copyright (C) 2020-2021 Austin Huang Ammar Githam This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Logo by [Stefan Najdovski](https://snajdovski.github.io/). Used under license. [![Snyk Vulnerabilities](https://img.shields.io/snyk/vulnerabilities/github/austinhuang0131/instagrabber)](https://snyk.io/test/github/austinhuang0131/barinsta) [![LGTM Alerts](https://img.shields.io/lgtm/alerts/github/austinhuang0131/instagrabber)](https://lgtm.com/projects/g/austinhuang0131/barinsta) [![LGTM Grade](https://img.shields.io/lgtm/grade/java/github/austinhuang0131/instagrabber)](https://lgtm.com/projects/g/austinhuang0131/barinsta) [![CodeFactor](https://www.codefactor.io/repository/github/austinhuang0131/barinsta/badge)](https://www.codefactor.io/repository/github/austinhuang0131/barinsta) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e9cfcb7733f8477d92e5c0f30cac137a)](https://www.codacy.com/manual/austinhuang0131/instagrabber) [![Crowdin](https://badges.crowdin.net/instagrabber/localized.svg)](https://crowdin.com/project/instagrabber) [![forthebadge](https://forthebadge.com/images/badges/made-with-java.svg)](https://forthebadge.com)[![forthebadge](https://forthebadge.com/images/badges/built-for-android.svg)](https://forthebadge.com) [![gplv3](https://www.gnu.org/graphics/gplv3-with-text-136x68.png)](https://www.gnu.org/licenses/gpl-3.0.html) ================================================ FILE: SECURITY.md ================================================ If there is a security issue with the latest version, please let me know using GitHub issues (bug), or email `im [at] austinhuang [dot] me` if confidential. Use [this PGP key](https://github.com/austinhuang0131/austinhuang0131.github.io/blob/master/assets/key.asc) if you know how to. ================================================ FILE: app/.classpath ================================================ ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/.project ================================================ app Project app created by Buildship. org.eclipse.jdt.core.javabuilder org.eclipse.buildship.core.gradleprojectbuilder org.eclipse.jdt.core.javanature org.eclipse.buildship.core.gradleprojectnature 1600117114944 30 org.eclipse.core.resources.regexFilterMatcher node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ ================================================ FILE: app/.settings/org.eclipse.buildship.core.prefs ================================================ connection.project.dir=.. eclipse.preferences.version=1 ================================================ FILE: app/build.gradle ================================================ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: "androidx.navigation.safeargs" apply plugin: 'kotlin-kapt' apply from: 'sentry.gradle' def getGitHash = { -> def stdout = new ByteArrayOutputStream() exec { commandLine 'git', 'rev-parse', '--short', 'HEAD' standardOutput = stdout } return stdout.toString().trim() } android { compileSdkVersion 30 defaultConfig { applicationId 'me.austinhuang.instagrabber' minSdkVersion 21 targetSdkVersion 30 versionCode 65 versionName '19.2.4' multiDexEnabled true vectorDrawables.useSupportLibrary = true vectorDrawables.generatedDensities = [] javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] } } testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } compileOptions { // Flag to enable support for the new language APIs coreLibraryDesugaringEnabled true targetCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8 } buildFeatures { viewBinding true } aaptOptions { additionalParameters '--no-version-vectors' } buildTypes { debug { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } flavorDimensions "repo" productFlavors { github { dimension "repo" // versionNameSuffix "-github" // appended in assemble task buildConfigField("String", "dsn", SENTRY_DSN) buildConfigField("boolean", "isPre", "false") } fdroid { dimension "repo" versionNameSuffix "-fdroid" buildConfigField("boolean", "isPre", "false") } } splits { // Configures multiple APKs based on ABI. abi { // Enables building multiple APKs per ABI. enable project.hasProperty("split") && !gradle.startParameter.taskNames.isEmpty() && gradle.startParameter.taskNames.get(0).contains('Release') // By default all ABIs are included, so use reset() and include to specify that we only // want APKs for x86 and x86_64. // Resets the list of ABIs that Gradle should create APKs for to none. reset() // Specifies a list of ABIs that Gradle should create APKs for. include "x86", "x86_64", "arm64-v8a", "armeabi-v7a" // Specifies that we want to also generate a universal APK that includes all ABIs. universalApk true } } android.applicationVariants.all { variant -> if (variant.flavorName != "github") return variant.outputs.all { output -> def builtType = variant.buildType.name def versionName = variant.versionName // def versionCode = variant.versionCode def flavor = variant.flavorName def flavorBuiltType = "${flavor}_${builtType}" def suffix // For x86 and x86_64, the versionNames are already overridden if (versionName.contains(flavorBuiltType)) { suffix = "${versionName}" } else { suffix = "${versionName}-${flavorBuiltType}" // eg. 19.1.0-github_debug or release } if (builtType.toString() == 'release' && project.hasProperty("pre")) { buildConfigField("boolean", "isPre", "true") flavorBuiltType = "${getGitHash()}-${flavor}" // For x86 and x86_64, the versionNames are already overridden if (versionName.contains(flavorBuiltType)) { suffix = "${versionName}" } else { // append latest commit short hash for pre-release suffix = "${versionName}.${flavorBuiltType}" // eg. 19.1.0.b123456-github } } output.versionNameOverride = suffix def abi = output.getFilter(com.android.build.OutputFile.ABI) // println(abi + ", " + versionName + ", " + flavor + ", " + builtType + ", " + suffix) outputFileName = abi == null ? "barinsta_${suffix}.apk" : "barinsta_${suffix}_${abi}.apk" } } packagingOptions { // Exclude file to avoid // Error: Duplicate files during packaging of APK exclude 'META-INF/LICENSE.md' exclude 'META-INF/LICENSE-notice.md' exclude 'META-INF/atomicfu.kotlin_module' exclude 'META-INF/AL2.0' exclude 'META-INF/LGPL2.1' } testOptions.unitTests { includeAndroidResources = true } } configurations.all { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' def exoplayer_version = '2.14.1' implementation 'com.google.android.material:material:1.4.0' implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" implementation "com.google.android.exoplayer:exoplayer-dash:$exoplayer_version" implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" implementation "androidx.recyclerview:recyclerview:1.2.1" implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation "androidx.preference:preference:1.1.1" implementation 'androidx.palette:palette:1.0.0' implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'com.google.guava:guava:27.0.1-android' def core_version = "1.6.0" implementation "androidx.core:core:$core_version" // Fragment implementation "androidx.fragment:fragment-ktx:1.3.5" // Lifecycle implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" // Navigation implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.nav_version" implementation "androidx.navigation:navigation-ui-ktx:$rootProject.nav_version" // Room def room_version = "2.3.0" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-guava:$room_version" implementation "androidx.room:room-ktx:$room_version" kapt "androidx.room:room-compiler:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" // CameraX def camerax_version = "1.1.0-alpha07" implementation "androidx.camera:camera-camera2:$camerax_version" implementation "androidx.camera:camera-lifecycle:$camerax_version" implementation "androidx.camera:camera-view:1.0.0-alpha27" // EmojiCompat def emoji_compat_version = "1.1.0" implementation "androidx.emoji:emoji:$emoji_compat_version" implementation "androidx.emoji:emoji-appcompat:$emoji_compat_version" // Work def work_version = '2.5.0' implementation "androidx.work:work-runtime:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0" implementation 'com.facebook.fresco:fresco:2.5.0' implementation 'com.facebook.fresco:animated-webp:2.5.0' implementation 'com.facebook.fresco:webpsupport:2.5.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-scalars:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'org.apache.commons:commons-imaging:1.0-alpha2' implementation 'com.github.skydoves:balloon:1.3.5' implementation 'com.github.ammargitham:AutoLinkTextViewV2:3.2.0' implementation 'com.github.ammargitham:uCrop:2.3-non-native' implementation 'com.github.ammargitham:android-gpuimage:2.1.1-beta4' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' githubImplementation 'io.sentry:sentry-android:5.0.1' testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' testImplementation "androidx.test.ext:junit-ktx:1.1.3" testImplementation "androidx.test:core-ktx:1.4.0" testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "org.robolectric:robolectric:4.5.1" testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0' androidTestImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' androidTestImplementation 'androidx.test:core:1.4.0' androidTestImplementation 'com.android.support:support-annotations:28.0.0' androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation "androidx.room:room-testing:2.3.0" androidTestImplementation "androidx.arch.core:core-testing:2.1.0" androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0' } ================================================ FILE: app/lint.xml ================================================ ================================================ FILE: app/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 #noinspection ShrinkerUnresolvedReference #-keep class !com.google.android.exoplayer2.**, ** { *; } -dontobfuscate # prevent shrinking retrofit response entities -keep class awais.instagrabber.repositories.responses.** { *; } ================================================ FILE: app/schemas/awais.instagrabber.db.AppDatabase/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "538d64adaeb8c3a98db9204955932e59", "entities": [ { "tableName": "accounts", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `username` TEXT, `cookie` TEXT, `full_name` TEXT, `profile_pic` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uid", "columnName": "uid", "affinity": "TEXT", "notNull": false }, { "fieldPath": "username", "columnName": "username", "affinity": "TEXT", "notNull": false }, { "fieldPath": "cookie", "columnName": "cookie", "affinity": "TEXT", "notNull": false }, { "fieldPath": "fullName", "columnName": "full_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "profilePic", "columnName": "profile_pic", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "favorites", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query_text` TEXT, `type` TEXT, `display_name` TEXT, `pic_url` TEXT, `date_added` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query_text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "displayName", "columnName": "display_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "picUrl", "columnName": "pic_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "dateAdded", "columnName": "date_added", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '538d64adaeb8c3a98db9204955932e59')" ] } } ================================================ FILE: app/schemas/awais.instagrabber.db.AppDatabase/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "0b38e12b76bb081ec837191c5ef5b54e", "entities": [ { "tableName": "accounts", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `username` TEXT, `cookie` TEXT, `full_name` TEXT, `profile_pic` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uid", "columnName": "uid", "affinity": "TEXT", "notNull": false }, { "fieldPath": "username", "columnName": "username", "affinity": "TEXT", "notNull": false }, { "fieldPath": "cookie", "columnName": "cookie", "affinity": "TEXT", "notNull": false }, { "fieldPath": "fullName", "columnName": "full_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "profilePic", "columnName": "profile_pic", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "favorites", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query_text` TEXT, `type` TEXT, `display_name` TEXT, `pic_url` TEXT, `date_added` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query_text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "displayName", "columnName": "display_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "picUrl", "columnName": "pic_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "dateAdded", "columnName": "date_added", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "dm_last_notified", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `thread_id` TEXT, `last_notified_msg_ts` INTEGER, `last_notified_at` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "threadId", "columnName": "thread_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastNotifiedMsgTs", "columnName": "last_notified_msg_ts", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastNotifiedAt", "columnName": "last_notified_at", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_dm_last_notified_thread_id", "unique": true, "columnNames": [ "thread_id" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dm_last_notified_thread_id` ON `${TABLE_NAME}` (`thread_id`)" } ], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0b38e12b76bb081ec837191c5ef5b54e')" ] } } ================================================ FILE: app/schemas/awais.instagrabber.db.AppDatabase/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "232e618b3bfcb4661336b359d036c455", "entities": [ { "tableName": "accounts", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `username` TEXT, `cookie` TEXT, `full_name` TEXT, `profile_pic` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uid", "columnName": "uid", "affinity": "TEXT", "notNull": false }, { "fieldPath": "username", "columnName": "username", "affinity": "TEXT", "notNull": false }, { "fieldPath": "cookie", "columnName": "cookie", "affinity": "TEXT", "notNull": false }, { "fieldPath": "fullName", "columnName": "full_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "profilePic", "columnName": "profile_pic", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "favorites", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query_text` TEXT, `type` TEXT, `display_name` TEXT, `pic_url` TEXT, `date_added` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "query", "columnName": "query_text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "displayName", "columnName": "display_name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "picUrl", "columnName": "pic_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "dateAdded", "columnName": "date_added", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "dm_last_notified", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `thread_id` TEXT, `last_notified_msg_ts` INTEGER, `last_notified_at` INTEGER)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "threadId", "columnName": "thread_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "lastNotifiedMsgTs", "columnName": "last_notified_msg_ts", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "lastNotifiedAt", "columnName": "last_notified_at", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_dm_last_notified_thread_id", "unique": true, "columnNames": [ "thread_id" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_dm_last_notified_thread_id` ON `${TABLE_NAME}` (`thread_id`)" } ], "foreignKeys": [] }, { "tableName": "recent_searches", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ig_id` TEXT NOT NULL, `name` TEXT NOT NULL, `username` TEXT, `pic_url` TEXT, `type` TEXT NOT NULL, `last_searched_on` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "igId", "columnName": "ig_id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "username", "columnName": "username", "affinity": "TEXT", "notNull": false }, { "fieldPath": "picUrl", "columnName": "pic_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastSearchedOn", "columnName": "last_searched_on", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_recent_searches_ig_id_type", "unique": true, "columnNames": [ "ig_id", "type" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_recent_searches_ig_id_type` ON `${TABLE_NAME}` (`ig_id`, `type`)" } ], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '232e618b3bfcb4661336b359d036c455')" ] } } ================================================ FILE: app/sentry.gradle ================================================ def dsnKey = 'DSN' def defaultDsn = '\"\"' final Properties properties = new Properties() File propertiesFile = rootProject.file('sentry.properties') if (!propertiesFile.exists()) { propertiesFile.createNewFile() } properties.load(new FileInputStream(propertiesFile)) ext{ SENTRY_DSN = properties.getProperty(dsnKey, defaultDsn) } ================================================ FILE: app/src/androidTest/java/awais/instagrabber/db/MigrationTest.java ================================================ package awais.instagrabber.db; import androidx.room.Room; import androidx.room.migration.Migration; import androidx.room.testing.MigrationTestHelper; import androidx.sqlite.db.SupportSQLiteDatabase; import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import java.io.IOException; import static awais.instagrabber.db.AppDatabase.MIGRATION_4_5; import static awais.instagrabber.db.AppDatabase.MIGRATION_5_6; @RunWith(AndroidJUnit4.class) public class MigrationTest { private static final String TEST_DB = "migration-test"; private static final Migration[] ALL_MIGRATIONS = new Migration[]{MIGRATION_4_5, MIGRATION_5_6}; @Rule public MigrationTestHelper helper; public MigrationTest() { final String canonicalName = AppDatabase.class.getCanonicalName(); helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), canonicalName, new FrameworkSQLiteOpenHelperFactory()); } @Test public void migrateAll() throws IOException { // Create earliest version of the database. Have to start with 4 since that is the version we migrated to Room. SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 4); db.close(); // Open latest version of the database. Room will validate the schema // once all migrations execute. AppDatabase appDb = Room.databaseBuilder(InstrumentationRegistry.getInstrumentation().getTargetContext(), AppDatabase.class, TEST_DB) .addMigrations(ALL_MIGRATIONS).build(); appDb.getOpenHelper().getWritableDatabase(); appDb.close(); } } ================================================ FILE: app/src/androidTest/java/awais/instagrabber/db/dao/RecentSearchDaoTest.kt ================================================ package awais.instagrabber.db.dao import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.runner.AndroidJUnit4 import awais.instagrabber.db.AppDatabase import awais.instagrabber.db.entities.RecentSearch import awais.instagrabber.models.enums.FavoriteType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runBlockingTest import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.jupiter.api.Assertions import org.junit.runner.RunWith import java.time.LocalDateTime @RunWith(AndroidJUnit4::class) class RecentSearchDaoTest { private lateinit var db: AppDatabase private lateinit var dao: RecentSearchDao @get:Rule var instantExecutorRule = InstantTaskExecutorRule() @Before fun createDb() { val context = ApplicationProvider.getApplicationContext() db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() dao = db.recentSearchDao() } @After fun closeDb() { db.close() } @ExperimentalCoroutinesApi @Test fun writeQueryDelete() = runBlockingTest { val recentSearch = insertRecentSearch(1, "1", "test1", FavoriteType.HASHTAG) val byIgIdAndType = dao.getRecentSearchByIgIdAndType("1", FavoriteType.HASHTAG) Assertions.assertNotNull(byIgIdAndType) Assertions.assertEquals(recentSearch, byIgIdAndType) dao.deleteRecentSearch(byIgIdAndType ?: throw NullPointerException()) val deleted = dao.getRecentSearchByIgIdAndType("1", FavoriteType.HASHTAG) Assertions.assertNull(deleted) } @ExperimentalCoroutinesApi @Test fun queryAllOrdered() = runBlockingTest { val insertListReversed: List = listOf( insertRecentSearch(1, "1", "test1", FavoriteType.HASHTAG), insertRecentSearch(2, "2", "test2", FavoriteType.LOCATION), insertRecentSearch(3, "3", "test3", FavoriteType.USER), insertRecentSearch(4, "4", "test4", FavoriteType.USER), insertRecentSearch(5, "5", "test5", FavoriteType.USER) ).asReversed() // important val fromDb: List = dao.getAllRecentSearches() Assertions.assertIterableEquals(insertListReversed, fromDb) } private fun insertRecentSearch(id: Int, igId: String, name: String, type: FavoriteType): RecentSearch { val recentSearch = RecentSearch( id, igId, name, null, null, type, LocalDateTime.now() ) runBlocking { dao.insertRecentSearch(recentSearch) } return recentSearch } } ================================================ FILE: app/src/androidTest/java/awaisomereport/CrashReporterHelperTest.kt ================================================ package awaisomereport import androidx.test.runner.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) internal class CrashReporterHelperTest { @Test fun getErrorContent() { val errorContent = CrashReporterHelper.getReportContent(Exception()) print(errorContent) } } ================================================ FILE: app/src/fdroid/java/awais/instagrabber/fragments/settings/FlavorSettings.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.fragment.app.FragmentManager; import androidx.preference.Preference; import java.util.Collections; import java.util.List; import awais.instagrabber.fragments.settings.IFlavorSettings; import awais.instagrabber.fragments.settings.SettingCategory; public final class FlavorSettings implements IFlavorSettings { private static FlavorSettings instance; private FlavorSettings() { } public static FlavorSettings getInstance() { if (instance == null) { instance = new FlavorSettings(); } return instance; } @NonNull @Override public List getPreferences(@NonNull final Context context, @NonNull final FragmentManager fragmentManager, @NonNull final SettingCategory settingCategory) { // switch (settingCategory) { // default: // break; // } return Collections.emptyList(); } } ================================================ FILE: app/src/fdroid/java/awais/instagrabber/utils/UpdateChecker.java ================================================ package awais.instagrabber.utils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import org.json.JSONObject; import java.net.HttpURLConnection; import java.net.URL; public class UpdateChecker { private static final Object LOCK = new Object(); private static final String TAG = UpdateChecker.class.getSimpleName(); private static UpdateChecker instance; public static UpdateChecker getInstance() { if (instance == null) { synchronized (LOCK) { if (instance == null) { instance = new UpdateChecker(); } } } return instance; } /** * Needs to be called asynchronously * * @return the latest version from f-droid */ @Nullable public String getLatestVersion() { HttpURLConnection conn = null; try { conn = (HttpURLConnection) new URL("https://f-droid.org/api/v1/packages/me.austinhuang.instagrabber").openConnection(); conn.setUseCaches(false); conn.setRequestProperty("User-Agent", "https://Barinsta.AustinHuang.me / mailto:Barinsta@AustinHuang.me"); conn.connect(); final int responseCode = conn.getResponseCode(); if (responseCode == HttpURLConnection.HTTP_OK) { final JSONObject data = new JSONObject(NetworkUtils.readFromConnection(conn)); return "v" + data.getJSONArray("packages").getJSONObject(0).getString("versionName"); // if (BuildConfig.VERSION_CODE < data.getInt("suggestedVersionCode")) { // } } } catch (final Exception e) { Log.e(TAG, "", e); } finally { if (conn != null) { conn.disconnect(); } } return null; } public void onDownload(@NonNull final AppCompatActivity context) { Utils.openURL(context, "https://f-droid.org/packages/me.austinhuang.instagrabber/"); } } ================================================ FILE: app/src/fdroid/java/awaisomereport/CrashHandler.kt ================================================ package awaisomereport import android.app.Application class CrashHandler(private val application: Application) : ICrashHandler { override fun uncaughtException( t: Thread, exception: Throwable, defaultExceptionHandler: Thread.UncaughtExceptionHandler ) { CrashReporterHelper.startErrorReporterActivity(application, exception) defaultExceptionHandler.uncaughtException(t, exception) } } ================================================ FILE: app/src/github/AndroidManifest.xml ================================================ ================================================ FILE: app/src/github/java/awais/instagrabber/fragments/settings/FlavorSettings.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.fragment.app.FragmentManager; import androidx.preference.Preference; import com.google.common.collect.ImmutableList; import java.util.Collections; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.dialogs.ConfirmDialogFragment; import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_ENABLE_SENTRY; import static awais.instagrabber.utils.Utils.settingsHelper; public final class FlavorSettings implements IFlavorSettings { private static FlavorSettings instance; private FlavorSettings() { } public static FlavorSettings getInstance() { if (instance == null) { instance = new FlavorSettings(); } return instance; } @NonNull @Override public List getPreferences(@NonNull final Context context, @NonNull final FragmentManager fragmentManager, @NonNull final SettingCategory settingCategory) { switch (settingCategory) { case GENERAL: return getGeneralPrefs(context, fragmentManager); default: break; } return Collections.emptyList(); } private List getGeneralPrefs(@NonNull final Context context, @NonNull final FragmentManager fragmentManager) { return ImmutableList.of( getSentryPreference(context, fragmentManager) ); } private Preference getSentryPreference(@NonNull final Context context, @NonNull final FragmentManager fragmentManager) { if (!settingsHelper.hasPreference(PREF_ENABLE_SENTRY)) { // disabled by default settingsHelper.putBoolean(PREF_ENABLE_SENTRY, false); } return PreferenceHelper.getSwitchPreference( context, PREF_ENABLE_SENTRY, R.string.enable_sentry, R.string.sentry_summary, false, (preference, newValue) -> { if (!(newValue instanceof Boolean)) return true; final boolean enabled = (Boolean) newValue; if (enabled) { final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( 111, 0, R.string.sentry_start_next_launch, R.string.ok, 0, 0); dialogFragment.show(fragmentManager, "sentry_dialog"); } return true; }); } } ================================================ FILE: app/src/github/java/awais/instagrabber/utils/UpdateChecker.java ================================================ package awais.instagrabber.utils; import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.net.HttpURLConnection; import java.net.URL; public class UpdateChecker { private static final Object LOCK = new Object(); private static final String TAG = UpdateChecker.class.getSimpleName(); private static UpdateChecker instance; public static UpdateChecker getInstance() { if (instance == null) { synchronized (LOCK) { if (instance == null) { instance = new UpdateChecker(); } } } return instance; } /** * Needs to be called asynchronously * * @return the latest version from Github */ @Nullable public String getLatestVersion() { HttpURLConnection conn = null; try { conn = (HttpURLConnection) new URL("https://github.com/austinhuang0131/barinsta/releases/latest").openConnection(); conn.setInstanceFollowRedirects(false); conn.setUseCaches(false); conn.setRequestProperty("User-Agent", "https://Barinsta.AustinHuang.me / mailto:Barinsta@AustinHuang.me"); conn.connect(); final int responseCode = conn.getResponseCode(); if (responseCode == HttpURLConnection.HTTP_MOVED_TEMP) { return "v" + conn.getHeaderField("Location").split("/v")[1]; // return !version.equals(BuildConfig.VERSION_NAME); } } catch (final Exception e) { Log.e(TAG, "", e); } finally { if (conn != null) { conn.disconnect(); } } return null; } public void onDownload(@NonNull final Context context) { Utils.openURL(context, "https://github.com/austinhuang0131/instagrabber/releases/latest"); } } ================================================ FILE: app/src/github/java/awaisomereport/CrashHandler.kt ================================================ package awaisomereport import android.app.Application import awais.instagrabber.BuildConfig import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.utils.Utils import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryOptions.BeforeSendCallback import io.sentry.android.core.SentryAndroid import io.sentry.android.core.SentryAndroidOptions class CrashHandler(private val application: Application) : ICrashHandler { private var enabled = false init { enabled = if (!Utils.settingsHelper.hasPreference(PreferenceKeys.PREF_ENABLE_SENTRY)) { // disabled by default (change to true if we need enabled by default) false } else { Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_SENTRY) } if (enabled) { SentryAndroid.init(application) { options: SentryAndroidOptions -> options.dsn = BuildConfig.dsn options.setDiagnosticLevel(SentryLevel.ERROR) options.beforeSend = BeforeSendCallback { event: SentryEvent, _: Any? -> // Removing unneeded info from event event.contexts.device?.apply { name = null timezone = null isCharging = null bootTime = null freeStorage = null batteryTemperature = null } event } } } } override fun uncaughtException( t: Thread, exception: Throwable, defaultExceptionHandler: Thread.UncaughtExceptionHandler ) { // When enabled, Sentry auto captures unhandled exceptions if (!enabled) { CrashReporterHelper.startErrorReporterActivity(application, exception) } defaultExceptionHandler.uncaughtException(t, exception) } } ================================================ FILE: app/src/github/res/values/strings.xml ================================================ Enable Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry will start on next launch ================================================ FILE: app/src/github/res/values-ar/strings.xml ================================================ تمكين الحراسة الحراسة هي مستمع/معالج للأخطاء الذي يرسل الخطأ/الاحداث إلى Sentry.io ستبدأ الحراسة عند التشغيل التالي ================================================ FILE: app/src/github/res/values-ca/strings.xml ================================================ Habilita el Sentry Sentry és un oient/intèrpret d\'error que envia asíncronament l\'error/esdeveniment a Sentry.io Sentry s\'iniciarà al pròxim llançament ================================================ FILE: app/src/github/res/values-cs/strings.xml ================================================ Povolit Sentry Sentry je listener/handler, který zaznamenává chyby a asynchronně je posílá na Sentry.io Sentry se spustí při příštím spuštění ================================================ FILE: app/src/github/res/values-de/strings.xml ================================================ Aktiviere Sentry Sentry ist ein Listener/Handler für Fehler, der den Fehler/das Ereignis asynchron an Sentry.io sendet Sentry startet beim nächsten Start ================================================ FILE: app/src/github/res/values-el/strings.xml ================================================ Ενεργοποίηση Sentry Το Sentry είναι διαχειριστής σφαλμάτων ασύγχρονης αποστολής του σφάλματος/συμβάντος στο Sentry.io Το Sentry θα ξεκινήσει στην επόμενη εκκίνηση ================================================ FILE: app/src/github/res/values-es/strings.xml ================================================ Activar Sentry Sentry es un oyente/manejador de errores que asincrónicamente envía el error/evento a Sentry.io Sentry comenzará en el próximo inicio ================================================ FILE: app/src/github/res/values-eu/strings.xml ================================================ Enable Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry will start on next launch ================================================ FILE: app/src/github/res/values-fa/strings.xml ================================================ فعالسازی Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry در اجرای بعدی، شروع خواهد شد ================================================ FILE: app/src/github/res/values-fr/strings.xml ================================================ Activer Sentry Sentry est un écouteur/gestionnaire d\'erreurs qui envoie de manière asynchrone l\'erreur/l\'événement à Sentry.io Sentry commencera au prochain lancement ================================================ FILE: app/src/github/res/values-hi/strings.xml ================================================ Enable Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry will start on next launch ================================================ FILE: app/src/github/res/values-in/strings.xml ================================================ Hidupkan Sentry Sentry adalah sebuah pendengar/penanganan eror yang secara asinkronis mengirimkan eror/kejadian ke Sentry.io Sentry akan dihidupkan pada peluncuran berikutnya ================================================ FILE: app/src/github/res/values-it/strings.xml ================================================ Abilita Sentry Sentry è un ascoltatore/gestore di errori che invia asincronicamente l\'errore/evento a Sentry.io Sentry comincerà al prossimo lancio ================================================ FILE: app/src/github/res/values-ja/strings.xml ================================================ Enable Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry will start on next launch ================================================ FILE: app/src/github/res/values-ko/strings.xml ================================================ Sentry 활성화 Sentry는 Sentry.io에 오류를 비동기적으로 보내는 오류 처리기입니다 Sentry는 다음 출시에 시작됩니다 ================================================ FILE: app/src/github/res/values-mk/strings.xml ================================================ Овозможи Sentry Sentry е слушач на грешки кој асинхроно ги испраќа на Sentry.io страната Sentry ќе биде овозможен на следно отварање ================================================ FILE: app/src/github/res/values-nl/strings.xml ================================================ Sentry inschakelen Sentry is een luister/handler voor fouten die asynchroon de fout/gebeurtenis versturen naar Sentry.io Sentry zal starten bij de volgende lancering ================================================ FILE: app/src/github/res/values-or/strings.xml ================================================ Sentryକୁ ସକ୍ଷମ କରନ୍ତୁ ତ୍ରୁଟି ପାଇଁ ସେଣ୍ଟ୍ରି ହେଉଛି ଏକ ଶ୍ରୋତା ଯାହା ତ୍ରୁଟି / ଘଟଣାକୁ Sentry.io କୁ ପଠାଏ | ପରବର୍ତ୍ତୀ ଲଞ୍ଚଠାରୁ ସେଣ୍ଟ୍ରି ଆରମ୍ଭ ହେବ | ================================================ FILE: app/src/github/res/values-pl/strings.xml ================================================ Włącz Sentry Sentry jest słuchaczem/obsługą błędów, które asynchronicznie wysyłają błąd/zdarzenie do Sentry.io Sentry rozpocznie się przy następnym uruchomieniu ================================================ FILE: app/src/github/res/values-pt/strings.xml ================================================ Ativar Sentry Sentry é um ouvinte/gestor de erros que assincronicamente envia o erro/evento para Sentry.io Sentry começará no próximo início ================================================ FILE: app/src/github/res/values-ru/strings.xml ================================================ Включить Sentry Sentry - это обработчик событий, который асинхронно отправляет сообщения об ошибках/поломках на Sentry.io Sentry включится при следующем запуске приложения ================================================ FILE: app/src/github/res/values-sk/strings.xml ================================================ Enable Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry will start on next launch ================================================ FILE: app/src/github/res/values-sv/strings.xml ================================================ Enable Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry will start on next launch ================================================ FILE: app/src/github/res/values-tr/strings.xml ================================================ Enable Sentry Sentry is a listener/handler for errors that asynchronously sends out the error/event to Sentry.io Sentry will start on next launch ================================================ FILE: app/src/github/res/values-vi/strings.xml ================================================ Bật Sentry Sentry là một thiết bị nghe/giải quyết cho những lỗi mà gửi những lỗi/sự kiện đến Sentry.io một cách tách biệt Sentry sẽ được bật vào lần khởi động kế tiếp ================================================ FILE: app/src/github/res/values-zh-rCN/strings.xml ================================================ 启用 Sentry Sentry 会将错误报告发送至 Sentry.io 启用 Sentry 将在下次启动应用时生效 ================================================ FILE: app/src/github/res/values-zh-rTW/strings.xml ================================================ 啟用 Sentry Sentry 會將錯誤報告發送至 Sentry.io 下次啟用應用程式時將會開啟 Sentry ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/awais/instagrabber/InstaGrabberApplication.kt ================================================ package awais.instagrabber import android.app.Application import android.content.ClipboardManager import android.util.Log import awais.instagrabber.fragments.settings.PreferenceKeys.CUSTOM_DATE_TIME_FORMAT import awais.instagrabber.fragments.settings.PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED import awais.instagrabber.fragments.settings.PreferenceKeys.DATE_TIME_FORMAT import awais.instagrabber.utils.* import awais.instagrabber.utils.LocaleUtils.currentLocale import awais.instagrabber.utils.Utils.settingsHelper import awais.instagrabber.utils.extensions.TAG import awaisomereport.CrashReporter import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.imagepipeline.core.ImagePipelineConfig import java.net.CookieHandler import java.time.format.DateTimeFormatter import java.util.* @Suppress("unused") class InstaGrabberApplication : Application() { override fun onCreate() { super.onCreate() CookieHandler.setDefault(NET_COOKIE_MANAGER) settingsHelper = SettingsHelper(this) setupCrashReporter() setupCloseGuard() setupFresco() Utils.cacheDir = cacheDir.absolutePath Utils.clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager LocaleUtils.setLocale(baseContext) val pattern = if (settingsHelper.getBoolean(CUSTOM_DATE_TIME_FORMAT_ENABLED)) { settingsHelper.getString(CUSTOM_DATE_TIME_FORMAT) } else { settingsHelper.getString(DATE_TIME_FORMAT) } TextUtils.setFormatter(DateTimeFormatter.ofPattern(pattern, currentLocale)) if (TextUtils.isEmpty(settingsHelper.getString(Constants.DEVICE_UUID))) { settingsHelper.putString(Constants.DEVICE_UUID, UUID.randomUUID().toString()) } } private fun setupCrashReporter() { if (BuildConfig.DEBUG) return CrashReporter.getInstance(this).start() } private fun setupCloseGuard() { if (!BuildConfig.DEBUG) return try { Class.forName("dalvik.system.CloseGuard") .getMethod("setEnabled", Boolean::class.javaPrimitiveType) .invoke(null, true) } catch (e: Exception) { Log.e(TAG, "Error", e) } } private fun setupFresco() { // final Set requestListeners = new HashSet<>(); // requestListeners.add(new RequestLoggingListener()); val imagePipelineConfig = ImagePipelineConfig .newBuilder(this) // .setMainDiskCacheConfig(diskCacheConfig) // .setRequestListeners(requestListeners) .setDownsampleEnabled(true) .build() Fresco.initialize(this, imagePipelineConfig) // FLog.setMinimumLoggingLevel(FLog.VERBOSE); } } ================================================ FILE: app/src/main/java/awais/instagrabber/activities/BaseLanguageActivity.kt ================================================ package awais.instagrabber.activities import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import awais.instagrabber.utils.LocaleUtils import awais.instagrabber.utils.ThemeUtils abstract class BaseLanguageActivity protected constructor() : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { ThemeUtils.changeTheme(this) super.onCreate(savedInstanceState) } init { @Suppress("LeakingThis") LocaleUtils.updateConfig(this) } } ================================================ FILE: app/src/main/java/awais/instagrabber/activities/CameraActivity.kt ================================================ package awais.instagrabber.activities import android.content.Intent import android.content.res.Configuration import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager.DisplayListener import android.os.Bundle import android.util.Log import android.view.LayoutInflater import androidx.camera.core.* import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import awais.instagrabber.databinding.ActivityCameraBinding import awais.instagrabber.utils.DownloadUtils import awais.instagrabber.utils.PermissionUtils import awais.instagrabber.utils.Utils import awais.instagrabber.utils.extensions.TAG import java.io.IOException import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutorService import java.util.concurrent.Executors class CameraActivity : BaseLanguageActivity() { private lateinit var binding: ActivityCameraBinding private lateinit var displayManager: DisplayManager private lateinit var cameraExecutor: ExecutorService private var outputDirectory: DocumentFile? = null private var imageCapture: ImageCapture? = null private var displayId = -1 private var cameraProvider: ProcessCameraProvider? = null private var lensFacing = 0 private val cameraRequestCode = 100 private val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US) private val displayListener: DisplayListener = object : DisplayListener { override fun onDisplayAdded(displayId: Int) {} override fun onDisplayRemoved(displayId: Int) {} override fun onDisplayChanged(displayId: Int) { if (displayId == this@CameraActivity.displayId) { imageCapture?.targetRotation = binding.root.display.rotation } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityCameraBinding.inflate(LayoutInflater.from(baseContext)) setContentView(binding.root) Utils.transparentStatusBar(this, true, false) displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager outputDirectory = DownloadUtils.cameraDir cameraExecutor = Executors.newSingleThreadExecutor() displayManager.registerDisplayListener(displayListener, null) binding.viewFinder.post { displayId = binding.viewFinder.display.displayId updateUi() checkPermissionsAndSetupCamera() } } override fun onResume() { super.onResume() // Make sure that all permissions are still present, since the // user could have removed them while the app was in paused state. if (!PermissionUtils.hasCameraPerms(this)) { PermissionUtils.requestCameraPerms(this, cameraRequestCode) } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // Redraw the camera UI controls updateUi() // Enable or disable switching between cameras updateCameraSwitchButton() } override fun onDestroy() { super.onDestroy() Utils.transparentStatusBar(this, false, false) cameraExecutor.shutdown() displayManager.unregisterDisplayListener(displayListener) } private fun updateUi() { binding.cameraCaptureButton.setOnClickListener { takePhoto() } // Disable the button until the camera is set up binding.switchCamera.isEnabled = false // Listener for button used to switch cameras. Only called if the button is enabled binding.switchCamera.setOnClickListener { lensFacing = if (CameraSelector.LENS_FACING_FRONT == lensFacing) CameraSelector.LENS_FACING_BACK else CameraSelector.LENS_FACING_FRONT // Re-bind use cases to update selected camera bindCameraUseCases() } binding.close.setOnClickListener { setResult(RESULT_CANCELED) finish() } } private fun checkPermissionsAndSetupCamera() { if (PermissionUtils.hasCameraPerms(this)) { setupCamera() return } PermissionUtils.requestCameraPerms(this, cameraRequestCode) } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == cameraRequestCode) { if (PermissionUtils.hasCameraPerms(this)) { setupCamera() } } } private fun setupCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ try { cameraProvider = cameraProviderFuture.get() // Select lensFacing depending on the available cameras lensFacing = -1 if (hasBackCamera()) { lensFacing = CameraSelector.LENS_FACING_BACK } else if (hasFrontCamera()) { lensFacing = CameraSelector.LENS_FACING_FRONT } check(lensFacing != -1) { "Back and front camera are unavailable" } // Enable or disable switching between cameras updateCameraSwitchButton() // Build and bind the camera use cases bindCameraUseCases() } catch (e: ExecutionException) { Log.e(TAG, "setupCamera: ", e) } catch (e: InterruptedException) { Log.e(TAG, "setupCamera: ", e) } catch (e: CameraInfoUnavailableException) { Log.e(TAG, "setupCamera: ", e) } }, ContextCompat.getMainExecutor(this)) } private fun bindCameraUseCases() { val rotation = binding.viewFinder.display.rotation // CameraSelector val cameraSelector = CameraSelector.Builder() .requireLensFacing(lensFacing) .build() // Preview val preview = Preview.Builder() // Set initial target rotation .setTargetRotation(rotation) .build() // ImageCapture imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) // Set initial target rotation, we will have to call this again if rotation changes // during the lifecycle of this use case .setTargetRotation(rotation) .build() cameraProvider?.unbindAll() cameraProvider?.bindToLifecycle(this, cameraSelector, preview, imageCapture) preview.setSurfaceProvider(binding.viewFinder.surfaceProvider) } private fun takePhoto() { if (imageCapture == null) return val fileName = simpleDateFormat.format(System.currentTimeMillis()) + ".jpg" val mimeType = "image/jpg" val photoFile = outputDirectory?.createFile(mimeType, fileName)?.let { it } ?: return val outputStream = contentResolver.openOutputStream(photoFile.uri)?.let { it } ?: return val outputFileOptions = ImageCapture.OutputFileOptions.Builder(outputStream).build() imageCapture?.takePicture( outputFileOptions, cameraExecutor, object : ImageCapture.OnImageSavedCallback { @Suppress("UnstableApiUsage") override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { try { outputStream.close() } catch (ignored: IOException) {} val intent = Intent() intent.data = photoFile.uri setResult(RESULT_OK, intent) finish() Log.d(TAG, "onImageSaved: " + photoFile.uri) } override fun onError(exception: ImageCaptureException) { Log.e(TAG, "onError: ", exception) try { outputStream.close() } catch (ignored: IOException) {} } } ) // We can only change the foreground Drawable using API level 23+ API // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // // Display flash animation to indicate that photo was captured // final ConstraintLayout container = binding.getRoot(); // container.postDelayed(() -> { // container.setForeground(new ColorDrawable(Color.WHITE)); // container.postDelayed(() -> container.setForeground(null), 50); // }, 100); // } } /** * Enabled or disabled a button to switch cameras depending on the available cameras */ private fun updateCameraSwitchButton() { try { binding.switchCamera.isEnabled = hasBackCamera() && hasFrontCamera() } catch (e: CameraInfoUnavailableException) { binding.switchCamera.isEnabled = false } } /** * Returns true if the device has an available back camera. False otherwise */ @Throws(CameraInfoUnavailableException::class) private fun hasBackCamera(): Boolean { return if (cameraProvider == null) false else cameraProvider?.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) ?: false } /** * Returns true if the device has an available front camera. False otherwise */ @Throws(CameraInfoUnavailableException::class) private fun hasFrontCamera(): Boolean { return if (cameraProvider == null) { false } else cameraProvider?.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) ?: false } } ================================================ FILE: app/src/main/java/awais/instagrabber/activities/DirectorySelectActivity.kt ================================================ package awais.instagrabber.activities import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.DocumentsContract import android.util.Log import android.view.View import androidx.activity.viewModels import awais.instagrabber.R import awais.instagrabber.databinding.ActivityDirectorySelectBinding import awais.instagrabber.dialogs.ConfirmDialogFragment import awais.instagrabber.utils.AppExecutors.mainThread import awais.instagrabber.utils.Constants import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.viewmodels.DirectorySelectActivityViewModel import java.io.IOException import java.io.PrintWriter import java.io.StringWriter class DirectorySelectActivity : BaseLanguageActivity() { private var initialUri: Uri? = null private lateinit var binding: ActivityDirectorySelectBinding private val viewModel: DirectorySelectActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityDirectorySelectBinding.inflate(layoutInflater) setContentView(binding.root) val intent = intent viewModel.setInitialUri(intent) setupObservers() binding.selectDir.setOnClickListener { openDirectoryChooser() } initialUri = intent.getParcelableExtra(Constants.EXTRA_INITIAL_URI) } private fun setupObservers() { viewModel.message.observe(this, { message: String? -> binding.message.text = message }) viewModel.prevUri.observe(this, { prevUri: String? -> if (prevUri == null) { binding.prevUri.visibility = View.GONE binding.message2.visibility = View.GONE return@observe } binding.prevUri.text = prevUri binding.prevUri.visibility = View.VISIBLE binding.message2.visibility = View.VISIBLE }) viewModel.dirSuccess.observe(this, { success: Boolean -> binding.selectDir.visibility = if (success) View.GONE else View.VISIBLE }) viewModel.loading.observe(this, { loading: Boolean -> binding.message.visibility = if (loading) View.GONE else View.VISIBLE binding.loadingIndicator.visibility = if (loading) View.VISIBLE else View.GONE }) } private fun openDirectoryChooser() { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && initialUri != null) { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri) } try { startActivityForResult(intent, SELECT_DIR_REQUEST_CODE) } catch (e: ActivityNotFoundException) { Log.e(TAG, "openDirectoryChooser: ", e) showErrorDialog(getString(R.string.no_directory_picker_activity)) } catch (e: Exception) { Log.e(TAG, "openDirectoryChooser: ", e) } } @SuppressLint("StringFormatInvalid") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode != SELECT_DIR_REQUEST_CODE) return if (resultCode != RESULT_OK) { showErrorDialog(getString(R.string.select_a_folder)) return } if (data == null || data.data == null) { showErrorDialog(getString(R.string.select_a_folder)) return } val authority = data.data?.authority if ("com.android.externalstorage.documents" != authority) { showErrorDialog(getString(R.string.dir_select_no_download_folder, authority)) return } mainThread.execute({ try { viewModel.setupSelectedDir(data) val intent = Intent(this, MainActivity::class.java) startActivity(intent) finish() } catch (e: Exception) { // Should not come to this point. // If it does, we have to show this error to the user so that they can report it. try { StringWriter().use { sw -> PrintWriter(sw).use { pw -> e.printStackTrace(pw) showErrorDialog("Please report this error to the developers:\n\n$sw") } } } catch (ioException: IOException) { Log.e(TAG, "onActivityResult: ", ioException) } } }, 500) } private fun showErrorDialog(message: String) { val dialogFragment = ConfirmDialogFragment.newInstance( ERROR_REQUEST_CODE, R.string.error, message, R.string.ok, 0, 0 ) dialogFragment.show(supportFragmentManager, ConfirmDialogFragment::class.java.simpleName) } companion object { const val SELECT_DIR_REQUEST_CODE = 0x01 private const val ERROR_REQUEST_CODE = 0x02 } } ================================================ FILE: app/src/main/java/awais/instagrabber/activities/Login.kt ================================================ package awais.instagrabber.activities import android.annotation.SuppressLint import android.content.Intent import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.webkit.* import android.widget.Toast import awais.instagrabber.R import awais.instagrabber.databinding.ActivityLoginBinding import awais.instagrabber.utils.Constants import awais.instagrabber.utils.getCookie class Login : BaseLanguageActivity(), View.OnClickListener { private var webViewUrl: String? = null private var ready = false private lateinit var loginBinding: ActivityLoginBinding private val webChromeClient = WebChromeClient() private val webViewClient: WebViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { webViewUrl = url } override fun onPageFinished(view: WebView, url: String) { webViewUrl = url val mainCookie = getCookie(url) if (mainCookie.isNullOrBlank() || !mainCookie.contains("; ds_user_id=")) { ready = true return } if (mainCookie.contains("; ds_user_id=") && ready) { returnCookieResult(mainCookie) } } } private fun returnCookieResult(mainCookie: String?) { val intent = Intent() intent.putExtra("cookie", mainCookie) setResult(Constants.LOGIN_RESULT_CODE, intent) finish() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loginBinding = ActivityLoginBinding.inflate(LayoutInflater.from(applicationContext)) setContentView(loginBinding.root) initWebView() loginBinding.cookies.setOnClickListener(this) loginBinding.refresh.setOnClickListener(this) } override fun onClick(v: View) { if (v === loginBinding.refresh) { loginBinding.webView.loadUrl("https://instagram.com/") return } if (v === loginBinding.cookies) { val mainCookie = getCookie(webViewUrl) if (mainCookie.isNullOrBlank() || !mainCookie.contains("; ds_user_id=")) { Toast.makeText(this, R.string.login_error_loading_cookies, Toast.LENGTH_SHORT).show() return } returnCookieResult(mainCookie) } } @SuppressLint("SetJavaScriptEnabled") private fun initWebView() { loginBinding.webView.webChromeClient = webChromeClient loginBinding.webView.webViewClient = webViewClient val webSettings = loginBinding.webView.settings webSettings.userAgentString = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.105 Mobile Safari/537.36" webSettings.javaScriptEnabled = true webSettings.domStorageEnabled = true webSettings.setSupportZoom(true) webSettings.builtInZoomControls = true webSettings.displayZoomControls = false webSettings.loadWithOverviewMode = true webSettings.useWideViewPort = true webSettings.allowFileAccessFromFileURLs = true webSettings.allowUniversalAccessFromFileURLs = true webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { CookieManager.getInstance().removeAllCookies(null) CookieManager.getInstance().flush() } else { val cookieSyncMngr = CookieSyncManager.createInstance(applicationContext) cookieSyncMngr.startSync() val cookieManager = CookieManager.getInstance() cookieManager.removeAllCookie() cookieManager.removeSessionCookie() cookieSyncMngr.stopSync() cookieSyncMngr.sync() } loginBinding.webView.loadUrl("https://instagram.com/") } override fun onPause() { loginBinding.webView.onPause() super.onPause() } override fun onResume() { super.onResume() loginBinding.webView.onResume() } override fun onDestroy() { loginBinding.webView.destroy() super.onDestroy() } } ================================================ FILE: app/src/main/java/awais/instagrabber/activities/MainActivity.kt ================================================ package awais.instagrabber.activities import android.animation.LayoutTransition import android.app.NotificationChannel import android.app.NotificationManager import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.os.* import android.provider.DocumentsContract.EXTRA_INITIAL_URI import android.text.Editable import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View import android.view.WindowManager import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.NotificationManagerCompat import androidx.core.provider.FontRequest import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat.InitCallback import androidx.emoji.text.FontRequestEmojiCompatConfig import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavGraph import androidx.navigation.NavGraphNavigator import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.* import awais.instagrabber.BuildConfig import awais.instagrabber.R import awais.instagrabber.customviews.emoji.EmojiVariantManager import awais.instagrabber.customviews.helpers.RootViewDeferringInsetsCallback import awais.instagrabber.customviews.helpers.TextWatcherAdapter import awais.instagrabber.databinding.ActivityMainBinding import awais.instagrabber.fragments.main.FeedFragment import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.models.IntentModel import awais.instagrabber.models.Resource import awais.instagrabber.models.Tab import awais.instagrabber.models.enums.IntentModelType import awais.instagrabber.services.ActivityCheckerService import awais.instagrabber.services.DMSyncAlarmReceiver import awais.instagrabber.utils.* import awais.instagrabber.utils.AppExecutors.tasksThread import awais.instagrabber.utils.DownloadUtils.ReselectDocumentTreeException import awais.instagrabber.utils.TextUtils.isEmpty import awais.instagrabber.utils.emoji.EmojiParser import awais.instagrabber.viewmodels.AppStateViewModel import awais.instagrabber.viewmodels.DirectInboxViewModel import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior import com.google.android.material.appbar.CollapsingToolbarLayout import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.textfield.TextInputLayout import com.google.common.collect.ImmutableList import java.util.* class MainActivity : BaseLanguageActivity() { private lateinit var binding: ActivityMainBinding private lateinit var navController: NavController private lateinit var appBarConfiguration: AppBarConfiguration private var searchMenuItem: MenuItem? = null private var startNavRootId: Int = 0 private var lastSelectedNavMenuId = 0 private var isActivityCheckerServiceBound = false private var isLoggedIn = false private var deviceUuid: String? = null private var csrfToken: String? = null private var userId: Long = 0 private var toolbarOwner: Fragment? = null private lateinit var toolbar: Toolbar var currentTabs: List = emptyList() private set private var showBottomViewDestinations: List = emptyList() private val serviceConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { // final ActivityCheckerService.LocalBinder binder = (ActivityCheckerService.LocalBinder) service; // final ActivityCheckerService activityCheckerService = binder.getService(); isActivityCheckerServiceBound = true } override fun onServiceDisconnected(name: ComponentName) { isActivityCheckerServiceBound = false } } override fun onCreate(savedInstanceState: Bundle?) { try { DownloadUtils.init( this, Utils.settingsHelper.getString(PreferenceKeys.PREF_BARINSTA_DIR_URI) ) } catch (e: ReselectDocumentTreeException) { super.onCreate(savedInstanceState) val intent = Intent(this, DirectorySelectActivity::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { intent.putExtra(EXTRA_INITIAL_URI, e.initialUri) } startActivity(intent) finish() return } super.onCreate(savedInstanceState) instance = this binding = ActivityMainBinding.inflate(layoutInflater) toolbar = binding.toolbar setupCookie() if (Utils.settingsHelper.getBoolean(PreferenceKeys.FLAG_SECURE)) { window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) } setContentView(binding.root) setSupportActionBar(binding.toolbar) setupInsetsCallback() createNotificationChannels() val navHostFragment = supportFragmentManager.findFragmentById(R.id.main_nav_host) as NavHostFragment navController = navHostFragment.navController if (savedInstanceState == null) { setupNavigation(true) } if (!BuildConfig.isPre) { val checkUpdates = Utils.settingsHelper.getBoolean(PreferenceKeys.CHECK_UPDATES) if (checkUpdates) FlavorTown.updateCheck(this) } FlavorTown.changelogCheck(this) ViewModelProvider(this).get(AppStateViewModel::class.java) // Just initiate the App state here handleIntent(intent) if (isLoggedIn && Utils.settingsHelper.getBoolean(PreferenceKeys.CHECK_ACTIVITY)) { bindActivityCheckerService() } // Initialise the internal map tasksThread.execute { EmojiParser.getInstance(this) EmojiVariantManager.getInstance() } initEmojiCompat() // initDmService(); initDmUnreadCount() initSearchInput() } private fun setupInsetsCallback() { val deferringInsetsCallback = RootViewDeferringInsetsCallback( WindowInsetsCompat.Type.systemBars(), WindowInsetsCompat.Type.ime() ) ViewCompat.setWindowInsetsAnimationCallback(binding.root, deferringInsetsCallback) ViewCompat.setOnApplyWindowInsetsListener(binding.root, deferringInsetsCallback) WindowCompat.setDecorFitsSystemWindows(window, false) } private fun setupCookie() { val cookie = Utils.settingsHelper.getString(Constants.COOKIE) userId = 0 csrfToken = null if (cookie.isNotBlank()) { userId = getUserIdFromCookie(cookie) csrfToken = getCsrfTokenFromCookie(cookie) } if (cookie.isBlank() || userId == 0L || csrfToken.isNullOrBlank()) { isLoggedIn = false return } deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) if (isEmpty(deviceUuid)) { Utils.settingsHelper.putString(Constants.DEVICE_UUID, UUID.randomUUID().toString()) } setupCookies(cookie) isLoggedIn = true } @Suppress("unused") private fun initDmService() { if (!isLoggedIn) return val enabled = Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH) if (!enabled) return DMSyncAlarmReceiver.setAlarm(this) } private fun initDmUnreadCount() { if (!isLoggedIn) return val directInboxViewModel = ViewModelProvider(this).get(DirectInboxViewModel::class.java) directInboxViewModel.unseenCount.observe(this, { unseenCountResource: Resource? -> if (unseenCountResource == null) return@observe val unseenCount = unseenCountResource.data setNavBarDMUnreadCountBadge(unseenCount ?: 0) }) } private fun initSearchInput() { binding.searchInputLayout.setEndIconOnClickListener { val editText = binding.searchInputLayout.editText ?: return@setEndIconOnClickListener editText.setText("") } binding.searchInputLayout.addOnEditTextAttachedListener { textInputLayout: TextInputLayout -> textInputLayout.isEndIconVisible = false val editText = textInputLayout.editText ?: return@addOnEditTextAttachedListener editText.addTextChangedListener(object : TextWatcherAdapter() { override fun afterTextChanged(s: Editable) { binding.searchInputLayout.isEndIconVisible = !isEmpty(s) } }) } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.main_menu, menu) searchMenuItem = menu.findItem(R.id.search) val currentDestination = navController.currentDestination if (currentDestination != null) { val backStack = navController.backQueue setupMenu(backStack.size, currentDestination.id) } return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.search) { try { navController.navigate(getSearchDeepLink()) return true } catch (e: Exception) { Log.e(TAG, "onOptionsItemSelected: ", e) } return false } return super.onOptionsItemSelected(item) } override fun onSaveInstanceState(outState: Bundle) { // outState.putString(FIRST_FRAGMENT_GRAPH_INDEX_KEY, firstFragmentGraphIndex.toString()) outState.putString(LAST_SELECT_NAV_MENU_ID, binding.bottomNavView.selectedItemId.toString()) super.onSaveInstanceState(outState) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) val lastSelected = savedInstanceState[LAST_SELECT_NAV_MENU_ID] as String? if (lastSelected != null) { try { lastSelectedNavMenuId = lastSelected.toInt() } catch (ignored: NumberFormatException) { } } setupNavigation(false) } override fun onSupportNavigateUp(): Boolean { return navController.navigateUp(appBarConfiguration) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntent(intent) } override fun onDestroy() { try { super.onDestroy() } catch (e: Exception) { Log.e(TAG, "onDestroy: ", e) } unbindActivityCheckerService() // try { // RetrofitFactory.getInstance().destroy() // } catch (e: Exception) { // Log.e(TAG, "onDestroy: ", e) // } DownloadUtils.destroy() instance = null } // override fun onBackPressed() { // Log.d(TAG, "onBackPressed: ") // navController.navigateUp() // val backStack = navController.backQueue // val currentNavControllerBackStack = backStack.size // if (isTaskRoot && isBackStackEmpty && currentNavControllerBackStack == 2) { // finishAfterTransition() // return // } // if (!isFinishing) { // try { // super.onBackPressed() // } catch (e: Exception) { // Log.e(TAG, "onBackPressed: ", e) // finish() // } // } // } private fun createNotificationChannels() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager.createNotificationChannel( NotificationChannel( Constants.DOWNLOAD_CHANNEL_ID, Constants.DOWNLOAD_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT ) ) notificationManager.createNotificationChannel( NotificationChannel( Constants.ACTIVITY_CHANNEL_ID, Constants.ACTIVITY_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT ) ) notificationManager.createNotificationChannel( NotificationChannel( Constants.DM_UNREAD_CHANNEL_ID, Constants.DM_UNREAD_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT ) ) val silentNotificationChannel = NotificationChannel( Constants.SILENT_NOTIFICATIONS_CHANNEL_ID, Constants.SILENT_NOTIFICATIONS_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW ) silentNotificationChannel.setSound(null, null) notificationManager.createNotificationChannel(silentNotificationChannel) } private fun setupNavigation(setDefaultTabFromSettings: Boolean) { currentTabs = if (isLoggedIn) setupMainBottomNav() else setupAnonBottomNav() showBottomViewDestinations = currentTabs.asSequence().map { it.startDestinationFragmentId }.toMutableList().apply { add(R.id.postViewFragment) add(R.id.favorites_non_top) add(R.id.notifications_viewer_non_top) add(R.id.profile_non_top) } if (setDefaultTabFromSettings) { setSelectedTab(currentTabs) } else { binding.bottomNavView.selectedItemId = lastSelectedNavMenuId } val navigatorProvider = navController.navigatorProvider val navigator = navigatorProvider.getNavigator("navigation") val rootNavGraph = NavGraph(navigator) val navInflater = navController.navInflater val topLevelDestinations = currentTabs.map { navInflater.inflate(it.navigationResId) } rootNavGraph.id = R.id.root_nav_graph rootNavGraph.label = "root_nav_graph" rootNavGraph.addDestinations(topLevelDestinations) rootNavGraph.setStartDestination(if (startNavRootId != 0) startNavRootId else R.id.profile_nav_graph) navController.graph = rootNavGraph binding.bottomNavView.setupWithNavController(navController) appBarConfiguration = AppBarConfiguration(currentTabs.map { it.startDestinationFragmentId }.toSet()) setupActionBarWithNavController(navController, appBarConfiguration) navController.addOnDestinationChangedListener { _: NavController?, destination: NavDestination, arguments: Bundle? -> if (destination.id == R.id.directMessagesThreadFragment && arguments != null) { // Set the thread title earlier for better ux val title = arguments.getString("title") if (!title.isNullOrBlank()) { supportActionBar?.title = title } } if (destination.id == R.id.profileFragment && arguments != null) { // Set the title to username val username = arguments.getString("username") if (!username.isNullOrBlank()) { supportActionBar?.title = username.substringAfter("@") } } // below is a hack to check if we are at the end of the current stack, to setup the search view binding.appBarLayout.setExpanded(true, true) val destinationId = destination.id val backStack = navController.backQueue setupMenu(backStack.size, destinationId) val contains = showBottomViewDestinations.contains(destinationId) binding.root.post { binding.bottomNavView.visibility = if (contains) View.VISIBLE else View.GONE // if (contains) { // behavior?.slideUp(binding.bottomNavView) // } } // explicitly hide keyboard when we navigate val view = currentFocus Utils.hideKeyboard(view) } setupReselection() } private fun setupReselection() { binding.bottomNavView.setOnItemReselectedListener { val navHostFragment = (supportFragmentManager.primaryNavigationFragment ?: return@setOnItemReselectedListener) as NavHostFragment val currentFragment = navHostFragment.childFragmentManager.fragments.firstOrNull() ?: return@setOnItemReselectedListener if (currentFragment is FeedFragment) { currentFragment.scrollToTop() return@setOnItemReselectedListener } val currentDestination = navController.currentDestination ?: return@setOnItemReselectedListener val currentTabStartDestId = (navController.getBackStackEntry(it.itemId).destination as NavGraph).startDestinationId if (currentDestination.id == currentTabStartDestId) return@setOnItemReselectedListener navController.popBackStack(currentTabStartDestId, false) } } private fun setSelectedTab(tabs: List) { val defaultTabResNameString = Utils.settingsHelper.getString(Constants.DEFAULT_TAB) try { var navId = 0 if (defaultTabResNameString.isNotBlank()) { navId = resources.getIdentifier(defaultTabResNameString, "id", packageName) } val startFragmentNavResId = if (navId <= 0) R.id.profile_nav_graph else navId val tab = tabs.firstOrNull { it.navigationRootId == startFragmentNavResId } // if (index < 0 || index >= tabs.size) index = 0 val firstTab = tab ?: tabs[0] startNavRootId = firstTab.navigationRootId binding.bottomNavView.selectedItemId = firstTab.navigationRootId } catch (e: Exception) { Log.e(TAG, "Error parsing id", e) } } private fun setupAnonBottomNav(): List { val selectedItemId = binding.bottomNavView.selectedItemId val anonNavTabs = getAnonNavTabs(this) val menu = binding.bottomNavView.menu menu.clear() for (tab in anonNavTabs) { menu.add(0, tab.navigationRootId, 0, tab.title).setIcon(tab.iconResId) } if (selectedItemId != R.id.profile_nav_graph && selectedItemId != R.id.more_nav_graph && selectedItemId != R.id.favorites_nav_graph) { binding.bottomNavView.selectedItemId = R.id.profile_nav_graph } return anonNavTabs } private fun setupMainBottomNav(): List { val menu = binding.bottomNavView.menu menu.clear() val navTabList = getLoggedInNavTabs(this).first for (tab in navTabList) { menu.add(0, tab.navigationRootId, 0, tab.title).setIcon(tab.iconResId) } return navTabList } private fun setupMenu(backStackSize: Int, destinationId: Int) { val searchMenuItem = searchMenuItem ?: return if (backStackSize >= 2 && SEARCH_VISIBLE_DESTINATIONS.contains(destinationId)) { searchMenuItem.isVisible = true return } searchMenuItem.isVisible = false } private fun setScrollingBehaviour() { val layoutParams = binding.mainNavHost.layoutParams as CoordinatorLayout.LayoutParams layoutParams.behavior = ScrollingViewBehavior() binding.mainNavHost.requestLayout() } private fun removeScrollingBehaviour() { val layoutParams = binding.mainNavHost.layoutParams as CoordinatorLayout.LayoutParams layoutParams.behavior = null binding.mainNavHost.requestLayout() } private fun handleIntent(intent: Intent?) { if (intent == null) return val action = intent.action val type = intent.type // Log.d(TAG, action + " " + type); if (Intent.ACTION_MAIN == action) return if (Constants.ACTION_SHOW_ACTIVITY == action) { showActivityView() return } if (Constants.ACTION_SHOW_DM_THREAD == action) { showThread(intent) return } if (Intent.ACTION_SEND == action && type != null) { if (type == "text/plain") { handleUrl(intent.getStringExtra(Intent.EXTRA_TEXT)) } return } if (Intent.ACTION_VIEW == action) { val data = intent.data ?: return handleUrl(data.toString()) } } private fun showThread(intent: Intent) { val threadId = intent.getStringExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_ID) val threadTitle = intent.getStringExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_TITLE) navigateToThread(threadId, threadTitle) } fun navigateToThread(threadId: String?, threadTitle: String?) { if (threadId == null || threadTitle == null) return try { navController.navigate(getDirectThreadDeepLink(threadId, threadTitle)) } catch (e: Exception) { Log.e(TAG, "navigateToThread: ", e) } } private fun handleUrl(url: String?) { if (url == null) return // Log.d(TAG, url); val intentModel = IntentUtils.parseUrl(url) ?: return showView(intentModel) } private fun showView(intentModel: IntentModel) { when (intentModel.type) { IntentModelType.USERNAME -> showProfileView(intentModel) IntentModelType.POST -> showPostView(intentModel) IntentModelType.LOCATION -> showLocationView(intentModel) IntentModelType.HASHTAG -> showHashtagView(intentModel) IntentModelType.UNKNOWN -> Log.w(TAG, "Unknown model type received!") // else -> Log.w(TAG, "Unknown model type received!") } } private fun showProfileView(intentModel: IntentModel) { try { val username = intentModel.text navController.navigate(getProfileDeepLink(username)) } catch (e: Exception) { Log.e(TAG, "showProfileView: ", e) } } private fun showPostView(intentModel: IntentModel) { val shortCode = intentModel.text // Log.d(TAG, "shortCode: " + shortCode); try { navController.navigate(getPostDeepLink(shortCode)) } catch (e: Exception) { Log.e(TAG, "showPostView: ", e) } } private fun showLocationView(intentModel: IntentModel) { val locationId = intentModel.text // Log.d(TAG, "locationId: " + locationId); try { navController.navigate(getLocationDeepLink(locationId)) } catch (e: Exception) { Log.e(TAG, "showLocationView: ", e) } } private fun showHashtagView(intentModel: IntentModel) { val hashtag = intentModel.text // Log.d(TAG, "hashtag: " + hashtag); try { navController.navigate(getHashtagDeepLink(hashtag)) } catch (e: Exception) { Log.e(TAG, "showHashtagView: ", e) } } private fun showActivityView() { try { navController.navigate(getNotificationsDeepLink("notif")) } catch (e: Exception) { Log.e(TAG, "showActivityView: ", e) } } private fun bindActivityCheckerService() { bindService(Intent(this, ActivityCheckerService::class.java), serviceConnection, BIND_AUTO_CREATE) isActivityCheckerServiceBound = true } private fun unbindActivityCheckerService() { if (!isActivityCheckerServiceBound) return unbindService(serviceConnection) isActivityCheckerServiceBound = false } val bottomNavView: BottomNavigationView get() = binding.bottomNavView // fun setCollapsingView(view: View) { // try { // binding.collapsingToolbarLayout.addView(view, 0) // } catch (e: Exception) { // Log.e(TAG, "setCollapsingView: ", e) // } // } // // fun removeCollapsingView(view: View) { // try { // binding.collapsingToolbarLayout.removeView(view) // } catch (e: Exception) { // Log.e(TAG, "removeCollapsingView: ", e) // } // } val collapsingToolbarView: CollapsingToolbarLayout get() = binding.collapsingToolbarLayout val appbarLayout: AppBarLayout get() = binding.appBarLayout fun removeLayoutTransition() { binding.root.layoutTransition = null } fun setLayoutTransition() { binding.root.layoutTransition = LayoutTransition() } private fun initEmojiCompat() { // Use a downloadable font for EmojiCompat val fontRequest = FontRequest( "com.google.android.gms.fonts", "com.google.android.gms", "Noto Color Emoji Compat", R.array.com_google_android_gms_fonts_certs ) val config: EmojiCompat.Config = FontRequestEmojiCompatConfig(applicationContext, fontRequest) config.setReplaceAll(true) // .setUseEmojiAsDefaultStyle(true) .registerInitCallback(object : InitCallback() { override fun onInitialized() { Log.i(TAG, "EmojiCompat initialized") } override fun onFailed(throwable: Throwable?) { Log.e(TAG, "EmojiCompat initialization failed", throwable) } }) EmojiCompat.init(config) } val rootView: View get() = binding.root private val toolbarLock = Any() fun getToolbar() = synchronized(toolbarLock) { this.toolbar } fun setToolbar(toolbar: Toolbar, owner: Fragment) = synchronized(toolbarLock) { supportActionBar?.subtitle = null toolbarOwner = owner binding.appBarLayout.visibility = View.GONE removeScrollingBehaviour() setSupportActionBar(toolbar) this.toolbar = toolbar NavigationUI.setupWithNavController(toolbar, navController, appBarConfiguration) } fun resetToolbar(owner: Fragment) = synchronized(toolbarLock) { if (owner != toolbarOwner) return this.toolbar = binding.toolbar setSupportActionBar(binding.toolbar) binding.appBarLayout.visibility = View.VISIBLE setScrollingBehaviour() setupActionBarWithNavController(navController, appBarConfiguration) toolbarOwner = null } private fun setNavBarDMUnreadCountBadge(unseenCount: Int) { val badge = binding.bottomNavView.getOrCreateBadge(R.id.direct_messages_nav_graph) if (unseenCount == 0) { badge.isVisible = false badge.clearNumber() return } if (badge.verticalOffset != 10) { badge.verticalOffset = 10 } badge.number = unseenCount badge.isVisible = true } fun showSearchView(): TextInputLayout { binding.searchInputLayout.visibility = View.VISIBLE return binding.searchInputLayout } fun hideSearchView() { binding.searchInputLayout.visibility = View.GONE } companion object { private const val TAG = "MainActivity" private const val LAST_SELECT_NAV_MENU_ID = "lastSelectedNavMenuId" private val SEARCH_VISIBLE_DESTINATIONS: List = ImmutableList.of( R.id.feedFragment, R.id.profileFragment, R.id.directMessagesInboxFragment, R.id.discoverFragment, R.id.favoritesFragment, R.id.hashTagFragment, R.id.locationFragment ) @JvmStatic var instance: MainActivity? = null private set } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/AccountSwitcherAdapter.java ================================================ package awais.instagrabber.adapters; import android.annotation.SuppressLint; import android.graphics.Typeface; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.databinding.PrefAccountSwitcherBinding; import awais.instagrabber.db.entities.Account; import awais.instagrabber.utils.Constants; import static awais.instagrabber.utils.Utils.settingsHelper; public class AccountSwitcherAdapter extends ListAdapter { private static final String TAG = "AccountSwitcherAdapter"; private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Account oldItem, @NonNull final Account newItem) { return oldItem.getUid().equals(newItem.getUid()); } @Override public boolean areContentsTheSame(@NonNull final Account oldItem, @NonNull final Account newItem) { return oldItem.getUid().equals(newItem.getUid()); } }; private final OnAccountClickListener clickListener; private final OnAccountLongClickListener longClickListener; public AccountSwitcherAdapter(final OnAccountClickListener clickListener, final OnAccountLongClickListener longClickListener) { super(DIFF_CALLBACK); this.clickListener = clickListener; this.longClickListener = longClickListener; } @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final PrefAccountSwitcherBinding binding = PrefAccountSwitcherBinding.inflate(layoutInflater, parent, false); return new ViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) { final Account model = getItem(position); if (model == null) return; final String cookie = settingsHelper.getString(Constants.COOKIE); final boolean isCurrent = model.getCookie().equals(cookie); holder.bind(model, isCurrent, clickListener, longClickListener); } public interface OnAccountClickListener { void onAccountClick(final Account model, final boolean isCurrent); } public interface OnAccountLongClickListener { boolean onAccountLongClick(final Account model, final boolean isCurrent); } public static class ViewHolder extends RecyclerView.ViewHolder { private final PrefAccountSwitcherBinding binding; public ViewHolder(final PrefAccountSwitcherBinding binding) { super(binding.getRoot()); this.binding = binding; binding.arrowDown.setImageResource(R.drawable.ic_check_24); } @SuppressLint("SetTextI18n") public void bind(final Account model, final boolean isCurrent, final OnAccountClickListener clickListener, final OnAccountLongClickListener longClickListener) { // Log.d(TAG, model.getFullName()); itemView.setOnClickListener(v -> { if (clickListener == null) return; clickListener.onAccountClick(model, isCurrent); }); itemView.setOnLongClickListener(v -> { if (longClickListener == null) return false; return longClickListener.onAccountLongClick(model, isCurrent); }); binding.profilePic.setImageURI(model.getProfilePic()); binding.username.setText("@" + model.getUsername()); binding.fullName.setTypeface(null); final String fullName = model.getFullName(); if (TextUtils.isEmpty(fullName)) { binding.fullName.setVisibility(View.GONE); } else { binding.fullName.setVisibility(View.VISIBLE); binding.fullName.setText(fullName); } if (!isCurrent) { binding.arrowDown.setVisibility(View.GONE); return; } binding.fullName.setTypeface(binding.fullName.getTypeface(), Typeface.BOLD); binding.arrowDown.setVisibility(View.VISIBLE); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/CommentsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.Objects; import awais.instagrabber.adapters.viewholder.CommentViewHolder; import awais.instagrabber.databinding.ItemCommentBinding; import awais.instagrabber.models.Comment; public final class CommentsAdapter extends ListAdapter { private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Comment oldItem, @NonNull final Comment newItem) { return Objects.equals(oldItem.getPk(), newItem.getPk()); } @Override public boolean areContentsTheSame(@NonNull final Comment oldItem, @NonNull final Comment newItem) { return Objects.equals(oldItem, newItem); } }; private final boolean showingReplies; private final CommentCallback commentCallback; private final long currentUserId; public CommentsAdapter(final long currentUserId, final boolean showingReplies, final CommentCallback commentCallback) { super(DIFF_CALLBACK); this.showingReplies = showingReplies; this.currentUserId = currentUserId; this.commentCallback = commentCallback; } @NonNull @Override public CommentViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemCommentBinding binding = ItemCommentBinding.inflate(layoutInflater, parent, false); return new CommentViewHolder(binding, currentUserId, commentCallback); } @Override public void onBindViewHolder(@NonNull final CommentViewHolder holder, final int position) { final Comment comment = getItem(position); holder.bind(comment, showingReplies && position == 0, showingReplies && position != 0); } public interface CommentCallback { void onClick(final Comment comment); void onHashtagClick(final String hashtag); void onMentionClick(final String mention); void onURLClick(final String url); void onEmailClick(final String emailAddress); void onLikeClick(final Comment comment, boolean liked, final boolean isReply); void onRepliesClick(final Comment comment); void onViewLikes(Comment comment); void onTranslate(Comment comment); void onDelete(Comment comment, boolean isReply); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/DirectItemsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.AdapterListUpdateCallback; import androidx.recyclerview.widget.AsyncDifferConfig; import androidx.recyclerview.widget.AsyncListDiffer; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.function.Function; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemActionLogViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemAnimatedMediaViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemDefaultViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemLikeViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemLinkViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemMediaShareViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemMediaViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemPlaceholderViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemProfileViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemRavenMediaViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemReelShareViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemStoryShareViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemTextViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemVideoCallEventViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemVoiceMediaViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemXmaViewHolder; import awais.instagrabber.customviews.emoji.Emoji; import awais.instagrabber.databinding.LayoutDmActionLogBinding; import awais.instagrabber.databinding.LayoutDmAnimatedMediaBinding; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmHeaderBinding; import awais.instagrabber.databinding.LayoutDmLikeBinding; import awais.instagrabber.databinding.LayoutDmLinkBinding; import awais.instagrabber.databinding.LayoutDmMediaBinding; import awais.instagrabber.databinding.LayoutDmMediaShareBinding; import awais.instagrabber.databinding.LayoutDmProfileBinding; import awais.instagrabber.databinding.LayoutDmRavenMediaBinding; import awais.instagrabber.databinding.LayoutDmReelShareBinding; import awais.instagrabber.databinding.LayoutDmStoryShareBinding; import awais.instagrabber.databinding.LayoutDmTextBinding; import awais.instagrabber.databinding.LayoutDmVoiceMediaBinding; import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; import awais.instagrabber.repositories.responses.directmessages.DirectThread; public final class DirectItemsAdapter extends RecyclerView.Adapter { private static final String TAG = DirectItemsAdapter.class.getSimpleName(); private List items; private DirectThread thread; private DirectItemViewHolder selectedViewHolder; private final User currentUser; private final DirectItemCallback callback; private final AsyncListDiffer differ; private final DirectItemInternalLongClickListener longClickListener; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final DirectItemOrHeader oldItem, @NonNull final DirectItemOrHeader newItem) { final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); boolean areSameType = bothHeaders || bothItems; if (!areSameType) return false; if (bothHeaders) { return oldItem.date.equals(newItem.date); } if (oldItem.item != null && newItem.item != null) { String oldClientContext = oldItem.item.getClientContext(); if (oldClientContext == null) { oldClientContext = oldItem.item.getItemId(); } String newClientContext = newItem.item.getClientContext(); if (newClientContext == null) { newClientContext = newItem.item.getItemId(); } return oldClientContext.equals(newClientContext); } return false; } @Override public boolean areContentsTheSame(@NonNull final DirectItemOrHeader oldItem, @NonNull final DirectItemOrHeader newItem) { final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); boolean areSameType = bothHeaders || bothItems; if (!areSameType) return false; if (bothHeaders) { return oldItem.date.equals(newItem.date); } final boolean timestampEqual = oldItem.item.getTimestamp() == newItem.item.getTimestamp(); final boolean bothPending = oldItem.item.isPending() == newItem.item.isPending(); final boolean reactionSame = Objects.equals(oldItem.item.getReactions(), newItem.item.getReactions()); return timestampEqual && bothPending && reactionSame; } }; public DirectItemsAdapter(@NonNull final User currentUser, @NonNull final DirectThread thread, @NonNull final DirectItemCallback callback, @NonNull final DirectItemLongClickListener itemLongClickListener) { this.currentUser = currentUser; this.thread = thread; this.callback = callback; differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), new AsyncDifferConfig.Builder<>(diffCallback).build()); longClickListener = (position, viewHolder) -> { if (selectedViewHolder != null) { selectedViewHolder.setSelected(false); } selectedViewHolder = viewHolder; viewHolder.setSelected(true); itemLongClickListener.onLongClick(position); }; } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); if (type == -1) { // header return new HeaderViewHolder(LayoutDmHeaderBinding.inflate(layoutInflater, parent, false)); } final LayoutDmBaseBinding baseBinding = LayoutDmBaseBinding.inflate(layoutInflater, parent, false); final DirectItemType directItemType = DirectItemType.Companion.getTypeFromId(type); final DirectItemViewHolder itemViewHolder = getItemViewHolder(layoutInflater, baseBinding, directItemType); itemViewHolder.setLongClickListener(longClickListener); return itemViewHolder; } @NonNull private DirectItemViewHolder getItemViewHolder(final LayoutInflater layoutInflater, final LayoutDmBaseBinding baseBinding, @NonNull final DirectItemType directItemType) { switch (directItemType) { case TEXT: { final LayoutDmTextBinding binding = LayoutDmTextBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemTextViewHolder(baseBinding, binding, currentUser, thread, callback); } case LIKE: { final LayoutDmLikeBinding binding = LayoutDmLikeBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemLikeViewHolder(baseBinding, binding, currentUser, thread, callback); } case LINK: { final LayoutDmLinkBinding binding = LayoutDmLinkBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemLinkViewHolder(baseBinding, binding, currentUser, thread, callback); } case ACTION_LOG: { final LayoutDmActionLogBinding binding = LayoutDmActionLogBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemActionLogViewHolder(baseBinding, binding, currentUser, thread, callback); } case VIDEO_CALL_EVENT: { final LayoutDmActionLogBinding binding = LayoutDmActionLogBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemVideoCallEventViewHolder(baseBinding, binding, currentUser, thread, callback); } case PLACEHOLDER: { final LayoutDmStoryShareBinding binding = LayoutDmStoryShareBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemPlaceholderViewHolder(baseBinding, binding, currentUser, thread, callback); } case ANIMATED_MEDIA: { final LayoutDmAnimatedMediaBinding binding = LayoutDmAnimatedMediaBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemAnimatedMediaViewHolder(baseBinding, binding, currentUser, thread, callback); } case VOICE_MEDIA: { final LayoutDmVoiceMediaBinding binding = LayoutDmVoiceMediaBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemVoiceMediaViewHolder(baseBinding, binding, currentUser, thread, callback); } case LOCATION: case PROFILE: { final LayoutDmProfileBinding binding = LayoutDmProfileBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemProfileViewHolder(baseBinding, binding, currentUser, thread, callback); } case MEDIA: { final LayoutDmMediaBinding binding = LayoutDmMediaBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemMediaViewHolder(baseBinding, binding, currentUser, thread, callback); } case CLIP: case FELIX_SHARE: case MEDIA_SHARE: { final LayoutDmMediaShareBinding binding = LayoutDmMediaShareBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemMediaShareViewHolder(baseBinding, binding, currentUser, thread, callback); } case STORY_SHARE: { final LayoutDmStoryShareBinding binding = LayoutDmStoryShareBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemStoryShareViewHolder(baseBinding, binding, currentUser, thread, callback); } case REEL_SHARE: { final LayoutDmReelShareBinding binding = LayoutDmReelShareBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemReelShareViewHolder(baseBinding, binding, currentUser, thread, callback); } case RAVEN_MEDIA: { final LayoutDmRavenMediaBinding binding = LayoutDmRavenMediaBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemRavenMediaViewHolder(baseBinding, binding, currentUser, thread, callback); } case XMA: { final LayoutDmAnimatedMediaBinding binding = LayoutDmAnimatedMediaBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemXmaViewHolder(baseBinding, binding, currentUser, thread, callback); } case UNKNOWN: default: { final LayoutDmTextBinding binding = LayoutDmTextBinding.inflate(layoutInflater, baseBinding.message, false); return new DirectItemDefaultViewHolder(baseBinding, binding, currentUser, thread, callback); } } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { final DirectItemOrHeader itemOrHeader = getItem(position); if (itemOrHeader.isHeader()) { ((HeaderViewHolder) holder).bind(itemOrHeader.date); return; } if (thread == null) return; ((DirectItemViewHolder) holder).bind(position, itemOrHeader.item); } protected DirectItemOrHeader getItem(int position) { return differ.getCurrentList().get(position); } @Override public int getItemCount() { return differ.getCurrentList().size(); } @Override public int getItemViewType(final int position) { final DirectItemOrHeader itemOrHeader = getItem(position); if (itemOrHeader.isHeader()) { return -1; } final DirectItemType itemType = itemOrHeader.item.getItemType(); if (itemType == null) { return 0; } return itemType.getId(); } @Override public long getItemId(final int position) { final DirectItemOrHeader itemOrHeader = getItem(position); if (itemOrHeader.isHeader()) { return itemOrHeader.date.hashCode(); } if (itemOrHeader.item.getClientContext() == null) { return itemOrHeader.item.getItemId().hashCode(); } return itemOrHeader.item.getClientContext().hashCode(); } public void setThread(final DirectThread thread) { if (thread == null) return; this.thread = thread; // notifyDataSetChanged(); } public void submitList(@Nullable final List list) { if (list == null) { differ.submitList(null); return; } differ.submitList(sectionAndSort(list)); this.items = list; } public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { if (list == null) { differ.submitList(null, commitCallback); return; } differ.submitList(sectionAndSort(list), commitCallback); this.items = list; } private List sectionAndSort(final List list) { final List itemOrHeaders = new ArrayList<>(); LocalDate prevSectionDate = null; for (int i = 0; i < list.size(); i++) { final DirectItem item = list.get(i); if (item == null || item.getDate() == null) continue; final DirectItemOrHeader prev = itemOrHeaders.isEmpty() ? null : itemOrHeaders.get(itemOrHeaders.size() - 1); if (prev != null && prev.item != null && prev.item.getDate() != null && prev.item.getDate().toLocalDate().isEqual(item.getDate().toLocalDate())) { // just add item final DirectItemOrHeader itemOrHeader = new DirectItemOrHeader(); itemOrHeader.item = item; itemOrHeaders.add(itemOrHeader); if (i == list.size() - 1) { // add header final DirectItemOrHeader itemOrHeader2 = new DirectItemOrHeader(); itemOrHeader2.date = prevSectionDate; itemOrHeaders.add(itemOrHeader2); } continue; } if (prevSectionDate != null) { // add header final DirectItemOrHeader itemOrHeader = new DirectItemOrHeader(); itemOrHeader.date = prevSectionDate; itemOrHeaders.add(itemOrHeader); } // Add item final DirectItemOrHeader itemOrHeader = new DirectItemOrHeader(); itemOrHeader.item = item; itemOrHeaders.add(itemOrHeader); prevSectionDate = item.getDate().toLocalDate(); } return itemOrHeaders; } public List getList() { return differ.getCurrentList(); } public List getItems() { return items; } @Override public void onViewRecycled(@NonNull final RecyclerView.ViewHolder holder) { if (holder instanceof DirectItemViewHolder) { ((DirectItemViewHolder) holder).cleanup(); } } @Override public void onViewDetachedFromWindow(@NonNull final RecyclerView.ViewHolder holder) { if (holder instanceof DirectItemViewHolder) { ((DirectItemViewHolder) holder).cleanup(); } } public DirectThread getThread() { return thread; } public static class DirectItemOrHeader { LocalDate date; public DirectItem item; public boolean isHeader() { return date != null; } @NonNull @Override public String toString() { return "DirectItemOrHeader{" + "date=" + date + ", item=" + (item != null ? item.getItemType() : null) + '}'; } } public static class HeaderViewHolder extends RecyclerView.ViewHolder { private final LayoutDmHeaderBinding binding; public HeaderViewHolder(@NonNull final LayoutDmHeaderBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final LocalDate date) { if (date == null) { binding.header.setText(""); return; } final DateTimeFormatter dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT); binding.header.setText(dateFormatter.format(date)); } } public interface DirectItemCallback { void onHashtagClick(String hashtag); void onMentionClick(String mention); void onLocationClick(long locationId); void onURLClick(String url); void onEmailClick(String email); void onMediaClick(Media media, int index); void onStoryClick(DirectItemStoryShare storyShare); void onReaction(DirectItem item, Emoji emoji); void onReactionClick(DirectItem item, int position); void onOptionSelect(DirectItem item, @IdRes int itemId, final Function callback); void onAddReactionListener(DirectItem item); } public interface DirectItemInternalLongClickListener { void onLongClick(int position, DirectItemViewHolder viewHolder); } public interface DirectItemLongClickListener { void onLongClick(int position); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/DirectMessageInboxAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.AsyncDifferConfig; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.List; import java.util.Objects; import awais.instagrabber.adapters.viewholder.directmessages.DirectInboxItemViewHolder; import awais.instagrabber.databinding.LayoutDmInboxItemBinding; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; public final class DirectMessageInboxAdapter extends ListAdapter { private final OnItemClickListener onClickListener; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final DirectThread oldItem, @NonNull final DirectThread newItem) { return oldItem.getThreadId().equals(newItem.getThreadId()); } @Override public boolean areContentsTheSame(@NonNull final DirectThread oldThread, @NonNull final DirectThread newThread) { final boolean titleEqual = oldThread.getThreadTitle().equals(newThread.getThreadTitle()); if (!titleEqual) return false; final boolean lastSeenAtEqual = Objects.equals(oldThread.getLastSeenAt(), newThread.getLastSeenAt()); if (!lastSeenAtEqual) return false; final List oldItems = oldThread.getItems(); final List newItems = newThread.getItems(); if (oldItems == null || newItems == null) return false; if (oldItems.size() != newItems.size()) return false; final DirectItem oldItemFirst = oldThread.getFirstDirectItem(); final DirectItem newItemFirst = newThread.getFirstDirectItem(); if (oldItemFirst == null || newItemFirst == null) return false; final boolean idsEqual = oldItemFirst.getItemId().equals(newItemFirst.getItemId()); if (!idsEqual) return false; return oldItemFirst.getTimestamp() == newItemFirst.getTimestamp(); } }; public DirectMessageInboxAdapter(final OnItemClickListener onClickListener) { super(new AsyncDifferConfig.Builder<>(diffCallback).build()); this.onClickListener = onClickListener; } @NonNull @Override public DirectInboxItemViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final LayoutDmInboxItemBinding binding = LayoutDmInboxItemBinding.inflate(layoutInflater, parent, false); return new DirectInboxItemViewHolder(binding, onClickListener); } @Override public void onBindViewHolder(@NonNull final DirectInboxItemViewHolder holder, final int position) { final DirectThread thread = getItem(position); holder.bind(thread); } @Override public long getItemId(final int position) { return getItem(position).getThreadId().hashCode(); } public interface OnItemClickListener { void onItemClick(final DirectThread thread); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/DirectPendingUsersAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.adapters.viewholder.directmessages.DirectPendingUserViewHolder; import awais.instagrabber.databinding.LayoutDmPendingUserItemBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectThreadParticipantRequestsResponse; public final class DirectPendingUsersAdapter extends ListAdapter { private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final PendingUser oldItem, @NonNull final PendingUser newItem) { return oldItem.user.getPk() == newItem.user.getPk(); } @Override public boolean areContentsTheSame(@NonNull final PendingUser oldItem, @NonNull final PendingUser newItem) { return Objects.equals(oldItem.user.getUsername(), newItem.user.getUsername()) && Objects.equals(oldItem.user.getFullName(), newItem.user.getFullName()) && Objects.equals(oldItem.requester, newItem.requester); } }; private final PendingUserCallback callback; public DirectPendingUsersAdapter(final PendingUserCallback callback) { super(DIFF_CALLBACK); this.callback = callback; setHasStableIds(true); } public void submitPendingRequests(final DirectThreadParticipantRequestsResponse requests) { if (requests == null || requests.getUsers() == null) { submitList(Collections.emptyList()); return; } submitList(parse(requests)); } private List parse(final DirectThreadParticipantRequestsResponse requests) { final List users = requests.getUsers(); final Map requesterUsernames = requests.getRequesterUsernames(); return users.stream() .map(user -> new PendingUser(user, requesterUsernames.get(user.getPk()))) .collect(Collectors.toList()); } @NonNull @Override public DirectPendingUserViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final LayoutDmPendingUserItemBinding binding = LayoutDmPendingUserItemBinding.inflate(layoutInflater, parent, false); return new DirectPendingUserViewHolder(binding, callback); } @Override public void onBindViewHolder(@NonNull final DirectPendingUserViewHolder holder, final int position) { final PendingUser pendingUser = getItem(position); holder.bind(position, pendingUser); } @Override public long getItemId(final int position) { final PendingUser item = getItem(position); return item.user.getPk(); } public static class PendingUser { private final User user; private final String requester; private boolean inProgress; public PendingUser(final User user, final String requester) { this.user = user; this.requester = requester; } public User getUser() { return user; } public String getRequester() { return requester; } public boolean isInProgress() { return inProgress; } public PendingUser setInProgress(final boolean inProgress) { this.inProgress = inProgress; return this; } } public interface PendingUserCallback { void onClick(int position, PendingUser pendingUser); void onApprove(int position, PendingUser pendingUser); void onDeny(int position, PendingUser pendingUser); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/DirectReactionsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.List; import awais.instagrabber.adapters.viewholder.directmessages.DirectReactionViewHolder; import awais.instagrabber.databinding.LayoutDmUserItemBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; public final class DirectReactionsAdapter extends ListAdapter { private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final DirectItemEmojiReaction oldItem, @NonNull final DirectItemEmojiReaction newItem) { return oldItem.getSenderId() == newItem.getSenderId(); } @Override public boolean areContentsTheSame(@NonNull final DirectItemEmojiReaction oldItem, @NonNull final DirectItemEmojiReaction newItem) { return oldItem.getEmoji().equals(newItem.getEmoji()); } }; private final long viewerId; private final List users; private final String itemId; private final OnReactionClickListener onReactionClickListener; public DirectReactionsAdapter(final long viewerId, final List users, final String itemId, final OnReactionClickListener onReactionClickListener) { super(DIFF_CALLBACK); this.viewerId = viewerId; this.users = users; this.itemId = itemId; this.onReactionClickListener = onReactionClickListener; setHasStableIds(true); } @NonNull @Override public DirectReactionViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); return new DirectReactionViewHolder(binding, viewerId, itemId, onReactionClickListener); } @Override public void onBindViewHolder(@NonNull final DirectReactionViewHolder holder, final int position) { final DirectItemEmojiReaction reaction = getItem(position); if (reaction == null) return; holder.bind(reaction, getUser(reaction.getSenderId())); } @Override public long getItemId(final int position) { return getItem(position).getSenderId(); } @Nullable private User getUser(final long pk) { return users.stream() .filter(user -> user.getPk() == pk) .findFirst() .orElse(null); } public interface OnReactionClickListener { void onReactionClick(String itemId, DirectItemEmojiReaction reaction); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/DirectUsersAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.directmessages.DirectUserViewHolder; import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; import awais.instagrabber.databinding.LayoutDmUserItemBinding; import awais.instagrabber.repositories.responses.User; public final class DirectUsersAdapter extends ListAdapter { private static final int VIEW_TYPE_HEADER = 0; private static final int VIEW_TYPE_USER = 1; private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final DirectUserOrHeader oldItem, @NonNull final DirectUserOrHeader newItem) { final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); boolean areSameType = bothHeaders || bothItems; if (!areSameType) return false; if (bothHeaders) { return oldItem.headerTitle == newItem.headerTitle; } if (oldItem.user != null && newItem.user != null) { return oldItem.user.getPk() == newItem.user.getPk(); } return false; } @Override public boolean areContentsTheSame(@NonNull final DirectUserOrHeader oldItem, @NonNull final DirectUserOrHeader newItem) { final boolean bothHeaders = oldItem.isHeader() && newItem.isHeader(); final boolean bothItems = !oldItem.isHeader() && !newItem.isHeader(); boolean areSameType = bothHeaders || bothItems; if (!areSameType) return false; if (bothHeaders) { return oldItem.headerTitle == newItem.headerTitle; } if (oldItem.user != null && newItem.user != null) { return oldItem.user.getUsername().equals(newItem.user.getUsername()) && oldItem.user.getFullName().equals(newItem.user.getFullName()); } return false; } }; private final long inviterId; private final OnDirectUserClickListener onClickListener; private final OnDirectUserLongClickListener onLongClickListener; private List adminUserIds; public DirectUsersAdapter(final long inviterId, final OnDirectUserClickListener onClickListener, final OnDirectUserLongClickListener onLongClickListener) { super(DIFF_CALLBACK); this.inviterId = inviterId; this.onClickListener = onClickListener; this.onLongClickListener = onLongClickListener; setHasStableIds(true); } public void submitUsers(final List users, final List leftUsers) { if (users == null && leftUsers == null) return; final List userOrHeaders = combineLists(users, leftUsers); submitList(userOrHeaders); } private List combineLists(final List users, final List leftUsers) { final ImmutableList.Builder listBuilder = ImmutableList.builder(); if (users != null && !users.isEmpty()) { listBuilder.add(new DirectUserOrHeader(R.string.members)); users.stream() .map(DirectUserOrHeader::new) .forEach(listBuilder::add); } if (leftUsers != null && !leftUsers.isEmpty()) { listBuilder.add(new DirectUserOrHeader(R.string.dms_left_users)); leftUsers.stream() .map(DirectUserOrHeader::new) .forEach(listBuilder::add); } return listBuilder.build(); } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); switch (viewType) { case VIEW_TYPE_USER: final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); return new DirectUserViewHolder(binding, onClickListener, onLongClickListener); case VIEW_TYPE_HEADER: default: final ItemFavSectionHeaderBinding headerBinding = ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false); return new HeaderViewHolder(headerBinding); } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { if (holder instanceof HeaderViewHolder) { ((HeaderViewHolder) holder).bind(getItem(position).headerTitle); return; } if (holder instanceof DirectUserViewHolder) { final User user = getItem(position).user; ((DirectUserViewHolder) holder).bind(position, user, user != null && adminUserIds != null && adminUserIds.contains(user.getPk()), user != null && user.getPk() == inviterId, false, false); } } @Override public int getItemViewType(final int position) { final DirectUserOrHeader item = getItem(position); return item.isHeader() ? VIEW_TYPE_HEADER : VIEW_TYPE_USER; } @Override public long getItemId(final int position) { final DirectUserOrHeader item = getItem(position); return item.isHeader() ? item.headerTitle : item.user.getPk(); } public void setAdminUserIds(final List adminUserIds) { this.adminUserIds = adminUserIds; notifyDataSetChanged(); } public static class DirectUserOrHeader { int headerTitle; User user; public DirectUserOrHeader(final int headerTitle) { this.headerTitle = headerTitle; } public DirectUserOrHeader(final User user) { this.user = user; } boolean isHeader() { return headerTitle > 0; } } public static class HeaderViewHolder extends RecyclerView.ViewHolder { private final ItemFavSectionHeaderBinding binding; public HeaderViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(@StringRes final int headerTitle) { binding.getRoot().setText(headerTitle); } } public interface OnDirectUserClickListener { void onClick(int position, User user, boolean selected); } public interface OnDirectUserLongClickListener { boolean onLongClick(int position, User user); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/DirectoryFilesAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import java.io.File; import awais.instagrabber.R; import awais.instagrabber.databinding.ItemDirListBinding; public final class DirectoryFilesAdapter extends ListAdapter { private final OnFileClickListener onFileClickListener; private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final File oldItem, @NonNull final File newItem) { return oldItem.getAbsolutePath().equals(newItem.getAbsolutePath()); } @Override public boolean areContentsTheSame(@NonNull final File oldItem, @NonNull final File newItem) { return oldItem.getAbsolutePath().equals(newItem.getAbsolutePath()); } }; public DirectoryFilesAdapter(final OnFileClickListener onFileClickListener) { super(DIFF_CALLBACK); this.onFileClickListener = onFileClickListener; } @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); final ItemDirListBinding binding = ItemDirListBinding.inflate(inflater, parent, false); return new ViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) { final File file = getItem(position); holder.bind(file, onFileClickListener); } public interface OnFileClickListener { void onFileClick(File file); } static final class ViewHolder extends RecyclerView.ViewHolder { private final ItemDirListBinding binding; private ViewHolder(final ItemDirListBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final File file, final OnFileClickListener onFileClickListener) { if (file == null) return; if (onFileClickListener != null) { itemView.setOnClickListener(v -> onFileClickListener.onFileClick(file)); } binding.text.setText(file.getName()); if (file.isDirectory()) { binding.icon.setImageResource(R.drawable.ic_folder_24); return; } binding.icon.setImageResource(R.drawable.ic_file_24); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/DiscoverTopicsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import awais.instagrabber.adapters.viewholder.TopicClusterViewHolder; import awais.instagrabber.databinding.ItemDiscoverTopicBinding; import awais.instagrabber.repositories.responses.discover.TopicCluster; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.ResponseBodyUtils; public class DiscoverTopicsAdapter extends ListAdapter { private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final TopicCluster oldItem, @NonNull final TopicCluster newItem) { return oldItem.getId().equals(newItem.getId()); } @Override public boolean areContentsTheSame(@NonNull final TopicCluster oldItem, @NonNull final TopicCluster newItem) { final String oldThumbUrl = ResponseBodyUtils.getThumbUrl(oldItem.getCoverMedia()); return oldThumbUrl != null && oldThumbUrl.equals(ResponseBodyUtils.getThumbUrl(newItem.getCoverMedia())) && oldItem.getTitle().equals(newItem.getTitle()); } }; private final OnTopicClickListener onTopicClickListener; public DiscoverTopicsAdapter(final OnTopicClickListener onTopicClickListener) { super(DIFF_CALLBACK); this.onTopicClickListener = onTopicClickListener; } @NonNull @Override public TopicClusterViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemDiscoverTopicBinding binding = ItemDiscoverTopicBinding.inflate(layoutInflater, parent, false); return new TopicClusterViewHolder(binding, onTopicClickListener, null); } @Override public void onBindViewHolder(@NonNull final TopicClusterViewHolder holder, final int position) { final TopicCluster topicCluster = getItem(position); holder.bind(topicCluster); } public interface OnTopicClickListener { void onTopicClick(TopicCluster topicCluster, View cover, int titleColor, int backgroundColor); void onTopicLongClick(Media coverMedia); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/FavoritesAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.ObjectsCompat; import androidx.recyclerview.widget.AdapterListUpdateCallback; import androidx.recyclerview.widget.AsyncDifferConfig; import androidx.recyclerview.widget.AsyncListDiffer; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.Collections; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.FavoriteViewHolder; import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; import awais.instagrabber.databinding.ItemSearchResultBinding; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.models.enums.FavoriteType; public class FavoritesAdapter extends RecyclerView.Adapter { private final OnFavoriteClickListener clickListener; private final OnFavoriteLongClickListener longClickListener; private final AsyncListDiffer differ; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final FavoriteModelOrHeader oldItem, @NonNull final FavoriteModelOrHeader newItem) { boolean areSame = oldItem.isHeader() && newItem.isHeader(); if (!areSame) { return false; } if (oldItem.isHeader()) { return ObjectsCompat.equals(oldItem.header, newItem.header); } if (oldItem.model != null && newItem.model != null) { return oldItem.model.getId() == newItem.model.getId(); } return false; } @Override public boolean areContentsTheSame(@NonNull final FavoriteModelOrHeader oldItem, @NonNull final FavoriteModelOrHeader newItem) { boolean areSame = oldItem.isHeader() && newItem.isHeader(); if (!areSame) { return false; } if (oldItem.isHeader()) { return ObjectsCompat.equals(oldItem.header, newItem.header); } return ObjectsCompat.equals(oldItem.model, newItem.model); } }; public FavoritesAdapter(final OnFavoriteClickListener clickListener, final OnFavoriteLongClickListener longClickListener) { this.clickListener = clickListener; this.longClickListener = longClickListener; differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), new AsyncDifferConfig.Builder<>(diffCallback).build()); } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); if (viewType == 0) { // header return new FavSectionViewHolder(ItemFavSectionHeaderBinding.inflate(inflater, parent, false)); } final ItemSearchResultBinding binding = ItemSearchResultBinding.inflate(inflater, parent, false); return new FavoriteViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { if (getItemViewType(position) == 0) { final FavoriteModelOrHeader modelOrHeader = getItem(position); if (!modelOrHeader.isHeader()) return; ((FavSectionViewHolder) holder).bind(modelOrHeader.header); return; } ((FavoriteViewHolder) holder).bind(getItem(position).model, clickListener, longClickListener); } protected FavoriteModelOrHeader getItem(int position) { return differ.getCurrentList().get(position); } @Override public int getItemCount() { return differ.getCurrentList().size(); } @Override public int getItemViewType(final int position) { return getItem(position).isHeader() ? 0 : 1; } public void submitList(@Nullable final List list) { if (list == null) { differ.submitList(null); return; } differ.submitList(sectionAndSort(list)); } public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { if (list == null) { differ.submitList(null, commitCallback); return; } differ.submitList(sectionAndSort(list), commitCallback); } @NonNull private List sectionAndSort(@NonNull final List list) { final List listCopy = new ArrayList<>(list); Collections.sort(listCopy, (o1, o2) -> { if (o1.getType() == o2.getType()) return 0; // keep users at top if (o1.getType() == FavoriteType.USER) return -1; if (o2.getType() == FavoriteType.USER) return 1; // keep locations at bottom if (o1.getType() == FavoriteType.LOCATION) return 1; if (o2.getType() == FavoriteType.LOCATION) return -1; return 0; }); final List modelOrHeaders = new ArrayList<>(); for (int i = 0; i < listCopy.size(); i++) { final Favorite model = listCopy.get(i); final FavoriteModelOrHeader prev = modelOrHeaders.isEmpty() ? null : modelOrHeaders.get(modelOrHeaders.size() - 1); boolean prevWasSameType = prev != null && prev.model.getType() == model.getType(); if (prevWasSameType) { // just add model final FavoriteModelOrHeader modelOrHeader = new FavoriteModelOrHeader(); modelOrHeader.model = model; modelOrHeaders.add(modelOrHeader); continue; } // add header and model FavoriteModelOrHeader modelOrHeader = new FavoriteModelOrHeader(); modelOrHeader.header = model.getType(); modelOrHeaders.add(modelOrHeader); modelOrHeader = new FavoriteModelOrHeader(); modelOrHeader.model = model; modelOrHeaders.add(modelOrHeader); } return modelOrHeaders; } private static class FavoriteModelOrHeader { FavoriteType header; Favorite model; boolean isHeader() { return header != null; } } public interface OnFavoriteClickListener { void onClick(final Favorite model); } public interface OnFavoriteLongClickListener { boolean onLongClick(final Favorite model); } public static class FavSectionViewHolder extends RecyclerView.ViewHolder { private final ItemFavSectionHeaderBinding binding; public FavSectionViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final FavoriteType header) { if (header == null) return; final int headerText; switch (header) { case USER: headerText = R.string.accounts; break; case HASHTAG: headerText = R.string.hashtags; break; case LOCATION: headerText = R.string.locations; break; default: headerText = R.string.unknown; break; } binding.getRoot().setText(headerText); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/FeedAdapterV2.java ================================================ package awais.instagrabber.adapters; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import java.util.HashSet; import java.util.Objects; import java.util.Set; import awais.instagrabber.adapters.viewholder.FeedGridItemViewHolder; import awais.instagrabber.adapters.viewholder.feed.FeedItemViewHolder; import awais.instagrabber.adapters.viewholder.feed.FeedPhotoViewHolder; import awais.instagrabber.adapters.viewholder.feed.FeedSliderViewHolder; import awais.instagrabber.adapters.viewholder.feed.FeedVideoViewHolder; import awais.instagrabber.databinding.ItemFeedGridBinding; import awais.instagrabber.databinding.ItemFeedPhotoBinding; import awais.instagrabber.databinding.ItemFeedSliderBinding; import awais.instagrabber.databinding.ItemFeedVideoBinding; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Caption; import awais.instagrabber.repositories.responses.Media; public final class FeedAdapterV2 extends ListAdapter { private static final String TAG = "FeedAdapterV2"; private final FeedItemCallback feedItemCallback; private final SelectionModeCallback selectionModeCallback; private final Set selectedPositions = new HashSet<>(); private final Set selectedFeedModels = new HashSet<>(); private PostsLayoutPreferences layoutPreferences; private boolean selectionModeActive = false; private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { return Objects.equals(oldItem.getPk(), newItem.getPk()); } @Override public boolean areContentsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { final Caption oldItemCaption = oldItem.getCaption(); final Caption newItemCaption = newItem.getCaption(); return Objects.equals(oldItem.getPk(), newItem.getPk()) && Objects.equals(getCaptionText(oldItemCaption), getCaptionText(newItemCaption)); } private String getCaptionText(final Caption caption) { if (caption == null) return null; return caption.getText(); } }; private final AdapterSelectionCallback adapterSelectionCallback = new AdapterSelectionCallback() { @Override public boolean onPostLongClick(final int position, final Media feedModel) { if (!selectionModeActive) { selectionModeActive = true; notifyDataSetChanged(); if (selectionModeCallback != null) { selectionModeCallback.onSelectionStart(); } } selectedPositions.add(position); selectedFeedModels.add(feedModel); notifyItemChanged(position); if (selectionModeCallback != null) { selectionModeCallback.onSelectionChange(selectedFeedModels); } return true; } @Override public void onPostClick(final int position, final Media feedModel) { if (!selectionModeActive) return; if (selectedPositions.contains(position)) { selectedPositions.remove(position); selectedFeedModels.remove(feedModel); } else { selectedPositions.add(position); selectedFeedModels.add(feedModel); } notifyItemChanged(position); if (selectionModeCallback != null) { selectionModeCallback.onSelectionChange(selectedFeedModels); } if (selectedPositions.isEmpty()) { selectionModeActive = false; notifyDataSetChanged(); if (selectionModeCallback != null) { selectionModeCallback.onSelectionEnd(); } } } }; public FeedAdapterV2(@NonNull final PostsLayoutPreferences layoutPreferences, final FeedItemCallback feedItemCallback, final SelectionModeCallback selectionModeCallback) { super(DIFF_CALLBACK); this.layoutPreferences = layoutPreferences; this.feedItemCallback = feedItemCallback; this.selectionModeCallback = selectionModeCallback; } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final Context context = parent.getContext(); final LayoutInflater layoutInflater = LayoutInflater.from(context); switch (layoutPreferences.getType()) { case LINEAR: return getLinearViewHolder(parent, layoutInflater, viewType); case GRID: case STAGGERED_GRID: default: final ItemFeedGridBinding binding = ItemFeedGridBinding.inflate(layoutInflater, parent, false); return new FeedGridItemViewHolder(binding); } } @NonNull private RecyclerView.ViewHolder getLinearViewHolder(@NonNull final ViewGroup parent, final LayoutInflater layoutInflater, final int viewType) { switch (MediaItemType.valueOf(viewType)) { case MEDIA_TYPE_VIDEO: { final ItemFeedVideoBinding binding = ItemFeedVideoBinding.inflate(layoutInflater, parent, false); return new FeedVideoViewHolder(binding, feedItemCallback); } case MEDIA_TYPE_SLIDER: { final ItemFeedSliderBinding binding = ItemFeedSliderBinding.inflate(layoutInflater, parent, false); return new FeedSliderViewHolder(binding, feedItemCallback); } case MEDIA_TYPE_IMAGE: default: { final ItemFeedPhotoBinding binding = ItemFeedPhotoBinding.inflate(layoutInflater, parent, false); return new FeedPhotoViewHolder(binding, feedItemCallback); } } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder viewHolder, final int position) { final Media feedModel = getItem(position); if (feedModel == null) return; switch (layoutPreferences.getType()) { case LINEAR: ((FeedItemViewHolder) viewHolder).bind(feedModel); break; case GRID: case STAGGERED_GRID: default: ((FeedGridItemViewHolder) viewHolder).bind(position, feedModel, layoutPreferences, feedItemCallback, adapterSelectionCallback, selectionModeActive, selectedPositions.contains(position)); } } @Override public int getItemViewType(final int position) { return getItem(position).getType().getId(); } public void setLayoutPreferences(@NonNull final PostsLayoutPreferences layoutPreferences) { this.layoutPreferences = layoutPreferences; } public void endSelection() { if (!selectionModeActive) return; selectionModeActive = false; selectedPositions.clear(); selectedFeedModels.clear(); notifyDataSetChanged(); if (selectionModeCallback != null) { selectionModeCallback.onSelectionEnd(); } } // @Override // public void onViewAttachedToWindow(@NonNull final FeedItemViewHolder holder) { // super.onViewAttachedToWindow(holder); // // Log.d(TAG, "attached holder: " + holder); // if (!(holder instanceof FeedSliderViewHolder)) return; // final FeedSliderViewHolder feedSliderViewHolder = (FeedSliderViewHolder) holder; // feedSliderViewHolder.startPlayingVideo(); // } // // @Override // public void onViewDetachedFromWindow(@NonNull final FeedItemViewHolder holder) { // super.onViewDetachedFromWindow(holder); // // Log.d(TAG, "detached holder: " + holder); // if (!(holder instanceof FeedSliderViewHolder)) return; // final FeedSliderViewHolder feedSliderViewHolder = (FeedSliderViewHolder) holder; // feedSliderViewHolder.stopPlayingVideo(); // } public interface FeedItemCallback { void onPostClick(final Media feedModel); void onProfilePicClick(final Media feedModel); void onNameClick(final Media feedModel); void onLocationClick(final Media feedModel); void onMentionClick(final String mention); void onHashtagClick(final String hashtag); void onCommentsClick(final Media feedModel); void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation); void onEmailClick(final String emailId); void onURLClick(final String url); void onSliderClick(Media feedModel, int position); } public interface AdapterSelectionCallback { boolean onPostLongClick(final int position, Media feedModel); void onPostClick(final int position, Media feedModel); } public interface SelectionModeCallback { void onSelectionStart(); void onSelectionChange(final Set selectedFeedModels); void onSelectionEnd(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/FeedItemCallbackAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.View; import awais.instagrabber.repositories.responses.Media; public class FeedItemCallbackAdapter implements FeedAdapterV2.FeedItemCallback { @Override public void onPostClick(final Media media) {} @Override public void onProfilePicClick(final Media media) {} @Override public void onNameClick(final Media media) {} @Override public void onLocationClick(final Media media) {} @Override public void onMentionClick(final String mention) {} @Override public void onHashtagClick(final String hashtag) {} @Override public void onCommentsClick(final Media media) {} @Override public void onDownloadClick(final Media media, final int childPosition, final View popupLocation) {} @Override public void onEmailClick(final String emailId) {} @Override public void onURLClick(final String url) {} @Override public void onSliderClick(final Media media, final int position) {} } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/FeedStoriesAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import awais.instagrabber.adapters.viewholder.FeedStoryViewHolder; import awais.instagrabber.databinding.ItemHighlightBinding; import awais.instagrabber.repositories.responses.stories.Story; public final class FeedStoriesAdapter extends ListAdapter { private final OnFeedStoryClickListener listener; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()); } @Override public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()) && oldItem.getSeen() == newItem.getSeen(); } }; public FeedStoriesAdapter(final OnFeedStoryClickListener listener) { super(diffCallback); this.listener = listener; } @NonNull @Override public FeedStoryViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemHighlightBinding binding = ItemHighlightBinding.inflate(layoutInflater, parent, false); return new FeedStoryViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final FeedStoryViewHolder holder, final int position) { final Story model = getItem(position); holder.bind(model, position, listener); } public interface OnFeedStoryClickListener { void onFeedStoryClick(Story model, int position); void onFeedStoryLongClick(Story model, int position); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/FeedStoriesListAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import android.widget.Filter; import android.widget.Filterable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.List; import java.util.stream.Collectors; import awais.instagrabber.adapters.viewholder.StoryListViewHolder; import awais.instagrabber.databinding.ItemNotificationBinding; import awais.instagrabber.repositories.responses.stories.Story; import awais.instagrabber.utils.TextUtils; public final class FeedStoriesListAdapter extends ListAdapter implements Filterable { private final OnFeedStoryClickListener listener; private List list; private final Filter filter = new Filter() { @NonNull @Override protected FilterResults performFiltering(final CharSequence filter) { final String query = TextUtils.isEmpty(filter) ? null : filter.toString().toLowerCase(); List filteredList = list; if (list != null && query != null) { filteredList = list.stream() .filter(feedStoryModel -> feedStoryModel.getUser() .getUsername() .toLowerCase() .contains(query)) .collect(Collectors.toList()); } final FilterResults filterResults = new FilterResults(); filterResults.count = filteredList != null ? filteredList.size() : 0; filterResults.values = filteredList; return filterResults; } @Override protected void publishResults(final CharSequence constraint, final FilterResults results) { //noinspection unchecked submitList((List) results.values, true); } }; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()); } @Override public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()) && oldItem.getSeen() == newItem.getSeen(); } }; public FeedStoriesListAdapter(final OnFeedStoryClickListener listener) { super(diffCallback); this.listener = listener; } @Override public Filter getFilter() { return filter; } private void submitList(@Nullable final List list, final boolean isFiltered) { if (!isFiltered) { this.list = list; } super.submitList(list); } @Override public void submitList(final List list) { submitList(list, false); } @NonNull @Override public StoryListViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemNotificationBinding binding = ItemNotificationBinding.inflate(layoutInflater, parent, false); return new StoryListViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final StoryListViewHolder holder, final int position) { final Story model = getItem(position); holder.bind(model, listener); } public interface OnFeedStoryClickListener { void onFeedStoryClick(final Story model); void onProfileClick(final String username); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/FiltersAdapter.java ================================================ package awais.instagrabber.adapters; import android.graphics.Bitmap; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.Collection; import java.util.List; import awais.instagrabber.adapters.viewholder.FilterViewHolder; import awais.instagrabber.databinding.ItemFilterBinding; import awais.instagrabber.fragments.imageedit.filters.filters.Filter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; public class FiltersAdapter extends ListAdapter, FilterViewHolder> { private static final DiffUtil.ItemCallback> DIFF_CALLBACK = new DiffUtil.ItemCallback>() { @Override public boolean areItemsTheSame(@NonNull final Filter oldItem, @NonNull final Filter newItem) { return oldItem.getType().equals(newItem.getType()); } @Override public boolean areContentsTheSame(@NonNull final Filter oldItem, @NonNull final Filter newItem) { return oldItem.getType().equals(newItem.getType()); } }; private final Bitmap bitmap; private final OnFilterClickListener onFilterClickListener; private final Collection filters; private final String originalKey; private int selectedPosition = 0; public FiltersAdapter(final Collection filters, final String originalKey, final Bitmap bitmap, final OnFilterClickListener onFilterClickListener) { super(DIFF_CALLBACK); this.filters = filters; this.originalKey = originalKey; this.bitmap = bitmap; this.onFilterClickListener = onFilterClickListener; setHasStableIds(true); } @NonNull @Override public FilterViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemFilterBinding binding = ItemFilterBinding.inflate(layoutInflater, parent, false); return new FilterViewHolder(binding, filters, onFilterClickListener); } @Override public void onBindViewHolder(@NonNull final FilterViewHolder holder, final int position) { holder.bind(position, originalKey, bitmap, getItem(position), selectedPosition == position); } @Override public long getItemId(final int position) { return getItem(position).getLabel(); } public void setSelected(final int position) { final int prev = this.selectedPosition; this.selectedPosition = position; notifyItemChanged(position); notifyItemChanged(prev); } public void setSelectedFilter(final GPUImageFilter instance) { final List> currentList = getCurrentList(); int index = -1; for (int i = 0; i < currentList.size(); i++) { final Filter filter = currentList.get(i); final GPUImageFilter filterInstance = filter.getInstance(); if (filterInstance.getClass() == instance.getClass()) { index = i; break; } } if (index < 0) return; setSelected(index); } public interface OnFilterClickListener { void onClick(int position, Filter filter); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/FollowAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Filter; import android.widget.Filterable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.FollowsViewHolder; import awais.instagrabber.databinding.ItemFollowBinding; import awais.instagrabber.interfaces.OnGroupClickListener; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.TextUtils; import thoughtbot.expandableadapter.ExpandableGroup; import thoughtbot.expandableadapter.ExpandableList; import thoughtbot.expandableadapter.ExpandableListPosition; import thoughtbot.expandableadapter.GroupViewHolder; // thanks to ThoughtBot's ExpandableRecyclerViewAdapter // https://github.com/thoughtbot/expandable-recycler-view public final class FollowAdapter extends RecyclerView.Adapter implements OnGroupClickListener, Filterable { private final View.OnClickListener onClickListener; private final ExpandableList expandableListOriginal; private final boolean hasManyGroups; private ExpandableList expandableList; private final Filter filter = new Filter() { @Nullable @Override protected FilterResults performFiltering(final CharSequence filter) { final List filteredItems = new ArrayList(); if (expandableListOriginal.groups == null || TextUtils.isEmpty(filter)) return null; final String query = filter.toString().toLowerCase(); final ArrayList groups = new ArrayList(); for (int x = 0; x < expandableListOriginal.groups.size(); ++x) { final ExpandableGroup expandableGroup = expandableListOriginal.groups.get(x); final String title = expandableGroup.getTitle(); final List items = expandableGroup.getItems(); if (items != null) { final List toReturn = items.stream() .filter(u -> hasKey(query, u.getUsername(), u.getFullName())) .collect(Collectors.toList()); groups.add(new ExpandableGroup(title, toReturn)); } } final FilterResults filterResults = new FilterResults(); filterResults.values = new ExpandableList(groups, expandableList.expandedGroupIndexes); return filterResults; } private boolean hasKey(final String key, final String username, final String name) { if (TextUtils.isEmpty(key)) return true; final boolean hasUserName = username != null && username.toLowerCase().contains(key); if (!hasUserName && name != null) return name.toLowerCase().contains(key); return true; } @Override protected void publishResults(final CharSequence constraint, final FilterResults results) { if (results == null) { expandableList = expandableListOriginal; } else { final ExpandableList filteredList = (ExpandableList) results.values; expandableList = filteredList; } notifyDataSetChanged(); } }; public FollowAdapter(final View.OnClickListener onClickListener, @NonNull final ArrayList groups) { this.expandableListOriginal = new ExpandableList(groups); expandableList = this.expandableListOriginal; this.onClickListener = onClickListener; this.hasManyGroups = groups.size() > 1; } @Override public Filter getFilter() { return filter; } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final boolean isGroup = hasManyGroups && viewType == ExpandableListPosition.GROUP; final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final View view; if (isGroup) { view = layoutInflater.inflate(R.layout.header_follow, parent, false); return new GroupViewHolder(view, this); } else { final ItemFollowBinding binding = ItemFollowBinding.inflate(layoutInflater, parent, false); return new FollowsViewHolder(binding); } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { final ExpandableListPosition listPos = expandableList.getUnflattenedPosition(position); final ExpandableGroup group = expandableList.getExpandableGroup(listPos); if (hasManyGroups && listPos.type == ExpandableListPosition.GROUP) { final GroupViewHolder gvh = (GroupViewHolder) holder; gvh.setTitle(group.getTitle()); gvh.toggle(isGroupExpanded(group)); return; } final User model = group.getItems().get(hasManyGroups ? listPos.childPos : position); ((FollowsViewHolder) holder).bind(model, onClickListener); } @Override public int getItemCount() { return expandableList.getVisibleItemCount() - (hasManyGroups ? 0 : 1); } @Override public int getItemViewType(final int position) { return !hasManyGroups ? 0 : expandableList.getUnflattenedPosition(position).type; } @Override public void toggleGroup(final int flatPos) { final ExpandableListPosition listPosition = expandableList.getUnflattenedPosition(flatPos); final int groupPos = listPosition.groupPos; final int positionStart = expandableList.getFlattenedGroupIndex(listPosition) + 1; final int positionEnd = expandableList.groups.get(groupPos).getItemCount(); final boolean isExpanded = expandableList.expandedGroupIndexes[groupPos]; expandableList.expandedGroupIndexes[groupPos] = !isExpanded; notifyItemChanged(positionStart - 1); if (positionEnd > 0) { if (isExpanded) notifyItemRangeRemoved(positionStart, positionEnd); else notifyItemRangeInserted(positionStart, positionEnd); } } public boolean isGroupExpanded(final ExpandableGroup group) { return expandableList.expandedGroupIndexes[expandableList.groups.indexOf(group)]; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/GifItemsAdapter.java ================================================ package awais.instagrabber.adapters; import android.net.Uri; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.backends.pipeline.PipelineDraweeControllerBuilder; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.imagepipeline.common.ResizeOptions; import com.facebook.imagepipeline.image.ImageInfo; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import java.util.Objects; import awais.instagrabber.databinding.ItemMediaBinding; import awais.instagrabber.repositories.responses.giphy.GiphyGif; import awais.instagrabber.utils.Utils; public class GifItemsAdapter extends ListAdapter { private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final GiphyGif oldItem, @NonNull final GiphyGif newItem) { return Objects.equals(oldItem.getId(), newItem.getId()); } @Override public boolean areContentsTheSame(@NonNull final GiphyGif oldItem, @NonNull final GiphyGif newItem) { return Objects.equals(oldItem.getId(), newItem.getId()); } }; private final OnItemClickListener onItemClickListener; public GifItemsAdapter(final OnItemClickListener onItemClickListener) { super(diffCallback); this.onItemClickListener = onItemClickListener; } @NonNull @Override public GifViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemMediaBinding binding = ItemMediaBinding.inflate(layoutInflater, parent, false); return new GifViewHolder(binding, onItemClickListener); } @Override public void onBindViewHolder(@NonNull final GifViewHolder holder, final int position) { holder.bind(getItem(position)); } public static class GifViewHolder extends RecyclerView.ViewHolder { private static final String TAG = GifViewHolder.class.getSimpleName(); private static final int size = Utils.displayMetrics.widthPixels / 3; private final ItemMediaBinding binding; private final OnItemClickListener onItemClickListener; public GifViewHolder(@NonNull final ItemMediaBinding binding, final OnItemClickListener onItemClickListener) { super(binding.getRoot()); this.binding = binding; this.onItemClickListener = onItemClickListener; binding.duration.setVisibility(View.GONE); final GenericDraweeHierarchyBuilder builder = new GenericDraweeHierarchyBuilder(itemView.getResources()); builder.setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER); binding.item.setHierarchy(builder.build()); } public void bind(final GiphyGif item) { if (onItemClickListener != null) { itemView.setOnClickListener(v -> onItemClickListener.onItemClick(item)); } final BaseControllerListener controllerListener = new BaseControllerListener() { @Override public void onFailure(final String id, final Throwable throwable) { Log.e(TAG, "onFailure: ", throwable); } }; final ImageRequest request = ImageRequestBuilder .newBuilderWithSource(Uri.parse(item.getImages().getFixedHeight().getWebp())) .setResizeOptions(ResizeOptions.forDimensions(size, size)) .build(); final PipelineDraweeControllerBuilder builder = Fresco.newDraweeControllerBuilder() .setImageRequest(request) .setAutoPlayAnimations(true) .setControllerListener(controllerListener); binding.item.setController(builder.build()); } } public interface OnItemClickListener { void onItemClick(GiphyGif giphyGif); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/HighlightStoriesListAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import awais.instagrabber.adapters.viewholder.StoryListViewHolder; import awais.instagrabber.databinding.ItemNotificationBinding; import awais.instagrabber.repositories.responses.stories.Story; public final class HighlightStoriesListAdapter extends ListAdapter { private final OnHighlightStoryClickListener listener; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()); } @Override public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()); } }; public HighlightStoriesListAdapter(final OnHighlightStoryClickListener listener) { super(diffCallback); this.listener = listener; } @NonNull @Override public StoryListViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemNotificationBinding binding = ItemNotificationBinding.inflate(layoutInflater, parent, false); return new StoryListViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final StoryListViewHolder holder, final int position) { final Story model = getItem(position); holder.bind(model, position, listener); } public interface OnHighlightStoryClickListener { void onHighlightClick(final Story model, final int position); void onProfileClick(final String username); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/HighlightsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import awais.instagrabber.adapters.viewholder.HighlightViewHolder; import awais.instagrabber.databinding.ItemHighlightBinding; import awais.instagrabber.repositories.responses.stories.Story; public final class HighlightsAdapter extends ListAdapter { private final OnHighlightClickListener clickListener; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()); } @Override public boolean areContentsTheSame(@NonNull final Story oldItem, @NonNull final Story newItem) { return oldItem.getId().equals(newItem.getId()); } }; public HighlightsAdapter(final OnHighlightClickListener clickListener) { super(diffCallback); this.clickListener = clickListener; } @NonNull @Override public HighlightViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemHighlightBinding binding = ItemHighlightBinding.inflate(layoutInflater, parent, false); return new HighlightViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final HighlightViewHolder holder, final int position) { final Story highlightModel = getItem(position); if (clickListener != null) { holder.itemView.setOnClickListener(v -> clickListener.onHighlightClick(highlightModel, position)); } holder.bind(highlightModel); } public interface OnHighlightClickListener { void onHighlightClick(final Story model, final int position); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/KeywordsFilterAdapter.java ================================================ package awais.instagrabber.adapters; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.dialogs.KeywordsFilterDialogViewHolder; public class KeywordsFilterAdapter extends RecyclerView.Adapter { private final Context context; private final ArrayList items; public KeywordsFilterAdapter(Context context, ArrayList items){ this.context = context; this.items = items; } @NonNull @Override public KeywordsFilterDialogViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { final View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_keyword, parent, false); return new KeywordsFilterDialogViewHolder(v); } @Override public void onBindViewHolder(@NonNull KeywordsFilterDialogViewHolder holder, int position) { holder.bind(items, position, context, this); } @Override public int getItemCount() { return items.size(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/LikesAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import java.util.List; import awais.instagrabber.adapters.viewholder.FollowsViewHolder; import awais.instagrabber.databinding.ItemFollowBinding; import awais.instagrabber.repositories.responses.User; public final class LikesAdapter extends RecyclerView.Adapter { private final List profileModels; private final View.OnClickListener onClickListener; public LikesAdapter(final List profileModels, final View.OnClickListener onClickListener) { this.profileModels = profileModels; this.onClickListener = onClickListener; } @NonNull @Override public FollowsViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemFollowBinding binding = ItemFollowBinding.inflate(layoutInflater, parent, false); return new FollowsViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final FollowsViewHolder holder, final int position) { final User model = profileModels.get(position); holder.bind(model, onClickListener); } @Override public int getItemCount() { return profileModels.size(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/NotificationsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.adapters.viewholder.NotificationViewHolder; import awais.instagrabber.databinding.ItemNotificationBinding; import awais.instagrabber.models.enums.NotificationType; import awais.instagrabber.repositories.responses.notification.Notification; public final class NotificationsAdapter extends ListAdapter { private final OnNotificationClickListener notificationClickListener; private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(final Notification oldItem, final Notification newItem) { return Objects.requireNonNull(oldItem.getPk()).equals(newItem.getPk()); } @Override public boolean areContentsTheSame(@NonNull final Notification oldItem, @NonNull final Notification newItem) { return Objects.requireNonNull(oldItem.getPk()).equals(newItem.getPk()) && Objects.equals(oldItem.getType(), newItem.getType()); } }; public NotificationsAdapter(final OnNotificationClickListener notificationClickListener) { super(DIFF_CALLBACK); this.notificationClickListener = notificationClickListener; } @NonNull @Override public NotificationViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemNotificationBinding binding = ItemNotificationBinding.inflate(layoutInflater, parent, false); return new NotificationViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final NotificationViewHolder holder, final int position) { final Notification Notification = getItem(position); holder.bind(Notification, notificationClickListener); } @Override public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { if (list == null) { super.submitList(null, commitCallback); return; } super.submitList(sort(list), commitCallback); } @Override public void submitList(@Nullable final List list) { if (list == null) { super.submitList(null); return; } super.submitList(sort(list)); } private List sort(final List list) { final List listCopy = new ArrayList<>(list).stream() .filter(i -> i.getType() != null) .collect(Collectors.toList()); Collections.sort(listCopy, (o1, o2) -> { // keep requests at top if (o1.getType() == o2.getType() && o1.getType() == NotificationType.REQUEST && o2.getType() == NotificationType.REQUEST) return 0; else if (o1.getType() == NotificationType.REQUEST) return -1; else if (o2.getType() == NotificationType.REQUEST) return 1; // timestamp return Double.compare(o2.getArgs().getTimestamp(), o1.getArgs().getTimestamp()); }); return listCopy; } public interface OnNotificationClickListener { void onNotificationClick(final Notification model); void onProfileClick(final String username); void onPreviewClick(final Notification model); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/SavedCollectionsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import java.util.Objects; import awais.instagrabber.adapters.viewholder.TopicClusterViewHolder; import awais.instagrabber.databinding.ItemDiscoverTopicBinding; import awais.instagrabber.repositories.responses.saved.SavedCollection; public class SavedCollectionsAdapter extends ListAdapter { private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) { return oldItem.getCollectionId().equals(newItem.getCollectionId()); } @Override public boolean areContentsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) { if (oldItem.getCoverMediaList() != null && newItem.getCoverMediaList() != null && oldItem.getCoverMediaList().size() == newItem.getCoverMediaList().size()) { return Objects.equals(oldItem.getCoverMediaList().get(0).getId(), newItem.getCoverMediaList().get(0).getId()); } else if (oldItem.getCoverMedia() != null && newItem.getCoverMedia() != null) { return Objects.equals(oldItem.getCoverMedia().getId(), newItem.getCoverMedia().getId()); } return false; } }; private final OnCollectionClickListener onCollectionClickListener; public SavedCollectionsAdapter(final OnCollectionClickListener onCollectionClickListener) { super(DIFF_CALLBACK); this.onCollectionClickListener = onCollectionClickListener; } @NonNull @Override public TopicClusterViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemDiscoverTopicBinding binding = ItemDiscoverTopicBinding.inflate(layoutInflater, parent, false); return new TopicClusterViewHolder(binding, null, onCollectionClickListener); } @Override public void onBindViewHolder(@NonNull final TopicClusterViewHolder holder, final int position) { final SavedCollection topicCluster = getItem(position); holder.bind(topicCluster); } public interface OnCollectionClickListener { void onCollectionClick(SavedCollection savedCollection, View root, View cover, View title, int titleColor, int backgroundColor); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/SearchCategoryAdapter.java ================================================ package awais.instagrabber.adapters; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.viewpager2.adapter.FragmentStateAdapter; import java.util.List; import awais.instagrabber.fragments.search.SearchCategoryFragment; import awais.instagrabber.models.enums.FavoriteType; public class SearchCategoryAdapter extends FragmentStateAdapter { private final List categories; public SearchCategoryAdapter(@NonNull final Fragment fragment, @NonNull final List categories) { super(fragment); this.categories = categories; } @NonNull @Override public Fragment createFragment(final int position) { return SearchCategoryFragment.newInstance(categories.get(position)); } @Override public int getItemCount() { return categories.size(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/SearchItemsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.AdapterListUpdateCallback; import androidx.recyclerview.widget.AsyncDifferConfig; import androidx.recyclerview.widget.AsyncListDiffer; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.SearchItemViewHolder; import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; import awais.instagrabber.databinding.ItemSearchResultBinding; import awais.instagrabber.fragments.search.SearchCategoryFragment.OnSearchItemClickListener; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.repositories.responses.search.SearchItem; public final class SearchItemsAdapter extends RecyclerView.Adapter { private static final String TAG = SearchItemsAdapter.class.getSimpleName(); private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final SearchItemOrHeader oldItem, @NonNull final SearchItemOrHeader newItem) { return Objects.equals(oldItem, newItem); } @Override public boolean areContentsTheSame(@NonNull final SearchItemOrHeader oldItem, @NonNull final SearchItemOrHeader newItem) { return Objects.equals(oldItem, newItem); } }; private static final String RECENT = "recent"; private static final String FAVORITE = "favorite"; private static final int VIEW_TYPE_HEADER = 0; private static final int VIEW_TYPE_ITEM = 1; private final OnSearchItemClickListener onSearchItemClickListener; private final AsyncListDiffer differ; public SearchItemsAdapter(final OnSearchItemClickListener onSearchItemClickListener) { differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), new AsyncDifferConfig.Builder<>(DIFF_CALLBACK).build()); this.onSearchItemClickListener = onSearchItemClickListener; } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); if (viewType == VIEW_TYPE_HEADER) { return new HeaderViewHolder(ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false)); } final ItemSearchResultBinding binding = ItemSearchResultBinding.inflate(layoutInflater, parent, false); return new SearchItemViewHolder(binding, onSearchItemClickListener); } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { if (getItemViewType(position) == VIEW_TYPE_HEADER) { final SearchItemOrHeader searchItemOrHeader = getItem(position); if (!searchItemOrHeader.isHeader()) return; ((HeaderViewHolder) holder).bind(searchItemOrHeader.header); return; } ((SearchItemViewHolder) holder).bind(getItem(position).searchItem); } protected SearchItemOrHeader getItem(int position) { return differ.getCurrentList().get(position); } @Override public int getItemCount() { return differ.getCurrentList().size(); } @Override public int getItemViewType(final int position) { return getItem(position).isHeader() ? VIEW_TYPE_HEADER : VIEW_TYPE_ITEM; } public void submitList(@Nullable final List list) { if (list == null) { differ.submitList(null); return; } differ.submitList(sectionAndSort(list)); } public void submitList(@Nullable final List list, @Nullable final Runnable commitCallback) { if (list == null) { differ.submitList(null, commitCallback); return; } differ.submitList(sectionAndSort(list), commitCallback); } @NonNull private List sectionAndSort(@NonNull final List list) { final boolean containsRecentOrFavorite = list.stream().anyMatch(searchItem -> searchItem.isRecent() || searchItem.isFavorite()); // Don't do anything if not showing recent results if (!containsRecentOrFavorite) { return list.stream() .map(SearchItemOrHeader::new) .collect(Collectors.toList()); } final List listCopy = new ArrayList<>(list); Collections.sort(listCopy, (o1, o2) -> { final boolean bothRecent = o1.isRecent() && o2.isRecent(); if (bothRecent) { // Don't sort return 0; } final boolean bothFavorite = o1.isFavorite() && o2.isFavorite(); if (bothFavorite) { if (o1.getType() == o2.getType()) return 0; // keep users at top if (o1.getType() == FavoriteType.USER) return -1; if (o2.getType() == FavoriteType.USER) return 1; // keep locations at bottom if (o1.getType() == FavoriteType.LOCATION) return 1; if (o2.getType() == FavoriteType.LOCATION) return -1; } // keep recents at top if (o1.isRecent()) return -1; if (o2.isRecent()) return 1; return 0; }); final List itemOrHeaders = new ArrayList<>(); for (int i = 0; i < listCopy.size(); i++) { final SearchItem searchItem = listCopy.get(i); final SearchItemOrHeader prev = itemOrHeaders.isEmpty() ? null : itemOrHeaders.get(itemOrHeaders.size() - 1); boolean prevWasSameType = prev != null && ((prev.searchItem.isRecent() && searchItem.isRecent()) || (prev.searchItem.isFavorite() && searchItem.isFavorite())); if (prevWasSameType) { // just add the item itemOrHeaders.add(new SearchItemOrHeader(searchItem)); continue; } // add header and item // add header only if search item is recent or favorite if (searchItem.isRecent() || searchItem.isFavorite()) { itemOrHeaders.add(new SearchItemOrHeader(searchItem.isRecent() ? RECENT : FAVORITE)); } itemOrHeaders.add(new SearchItemOrHeader(searchItem)); } return itemOrHeaders; } private static class SearchItemOrHeader { String header; SearchItem searchItem; public SearchItemOrHeader(final SearchItem searchItem) { this.searchItem = searchItem; } public SearchItemOrHeader(final String header) { this.header = header; } boolean isHeader() { return header != null; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final SearchItemOrHeader that = (SearchItemOrHeader) o; return Objects.equals(header, that.header) && Objects.equals(searchItem, that.searchItem); } @Override public int hashCode() { return Objects.hash(header, searchItem); } } public static class HeaderViewHolder extends RecyclerView.ViewHolder { private final ItemFavSectionHeaderBinding binding; public HeaderViewHolder(@NonNull final ItemFavSectionHeaderBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final String header) { if (header == null) return; final int headerText; switch (header) { case RECENT: headerText = R.string.recent; break; case FAVORITE: headerText = R.string.title_favorites; break; default: headerText = R.string.unknown; break; } binding.getRoot().setText(headerText); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/SliderCallbackAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.View; import com.google.android.exoplayer2.ui.StyledPlayerView; import awais.instagrabber.repositories.responses.Media; public class SliderCallbackAdapter implements SliderItemsAdapter.SliderCallback { @Override public void onThumbnailLoaded(final int position) {} @Override public void onItemClicked(final int position, final Media media, final View view) {} @Override public void onPlayerPlay(final int position) {} @Override public void onPlayerPause(final int position) {} @Override public void onPlayerRelease(final int position) {} @Override public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) {} @Override public boolean isInFullScreen() { return false; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/SliderItemsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import com.google.android.exoplayer2.ui.StyledPlayerView; import awais.instagrabber.adapters.viewholder.SliderItemViewHolder; import awais.instagrabber.adapters.viewholder.SliderPhotoViewHolder; import awais.instagrabber.adapters.viewholder.SliderVideoViewHolder; import awais.instagrabber.databinding.ItemSliderPhotoBinding; import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Media; public final class SliderItemsAdapter extends ListAdapter { private final boolean loadVideoOnItemClick; private final SliderCallback sliderCallback; private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { return oldItem.getPk().equals(newItem.getPk()); } @Override public boolean areContentsTheSame(@NonNull final Media oldItem, @NonNull final Media newItem) { return oldItem.getPk().equals(newItem.getPk()); } }; public SliderItemsAdapter(final boolean loadVideoOnItemClick, final SliderCallback sliderCallback) { super(DIFF_CALLBACK); this.loadVideoOnItemClick = loadVideoOnItemClick; this.sliderCallback = sliderCallback; } @NonNull @Override public SliderItemViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); final MediaItemType mediaItemType = MediaItemType.valueOf(viewType); switch (mediaItemType) { case MEDIA_TYPE_VIDEO: { final LayoutVideoPlayerWithThumbnailBinding binding = LayoutVideoPlayerWithThumbnailBinding.inflate(inflater, parent, false); return new SliderVideoViewHolder(binding, loadVideoOnItemClick); } case MEDIA_TYPE_IMAGE: default: final ItemSliderPhotoBinding binding = ItemSliderPhotoBinding.inflate(inflater, parent, false); return new SliderPhotoViewHolder(binding); } } @Override public void onBindViewHolder(@NonNull final SliderItemViewHolder holder, final int position) { final Media media = getItem(position); holder.bind(media, position, sliderCallback); } @Override public int getItemViewType(final int position) { final Media media = getItem(position); return media.getType().getId(); } // @NonNull // @Override // public Object instantiateItem(@NonNull final ViewGroup container, final int position) { // final Context context = container.getContext(); // final ViewerPostModel sliderItem = sliderItems.get(position); // // if (sliderItem.getItemType() == MediaItemType.MEDIA_TYPE_VIDEO) { // final ViewSwitcher viewSwitcher = createViewSwitcher(context, position, sliderItem.getThumbnailUrl(), sliderItem.getDisplayUrl()); // container.addView(viewSwitcher); // return viewSwitcher; // } // final GenericDraweeHierarchy hierarchy = GenericDraweeHierarchyBuilder.newInstance(container.getResources()) // .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) // .build(); // final SimpleDraweeView photoView = new SimpleDraweeView(context, hierarchy); // photoView.setLayoutParams(layoutParams); // final ImageRequest imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(sliderItem.getDisplayUrl())) // .setLocalThumbnailPreviewsEnabled(true) // .setProgressiveRenderingEnabled(true) // .build(); // photoView.setImageRequest(imageRequest); // container.addView(photoView); // return photoView; // } // @NonNull // private ViewSwitcher createViewSwitcher(final Context context, // final int position, // final String thumbnailUrl, // final String displayUrl) { // // final ViewSwitcher viewSwitcher = new ViewSwitcher(context); // viewSwitcher.setLayoutParams(layoutParams); // // final FrameLayout frameLayout = new FrameLayout(context); // frameLayout.setLayoutParams(layoutParams); // // final GenericDraweeHierarchy hierarchy = new GenericDraweeHierarchyBuilder(context.getResources()) // .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) // .build(); // final SimpleDraweeView simpleDraweeView = new SimpleDraweeView(context, hierarchy); // simpleDraweeView.setLayoutParams(layoutParams); // simpleDraweeView.setImageURI(thumbnailUrl); // frameLayout.addView(simpleDraweeView); // // final AppCompatImageView imageView = new AppCompatImageView(context); // final int px = Utils.convertDpToPx(50); // final FrameLayout.LayoutParams playButtonLayoutParams = new FrameLayout.LayoutParams(px, px); // playButtonLayoutParams.gravity = Gravity.CENTER; // imageView.setLayoutParams(playButtonLayoutParams); // imageView.setImageResource(R.drawable.exo_icon_play); // frameLayout.addView(imageView); // // viewSwitcher.addView(frameLayout); // // final PlayerView playerView = new PlayerView(context); // viewSwitcher.addView(playerView); // if (shouldAutoPlay && position == 0) { // loadPlayer(context, position, displayUrl, viewSwitcher, factory, playerChangeListener); // } else // frameLayout.setOnClickListener(v -> loadPlayer(context, position, displayUrl, viewSwitcher, factory, playerChangeListener)); // return viewSwitcher; // } public interface SliderCallback { void onThumbnailLoaded(int position); void onItemClicked(int position, final Media media, final View view); void onPlayerPlay(int position); void onPlayerPause(int position); void onPlayerRelease(int position); void onFullScreenModeChanged(boolean isFullScreen, final StyledPlayerView playerView); boolean isInFullScreen(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/StoriesAdapter.java ================================================ package awais.instagrabber.adapters; import java.util.List; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.databinding.ItemStoryBinding; import awais.instagrabber.repositories.responses.stories.StoryMedia; import awais.instagrabber.utils.ResponseBodyUtils; public final class StoriesAdapter extends ListAdapter { private final OnItemClickListener onItemClickListener; private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final StoryMedia oldItem, @NonNull final StoryMedia newItem) { return oldItem.getId().equals(newItem.getId()); } @Override public boolean areContentsTheSame(@NonNull final StoryMedia oldItem, @NonNull final StoryMedia newItem) { return oldItem.getId().equals(newItem.getId()); } }; public StoriesAdapter(final OnItemClickListener onItemClickListener) { super(diffCallback); this.onItemClickListener = onItemClickListener; } @NonNull @Override public StoryViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemStoryBinding binding = ItemStoryBinding.inflate(layoutInflater, parent, false); return new StoryViewHolder(binding); } @Override public void onBindViewHolder(@NonNull final StoryViewHolder holder, final int position) { final StoryMedia storyMedia = getItem(position); holder.bind(storyMedia, position, onItemClickListener); } public final static class StoryViewHolder extends RecyclerView.ViewHolder { private final ItemStoryBinding binding; public StoryViewHolder(final ItemStoryBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final StoryMedia model, final int position, final OnItemClickListener clickListener) { if (model == null) return; model.setPosition(position); itemView.setTag(model); itemView.setOnClickListener(v -> { if (clickListener == null) return; clickListener.onItemClick(model, position); }); binding.selectedView.setVisibility(model.isCurrentSlide() ? View.VISIBLE : View.GONE); binding.icon.setImageURI(ResponseBodyUtils.getThumbUrl(model)); } } public void paginate(final int newIndex) { final List list = getCurrentList(); for (int i = 0; i < list.size(); i++) { final StoryMedia item = list.get(i); if (!item.isCurrentSlide() && i != newIndex) continue; item.setCurrentSlide(i == newIndex); notifyItemChanged(i, item); } } public interface OnItemClickListener { void onItemClick(StoryMedia storyModel, int position); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/TabsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.TabViewHolder; import awais.instagrabber.databinding.ItemFavSectionHeaderBinding; import awais.instagrabber.databinding.ItemTabOrderPrefBinding; import awais.instagrabber.models.Tab; import awais.instagrabber.utils.Utils; public class TabsAdapter extends ListAdapter { private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final TabOrHeader oldItem, @NonNull final TabOrHeader newItem) { if (oldItem.isHeader() && newItem.isHeader()) { return oldItem.header == newItem.header; } if (!oldItem.isHeader() && !newItem.isHeader()) { final Tab oldTab = oldItem.tab; final Tab newTab = newItem.tab; return oldTab.getIconResId() == newTab.getIconResId() && Objects.equals(oldTab.getTitle(), newTab.getTitle()); } return false; } @Override public boolean areContentsTheSame(@NonNull final TabOrHeader oldItem, @NonNull final TabOrHeader newItem) { if (oldItem.isHeader() && newItem.isHeader()) { return oldItem.header == newItem.header; } if (!oldItem.isHeader() && !newItem.isHeader()) { final Tab oldTab = oldItem.tab; final Tab newTab = newItem.tab; return oldTab.getIconResId() == newTab.getIconResId() && Objects.equals(oldTab.getTitle(), newTab.getTitle()); } return false; } }; private final TabAdapterCallback tabAdapterCallback; private List current = new ArrayList<>(); private List others = new ArrayList<>(); public TabsAdapter(@NonNull final TabAdapterCallback tabAdapterCallback) { super(DIFF_CALLBACK); this.tabAdapterCallback = tabAdapterCallback; } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); if (viewType == 1) { final ItemTabOrderPrefBinding binding = ItemTabOrderPrefBinding.inflate(layoutInflater, parent, false); return new TabViewHolder(binding, tabAdapterCallback); } final ItemFavSectionHeaderBinding headerBinding = ItemFavSectionHeaderBinding.inflate(layoutInflater, parent, false); return new DirectUsersAdapter.HeaderViewHolder(headerBinding); } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { if (holder instanceof DirectUsersAdapter.HeaderViewHolder) { ((DirectUsersAdapter.HeaderViewHolder) holder).bind(R.string.other_tabs); return; } if (holder instanceof TabViewHolder) { final Tab tab = getItem(position).tab; ((TabViewHolder) holder).bind(tab, others.contains(tab), current.size() == 5); } } @Override public int getItemViewType(final int position) { return getItem(position).isHeader() ? 0 : 1; } public void submitList(final List current, final List others, final Runnable commitCallback) { final ImmutableList.Builder builder = ImmutableList.builder(); if (current != null) { builder.addAll(current.stream() .map(TabOrHeader::new) .collect(Collectors.toList())); } builder.add(new TabOrHeader(R.string.other_tabs)); if (others != null) { builder.addAll(others.stream() .map(TabOrHeader::new) .collect(Collectors.toList())); } // Mutable non-null copies this.current = current != null ? new ArrayList<>(current) : new ArrayList<>(); this.others = others != null ? new ArrayList<>(others) : new ArrayList<>(); submitList(builder.build(), commitCallback); } public void submitList(final List current, final List others) { submitList(current, others, null); } public void moveItem(final int from, final int to) { final List currentCopy = new ArrayList<>(current); Utils.moveItem(from, to, currentCopy); submitList(currentCopy, others); tabAdapterCallback.onOrderChange(currentCopy); } public int getCurrentCount() { return current.size(); } public static class TabOrHeader { Tab tab; int header; public TabOrHeader(final Tab tab) { this.tab = tab; } public TabOrHeader(@StringRes final int header) { this.header = header; } boolean isHeader() { return header != 0; } } public interface TabAdapterCallback { void onStartDrag(TabViewHolder viewHolder); void onOrderChange(List newOrderTabs); void onAdd(Tab tab); void onRemove(Tab tab); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/UserSearchResultsAdapter.java ================================================ package awais.instagrabber.adapters; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserClickListener; import awais.instagrabber.adapters.viewholder.directmessages.DirectUserViewHolder; import awais.instagrabber.adapters.viewholder.directmessages.RecipientThreadViewHolder; import awais.instagrabber.databinding.LayoutDmUserItemBinding; import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; public final class UserSearchResultsAdapter extends ListAdapter { private static final int VIEW_TYPE_USER = 0; private static final int VIEW_TYPE_THREAD = 1; private static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final RankedRecipient oldItem, @NonNull final RankedRecipient newItem) { final boolean bothUsers = oldItem.getUser() != null && newItem.getUser() != null; if (!bothUsers) return false; final boolean bothThreads = oldItem.getThread() != null && newItem.getThread() != null; if (!bothThreads) return false; if (bothUsers) { return oldItem.getUser().getPk() == newItem.getUser().getPk(); } return Objects.equals(oldItem.getThread().getThreadId(), newItem.getThread().getThreadId()); } @Override public boolean areContentsTheSame(@NonNull final RankedRecipient oldItem, @NonNull final RankedRecipient newItem) { final boolean bothUsers = oldItem.getUser() != null && newItem.getUser() != null; if (bothUsers) { return Objects.equals(oldItem.getUser().getUsername(), newItem.getUser().getUsername()) && Objects.equals(oldItem.getUser().getFullName(), newItem.getUser().getFullName()); } return Objects.equals(oldItem.getThread().getThreadTitle(), newItem.getThread().getThreadTitle()); } }; private final boolean showSelection; private final Set selectedRecipients; private final OnDirectUserClickListener onUserClickListener; private final OnRecipientClickListener onRecipientClickListener; public UserSearchResultsAdapter(final boolean showSelection, final OnRecipientClickListener onRecipientClickListener) { super(DIFF_CALLBACK); this.showSelection = showSelection; selectedRecipients = showSelection ? new HashSet<>() : null; this.onRecipientClickListener = onRecipientClickListener; this.onUserClickListener = (position, user, selected) -> { if (onRecipientClickListener != null) { onRecipientClickListener.onClick(position, RankedRecipient.of(user), selected); } }; setHasStableIds(true); } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final LayoutDmUserItemBinding binding = LayoutDmUserItemBinding.inflate(layoutInflater, parent, false); if (viewType == VIEW_TYPE_USER) { return new DirectUserViewHolder(binding, onUserClickListener, null); } return new RecipientThreadViewHolder(binding, onRecipientClickListener); } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { final RankedRecipient recipient = getItem(position); final int itemViewType = getItemViewType(position); if (itemViewType == VIEW_TYPE_USER) { boolean isSelected = false; if (selectedRecipients != null) { isSelected = selectedRecipients.stream() .anyMatch(rankedRecipient -> rankedRecipient.getUser() != null && rankedRecipient.getUser().getPk() == recipient.getUser().getPk()); } ((DirectUserViewHolder) holder).bind(position, recipient.getUser(), false, false, showSelection, isSelected); return; } boolean isSelected = false; if (selectedRecipients != null) { isSelected = selectedRecipients.stream() .anyMatch(rankedRecipient -> rankedRecipient.getThread() != null && Objects.equals(rankedRecipient.getThread().getThreadId(), recipient.getThread().getThreadId())); } ((RecipientThreadViewHolder) holder).bind(position, recipient.getThread(), showSelection, isSelected); } @Override public long getItemId(final int position) { final RankedRecipient recipient = getItem(position); if (recipient.getUser() != null) { return recipient.getUser().getPk(); } if (recipient.getThread() != null) { return recipient.getThread().getThreadTitle().hashCode(); } return 0; } @Override public int getItemViewType(final int position) { final RankedRecipient recipient = getItem(position); return recipient.getUser() != null ? VIEW_TYPE_USER : VIEW_TYPE_THREAD; } public void setSelectedRecipient(final RankedRecipient recipient, final boolean selected) { if (selectedRecipients == null || recipient == null || (recipient.getUser() == null && recipient.getThread() == null)) return; final boolean isUser = recipient.getUser() != null; int position = -1; final List currentList = getCurrentList(); for (int i = 0; i < currentList.size(); i++) { final RankedRecipient temp = currentList.get(i); if (isUser) { if (temp.getUser() != null && temp.getUser().getPk() == recipient.getUser().getPk()) { position = i; break; } continue; } if (temp.getThread() != null && Objects.equals(temp.getThread().getThreadId(), recipient.getThread().getThreadId())) { position = i; break; } } if (position < 0) return; if (selected) { selectedRecipients.add(recipient); } else { selectedRecipients.remove(recipient); } notifyItemChanged(position); } public interface OnRecipientClickListener { void onClick(int position, RankedRecipient recipient, final boolean isSelected); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/CommentViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.content.Context; import android.content.res.Resources; import android.util.TypedValue; import android.view.Menu; import android.view.View; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.appcompat.view.ContextThemeWrapper; import androidx.appcompat.widget.PopupMenu; import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.adapters.CommentsAdapter.CommentCallback; import awais.instagrabber.customviews.ProfilePicView; import awais.instagrabber.databinding.ItemCommentBinding; import awais.instagrabber.models.Comment; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.Utils; public final class CommentViewHolder extends RecyclerView.ViewHolder { private final ItemCommentBinding binding; private final long currentUserId; private final CommentCallback commentCallback; @ColorInt private int parentCommentHighlightColor; private PopupMenu optionsPopup; public CommentViewHolder(@NonNull final ItemCommentBinding binding, final long currentUserId, final CommentCallback commentCallback) { super(binding.getRoot()); this.binding = binding; this.currentUserId = currentUserId; this.commentCallback = commentCallback; final Context context = itemView.getContext(); if (context == null) return; final Resources.Theme theme = context.getTheme(); if (theme == null) return; final TypedValue typedValue = new TypedValue(); final boolean resolved = theme.resolveAttribute(R.attr.parentCommentHighlightColor, typedValue, true); if (resolved) { parentCommentHighlightColor = typedValue.data; } } public void bind(final Comment comment, final boolean isReplyParent, final boolean isReply) { if (comment == null) return; itemView.setOnClickListener(v -> { if (commentCallback != null) { commentCallback.onClick(comment); } }); if (isReplyParent && parentCommentHighlightColor != 0) { itemView.setBackgroundColor(parentCommentHighlightColor); } else { itemView.setBackgroundColor(itemView.getResources().getColor(android.R.color.transparent)); } setupCommentText(comment, isReply); binding.date.setText(comment.getDateTime()); setLikes(comment, isReply); setReplies(comment, isReply); setUser(comment, isReply); setupOptions(comment, isReply); } private void setupCommentText(@NonNull final Comment comment, final boolean isReply) { binding.comment.clearOnURLClickListeners(); binding.comment.clearOnHashtagClickListeners(); binding.comment.clearOnMentionClickListeners(); binding.comment.clearOnEmailClickListeners(); binding.comment.setText(comment.getText()); binding.comment.setTextSize(TypedValue.COMPLEX_UNIT_SP, isReply ? 12 : 14); binding.comment.addOnHashtagListener(autoLinkItem -> { final String originalText = autoLinkItem.getOriginalText(); if (commentCallback == null) return; commentCallback.onHashtagClick(originalText); }); binding.comment.addOnMentionClickListener(autoLinkItem -> { final String originalText = autoLinkItem.getOriginalText(); if (commentCallback == null) return; commentCallback.onMentionClick(originalText); }); binding.comment.addOnEmailClickListener(autoLinkItem -> { final String originalText = autoLinkItem.getOriginalText(); if (commentCallback == null) return; commentCallback.onEmailClick(originalText); }); binding.comment.addOnURLClickListener(autoLinkItem -> { final String originalText = autoLinkItem.getOriginalText(); if (commentCallback == null) return; commentCallback.onURLClick(originalText); }); binding.comment.setOnLongClickListener(v -> { Utils.copyText(itemView.getContext(), comment.getText()); return true; }); binding.comment.setOnClickListener(v -> commentCallback.onClick(comment)); } private void setUser(@NonNull final Comment comment, final boolean isReply) { final User user = comment.getUser(); if (user == null) return; binding.username.setUsername(user.getUsername(), user.isVerified()); binding.username.setTextAppearance(itemView.getContext(), isReply ? R.style.TextAppearance_MaterialComponents_Subtitle2 : R.style.TextAppearance_MaterialComponents_Subtitle1); binding.username.setOnClickListener(v -> { if (commentCallback == null) return; commentCallback.onMentionClick("@" + user.getUsername()); }); binding.profilePic.setImageURI(user.getProfilePicUrl()); binding.profilePic.setSize(isReply ? ProfilePicView.Size.SMALLER : ProfilePicView.Size.SMALL); binding.profilePic.setOnClickListener(v -> { if (commentCallback == null) return; commentCallback.onMentionClick("@" + user.getUsername()); }); } private void setLikes(@NonNull final Comment comment, final boolean isReply) { binding.likes.setText(String.valueOf(comment.getCommentLikeCount())); binding.likes.setOnLongClickListener(v -> { if (commentCallback == null) return false; commentCallback.onViewLikes(comment); return true; }); if (currentUserId == 0) { // not logged in binding.likes.setOnClickListener(v -> { if (commentCallback == null) return; commentCallback.onViewLikes(comment); }); return; } final boolean liked = comment.getLiked(); final int resId = liked ? R.drawable.ic_like : R.drawable.ic_not_liked; binding.likes.setCompoundDrawablesRelativeWithSize(ContextCompat.getDrawable(itemView.getContext(), resId), null, null, null); binding.likes.setOnClickListener(v -> { if (commentCallback == null) return; // toggle like commentCallback.onLikeClick(comment, !liked, isReply); }); } private void setReplies(@NonNull final Comment comment, final boolean isReply) { final int replies = comment.getChildCommentCount(); binding.replies.setVisibility(View.VISIBLE); final String text = isReply ? "" : String.valueOf(replies); binding.replies.setText(text); binding.replies.setOnClickListener(v -> { if (commentCallback == null) return; commentCallback.onRepliesClick(comment); }); } private void setupOptions(final Comment comment, final boolean isReply) { binding.options.setOnClickListener(v -> { if (optionsPopup == null) { createOptionsPopupMenu(comment, isReply); } if (optionsPopup == null) return; optionsPopup.show(); }); } private void createOptionsPopupMenu(final Comment comment, final boolean isReply) { if (optionsPopup == null) { final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(itemView.getContext(), R.style.popupMenuStyle); optionsPopup = new PopupMenu(themeWrapper, binding.options); } else { optionsPopup.getMenu().clear(); } optionsPopup.getMenuInflater().inflate(R.menu.comment_options_menu, optionsPopup.getMenu()); final User user = comment.getUser(); if (currentUserId == 0 || user == null || user.getPk() != currentUserId) { final Menu menu = optionsPopup.getMenu(); menu.removeItem(R.id.delete); } optionsPopup.setOnMenuItemClickListener(item -> { if (commentCallback == null) return false; int itemId = item.getItemId(); if (itemId == R.id.translate) { commentCallback.onTranslate(comment); return true; } if (itemId == R.id.delete) { commentCallback.onDelete(comment, isReply); } return true; }); } // private void setupReply(final Comment comment) { // if (!isLoggedIn) { // binding.reply.setVisibility(View.GONE); // return; // } // binding.reply.setOnClickListener(v -> { // if (commentCallback == null) return; // // toggle like // commentCallback.onReplyClick(comment); // }); // } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/DiscoverViewHolder.java ================================================ //package awais.instagrabber.adapters.viewholder; // //import android.view.View; //import android.widget.ImageView; // //import androidx.annotation.NonNull; //import androidx.recyclerview.widget.RecyclerView; // //import com.facebook.drawee.view.SimpleDraweeView; // //import awais.instagrabber.R; // //public final class DiscoverViewHolder extends RecyclerView.ViewHolder { // public final SimpleDraweeView postImage; // public final ImageView typeIcon; // public final View selectedView; // // public final View progressView; // // public DiscoverViewHolder(@NonNull final View itemView) { // super(itemView); // typeIcon = itemView.findViewById(R.id.typeIcon); // postImage = itemView.findViewById(R.id.postImage); // selectedView = itemView.findViewById(R.id.selectedView); // // progressView = itemView.findViewById(R.id.progressView); // } //} ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/FavoriteViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.adapters.FavoritesAdapter; import awais.instagrabber.databinding.ItemSearchResultBinding; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.utils.Constants; public class FavoriteViewHolder extends RecyclerView.ViewHolder { private static final String TAG = "FavoriteViewHolder"; private final ItemSearchResultBinding binding; public FavoriteViewHolder(@NonNull final ItemSearchResultBinding binding) { super(binding.getRoot()); this.binding = binding; binding.verified.setVisibility(View.GONE); } public void bind(final Favorite model, final FavoritesAdapter.OnFavoriteClickListener clickListener, final FavoritesAdapter.OnFavoriteLongClickListener longClickListener) { // Log.d(TAG, "bind: " + model); if (model == null) return; itemView.setOnClickListener(v -> { if (clickListener == null) return; clickListener.onClick(model); }); itemView.setOnLongClickListener(v -> { if (clickListener == null) return false; return longClickListener.onLongClick(model); }); if (model.getType() == FavoriteType.HASHTAG) { binding.profilePic.setImageURI(Constants.DEFAULT_HASH_TAG_PIC); } else { binding.profilePic.setImageURI(model.getPicUrl()); } binding.title.setVisibility(View.VISIBLE); binding.subtitle.setText(model.getDisplayName()); String query = model.getQuery(); switch (model.getType()) { case HASHTAG: query = "#" + query; break; case USER: query = "@" + query; break; case LOCATION: binding.title.setVisibility(View.GONE); break; default: // do nothing } binding.title.setText(query); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/FeedGridItemViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.content.Context; import android.content.res.ColorStateList; import android.net.Uri; import android.view.View; import android.view.ViewGroup; import androidx.annotation.DimenRes; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.backends.pipeline.PipelineDraweeControllerBuilder; import com.facebook.imagepipeline.common.ResizeOptions; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.databinding.ItemFeedGridBinding; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; import static awais.instagrabber.models.PostsLayoutPreferences.PostsLayoutType.STAGGERED_GRID; public class FeedGridItemViewHolder extends RecyclerView.ViewHolder { private final ItemFeedGridBinding binding; public FeedGridItemViewHolder(@NonNull final ItemFeedGridBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final int position, @NonNull final Media media, @NonNull final PostsLayoutPreferences layoutPreferences, final FeedAdapterV2.FeedItemCallback feedItemCallback, final FeedAdapterV2.AdapterSelectionCallback adapterSelectionCallback, final boolean selectionModeActive, final boolean selected) { itemView.setOnClickListener(v -> { if (!selectionModeActive && feedItemCallback != null) { feedItemCallback.onPostClick(media); return; } if (selectionModeActive && adapterSelectionCallback != null) { adapterSelectionCallback.onPostClick(position, media); } }); if (adapterSelectionCallback != null) { itemView.setOnLongClickListener(v -> adapterSelectionCallback.onPostLongClick(position, media)); } binding.selectedView.setVisibility(selected ? View.VISIBLE : View.GONE); // for rounded borders (clip view to background shape) itemView.setClipToOutline(layoutPreferences.getHasRoundedCorners()); if (layoutPreferences.getType() == STAGGERED_GRID) { final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); binding.postImage.setAspectRatio(aspectRatio); } else { binding.postImage.setAspectRatio(1); } setUserDetails(media, layoutPreferences); String thumbnailUrl = null; final int typeIconRes; final MediaItemType mediaType = media.getType(); if (mediaType == null) return; switch (mediaType) { case MEDIA_TYPE_IMAGE: typeIconRes = -1; thumbnailUrl = ResponseBodyUtils.getThumbUrl(media); break; case MEDIA_TYPE_VIDEO: thumbnailUrl = ResponseBodyUtils.getThumbUrl(media); typeIconRes = R.drawable.exo_icon_play; break; case MEDIA_TYPE_SLIDER: final List sliderItems = media.getCarouselMedia(); if (sliderItems != null) { final Media child = sliderItems.get(0); if (child != null) { thumbnailUrl = ResponseBodyUtils.getThumbUrl(child); if (layoutPreferences.getType() == STAGGERED_GRID) { final float childAspectRatio = (float) child.getOriginalWidth() / child.getOriginalHeight(); binding.postImage.setAspectRatio(childAspectRatio); } } } typeIconRes = R.drawable.ic_checkbox_multiple_blank_stroke; break; default: typeIconRes = -1; thumbnailUrl = null; } setThumbImage(thumbnailUrl); if (typeIconRes <= 0) { binding.typeIcon.setVisibility(View.GONE); } else { binding.typeIcon.setVisibility(View.VISIBLE); binding.typeIcon.setImageResource(typeIconRes); } binding.downloaded.setVisibility(View.GONE); final Context context = itemView.getContext(); if (context == null) { return; } AppExecutors.INSTANCE.getTasksThread().execute(() -> { final List checkList = DownloadUtils.checkDownloaded(media, context); if (checkList.isEmpty()) { return; } AppExecutors.INSTANCE.getMainThread().execute(() -> { switch (media.getType()) { case MEDIA_TYPE_IMAGE: case MEDIA_TYPE_VIDEO: binding.downloaded.setVisibility(checkList.get(0) ? View.VISIBLE : View.GONE); binding.downloaded.setImageTintList(ColorStateList.valueOf(itemView.getResources().getColor(R.color.green_A400))); break; case MEDIA_TYPE_SLIDER: binding.downloaded.setVisibility(checkList.get(0) ? View.VISIBLE : View.GONE); final List carouselMedia = media.getCarouselMedia(); boolean allDownloaded = checkList.size() == (carouselMedia == null ? 0 : carouselMedia.size()); if (allDownloaded) { allDownloaded = checkList.stream().allMatch(downloaded -> downloaded); } binding.downloaded.setImageTintList(ColorStateList.valueOf(itemView.getResources().getColor( allDownloaded ? R.color.green_A400 : R.color.yellow_400))); break; default: } }); }); } private void setThumbImage(final String thumbnailUrl) { if (TextUtils.isEmpty(thumbnailUrl)) { binding.postImage.setController(null); return; } final ImageRequest requestBuilder = ImageRequestBuilder.newBuilderWithSource(Uri.parse(thumbnailUrl)) .setResizeOptions(ResizeOptions.forDimensions(binding.postImage.getWidth(), binding.postImage.getHeight())) .setLocalThumbnailPreviewsEnabled(true) .setProgressiveRenderingEnabled(true) .build(); final PipelineDraweeControllerBuilder builder = Fresco.newDraweeControllerBuilder() .setImageRequest(requestBuilder) .setOldController(binding.postImage.getController()); binding.postImage.setController(builder.build()); } private void setUserDetails(@NonNull final Media media, @NonNull final PostsLayoutPreferences layoutPreferences) { final User user = media.getUser(); if (layoutPreferences.isAvatarVisible()) { if (user == null) { binding.profilePic.setVisibility(View.GONE); } else { final String profilePicUrl = user.getProfilePicUrl(); if (TextUtils.isEmpty(profilePicUrl)) { binding.profilePic.setVisibility(View.GONE); } else { binding.profilePic.setVisibility(View.VISIBLE); binding.profilePic.setImageURI(profilePicUrl); } } final ViewGroup.LayoutParams layoutParams = binding.profilePic.getLayoutParams(); @DimenRes final int dimenRes; switch (layoutPreferences.getProfilePicSize()) { case SMALL: dimenRes = R.dimen.profile_pic_size_small; break; case TINY: dimenRes = R.dimen.profile_pic_size_tiny; break; default: case REGULAR: dimenRes = R.dimen.profile_pic_size_regular; break; } final int dimensionPixelSize = itemView.getResources().getDimensionPixelSize(dimenRes); layoutParams.width = dimensionPixelSize; layoutParams.height = dimensionPixelSize; binding.profilePic.requestLayout(); } else { binding.profilePic.setVisibility(View.GONE); } if (layoutPreferences.isNameVisible()) { if (user == null) { binding.name.setVisibility(View.GONE); } else { final String username = user.getUsername(); if (username == null) { binding.name.setVisibility(View.GONE); } else { binding.name.setVisibility(View.VISIBLE); binding.name.setText(username); } } } else { binding.name.setVisibility(View.GONE); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/FeedStoryViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.adapters.FeedStoriesAdapter; import awais.instagrabber.databinding.ItemHighlightBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.stories.Story; public final class FeedStoryViewHolder extends RecyclerView.ViewHolder { private final ItemHighlightBinding binding; public FeedStoryViewHolder(final ItemHighlightBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final Story model, final int position, final FeedStoriesAdapter.OnFeedStoryClickListener listener) { if (model == null) return; binding.getRoot().setOnClickListener(v -> { if (listener == null) return; listener.onFeedStoryClick(model, position); }); binding.getRoot().setOnLongClickListener(v -> { if (listener != null) listener.onFeedStoryLongClick(model, position); return true; }); final User profileModel = model.getUser(); binding.title.setText(profileModel.getUsername()); final boolean isFullyRead = model.getSeen() != null && model.getSeen().equals(model.getLatestReelMedia()); binding.title.setAlpha(isFullyRead ? 0.5F : 1.0F); binding.icon.setImageURI(profileModel.getProfilePicUrl()); binding.icon.setAlpha(isFullyRead ? 0.5F : 1.0F); if (model.getBroadcast() != null) binding.icon.setStoriesBorder(2); else if (model.getHasBestiesMedia() == true) binding.icon.setStoriesBorder(1); else binding.icon.setStoriesBorder(0); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/FilterViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.graphics.Bitmap; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.google.common.collect.ImmutableList; import java.util.Collection; import awais.instagrabber.adapters.FiltersAdapter; import awais.instagrabber.databinding.ItemFilterBinding; import awais.instagrabber.fragments.imageedit.filters.filters.Filter; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.BitmapUtils; import jp.co.cyberagent.android.gpuimage.GPUImage; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; public class FilterViewHolder extends RecyclerView.ViewHolder { private static final String TAG = FilterViewHolder.class.getSimpleName(); private final ItemFilterBinding binding; private final Collection tuneFilters; private final FiltersAdapter.OnFilterClickListener onFilterClickListener; private final AppExecutors appExecutors; public FilterViewHolder(@NonNull final ItemFilterBinding binding, final Collection tuneFilters, final FiltersAdapter.OnFilterClickListener onFilterClickListener) { super(binding.getRoot()); this.binding = binding; this.tuneFilters = tuneFilters; this.onFilterClickListener = onFilterClickListener; appExecutors = AppExecutors.INSTANCE; } public void bind(final int position, final String originalKey, final Bitmap originalBitmap, final Filter item, final boolean isSelected) { if (originalBitmap == null || item == null) return; if (onFilterClickListener != null) { itemView.setOnClickListener(v -> onFilterClickListener.onClick(position, item)); } if (item.getLabel() != -1) { binding.name.setVisibility(View.VISIBLE); binding.name.setText(item.getLabel()); binding.name.setSelected(isSelected); } else { binding.name.setVisibility(View.GONE); } final String filterKey = item.getLabel() + "_" + originalKey; // avoid resetting the bitmap if (binding.preview.getTag() != null && binding.preview.getTag().equals(filterKey)) return; binding.preview.setTag(filterKey); final Bitmap bitmap = BitmapUtils.getBitmapFromMemCache(filterKey); if (bitmap == null) { final GPUImageFilter filter = item.getInstance(); appExecutors.getTasksThread().submit(() -> { GPUImage.getBitmapForMultipleFilters( originalBitmap, ImmutableList.builder().add(filter).addAll(tuneFilters).build(), filteredBitmap -> { BitmapUtils.addBitmapToMemoryCache(filterKey, filteredBitmap, true); appExecutors.getMainThread().execute(() -> binding.getRoot().post(() -> binding.preview.setImageBitmap(filteredBitmap))); } ); }); return; } binding.getRoot().post(() -> binding.preview.setImageBitmap(bitmap)); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/FollowsViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.databinding.ItemFollowBinding; import awais.instagrabber.repositories.responses.User; public final class FollowsViewHolder extends RecyclerView.ViewHolder { private final ItemFollowBinding binding; public FollowsViewHolder(@NonNull final ItemFollowBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final User model, final View.OnClickListener onClickListener) { if (model == null) return; itemView.setTag(model); itemView.setOnClickListener(onClickListener); binding.username.setUsername("@" + model.getUsername(), model.isVerified()); binding.fullName.setText(model.getFullName()); binding.profilePic.setImageURI(model.getProfilePicUrl()); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/HighlightViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.databinding.ItemHighlightBinding; import awais.instagrabber.repositories.responses.stories.Story; public final class HighlightViewHolder extends RecyclerView.ViewHolder { private final ItemHighlightBinding binding; public HighlightViewHolder(final ItemHighlightBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final Story model) { if (model == null) return; binding.title.setText(model.getTitle()); binding.icon.setImageURI(model.getCoverMedia().getCroppedImageVersion().getUrl()); // binding.getRoot().setOnClickListener(v -> { // if (listener == null) return; // listener.onFeedStoryClick(model, position); // }); // final ProfileModel profileModel = model.getProfileModel(); // binding.title.setText(profileModel.getUsername()); // binding.title.setAlpha(model.getFullyRead() ? 0.5F : 1.0F); // binding.icon.setImageURI(profileModel.getSdProfilePic()); // binding.icon.setAlpha(model.getFullyRead() ? 0.5F : 1.0F); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/NotificationViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.text.TextUtils; import android.view.View; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.adapters.NotificationsAdapter.OnNotificationClickListener; import awais.instagrabber.databinding.ItemNotificationBinding; import awais.instagrabber.models.enums.NotificationType; import awais.instagrabber.repositories.responses.notification.Notification; import awais.instagrabber.repositories.responses.notification.NotificationArgs; public final class NotificationViewHolder extends RecyclerView.ViewHolder { private final ItemNotificationBinding binding; public NotificationViewHolder(final ItemNotificationBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final Notification model, final OnNotificationClickListener notificationClickListener) { if (model == null) return; int text = -1; CharSequence subtext = null; final NotificationArgs args = model.getArgs(); switch (model.getType()) { case LIKE: text = R.string.liked_notif; break; case COMMENT: // untested text = R.string.comment_notif; subtext = args.getText(); break; case TAGGED: text = R.string.tagged_notif; break; case FOLLOW: text = R.string.follow_notif; break; case REQUEST: text = R.string.request_notif; break; case COMMENT_MENTION: case COMMENT_LIKE: case TAGGED_COMMENT: case RESPONDED_STORY: subtext = args.getText(); break; case AYML: subtext = args.getFullName(); break; } binding.tvSubComment.setText(model.getType() == NotificationType.AYML ? args.getText() : subtext); if (text == -1 && subtext != null) { binding.tvComment.setText(args.getText()); binding.tvComment.setVisibility(TextUtils.isEmpty(args.getText()) || args.getText().equals(args.getFullName()) ? View.GONE : View.VISIBLE); binding.tvSubComment.setText(subtext); binding.tvSubComment.setVisibility(model.getType() == NotificationType.AYML ? View.VISIBLE : View.GONE); } else if (text != -1) { binding.tvComment.setText(text); binding.tvSubComment.setVisibility(subtext == null ? View.GONE : View.VISIBLE); } binding.tvDate.setVisibility(model.getType() == NotificationType.AYML ? View.GONE : View.VISIBLE); if (model.getType() != NotificationType.AYML) { binding.tvDate.setText(args.getDateTime()); } binding.isVerified.setVisibility(args.isVerified() ? View.VISIBLE : View.GONE); binding.tvUsername.setText(args.getUsername()); binding.ivProfilePic.setImageURI(args.getProfilePic()); binding.ivProfilePic.setOnClickListener(v -> { if (notificationClickListener == null) return; notificationClickListener.onProfileClick(args.getUsername()); }); if (model.getType() == NotificationType.AYML) { binding.ivPreviewPic.setVisibility(View.GONE); } else if (args.getMedia() == null) { binding.ivPreviewPic.setVisibility(View.INVISIBLE); } else { binding.ivPreviewPic.setVisibility(View.VISIBLE); binding.ivPreviewPic.setImageURI(args.getMedia().get(0).getImage()); binding.ivPreviewPic.setOnClickListener(v -> { if (notificationClickListener == null) return; notificationClickListener.onPreviewClick(model); }); } itemView.setOnClickListener(v -> { if (notificationClickListener == null) return; notificationClickListener.onNotificationClick(model); }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/SearchItemViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.databinding.ItemSearchResultBinding; import awais.instagrabber.fragments.search.SearchCategoryFragment.OnSearchItemClickListener; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.repositories.responses.Hashtag; import awais.instagrabber.repositories.responses.Place; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.search.SearchItem; public class SearchItemViewHolder extends RecyclerView.ViewHolder { private final ItemSearchResultBinding binding; private final OnSearchItemClickListener onSearchItemClickListener; public SearchItemViewHolder(@NonNull final ItemSearchResultBinding binding, final OnSearchItemClickListener onSearchItemClickListener) { super(binding.getRoot()); this.binding = binding; this.onSearchItemClickListener = onSearchItemClickListener; } public void bind(final SearchItem searchItem) { if (searchItem == null) return; final FavoriteType type = searchItem.getType(); if (type == null) return; String title; String subtitle; String picUrl; boolean isVerified = false; switch (type) { case USER: final User user = searchItem.getUser(); title = "@" + user.getUsername(); subtitle = user.getFullName(); picUrl = user.getProfilePicUrl(); isVerified = user.isVerified(); break; case HASHTAG: final Hashtag hashtag = searchItem.getHashtag(); title = "#" + hashtag.getName(); subtitle = hashtag.getSearchResultSubtitle(); picUrl = "res:/" + R.drawable.ic_hashtag; break; case LOCATION: final Place place = searchItem.getPlace(); title = place.getTitle(); subtitle = place.getSubtitle(); picUrl = "res:/" + R.drawable.ic_location; break; default: return; } itemView.setOnClickListener(v -> { if (onSearchItemClickListener != null) { onSearchItemClickListener.onSearchItemClick(searchItem); } }); binding.delete.setVisibility(searchItem.isRecent() ? View.VISIBLE : View.GONE); if (searchItem.isRecent()) { binding.delete.setEnabled(true); binding.delete.setOnClickListener(v -> { if (onSearchItemClickListener != null) { binding.delete.setEnabled(false); onSearchItemClickListener.onSearchItemDelete(searchItem, type); } }); } binding.title.setText(title); binding.subtitle.setText(subtitle); binding.profilePic.setImageURI(picUrl); binding.verified.setVisibility(isVerified ? View.VISIBLE : View.GONE); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/SliderItemViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.adapters.SliderItemsAdapter; import awais.instagrabber.repositories.responses.Media; public abstract class SliderItemViewHolder extends RecyclerView.ViewHolder { private static final String TAG = "FeedSliderItemViewHolder"; public SliderItemViewHolder(@NonNull final View itemView) { super(itemView); } public abstract void bind(final Media media, final int position, final SliderItemsAdapter.SliderCallback sliderCallback); } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/SliderPhotoViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.graphics.drawable.Animatable; import android.net.Uri; import android.view.MotionEvent; import androidx.annotation.NonNull; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.imagepipeline.image.ImageInfo; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import awais.instagrabber.adapters.SliderItemsAdapter; import awais.instagrabber.customviews.drawee.AnimatedZoomableController; import awais.instagrabber.customviews.drawee.DoubleTapGestureListener; import awais.instagrabber.databinding.ItemSliderPhotoBinding; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.ResponseBodyUtils; public class SliderPhotoViewHolder extends SliderItemViewHolder { private static final String TAG = "FeedSliderPhotoViewHolder"; private final ItemSliderPhotoBinding binding; public SliderPhotoViewHolder(@NonNull final ItemSliderPhotoBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(@NonNull final Media model, final int position, final SliderItemsAdapter.SliderCallback sliderCallback) { final ImageRequest requestBuilder = ImageRequestBuilder .newBuilderWithSource(Uri.parse(ResponseBodyUtils.getImageUrl(model))) .setLocalThumbnailPreviewsEnabled(true) .build(); binding.getRoot() .setController(Fresco.newDraweeControllerBuilder() .setImageRequest(requestBuilder) .setControllerListener(new BaseControllerListener() { @Override public void onFailure(final String id, final Throwable throwable) { if (sliderCallback != null) { sliderCallback.onThumbnailLoaded(position); } } @Override public void onFinalImageSet(final String id, final ImageInfo imageInfo, final Animatable animatable) { if (sliderCallback != null) { sliderCallback.onThumbnailLoaded(position); } } }) .setLowResImageRequest(ImageRequest.fromUri(ResponseBodyUtils.getThumbUrl(model))) .build()); final DoubleTapGestureListener tapListener = new DoubleTapGestureListener(binding.getRoot()) { @Override public boolean onSingleTapConfirmed(final MotionEvent e) { if (sliderCallback != null) { sliderCallback.onItemClicked(position, model, binding.getRoot()); } return super.onSingleTapConfirmed(e); } }; binding.getRoot().setTapListener(tapListener); final AnimatedZoomableController zoomableController = AnimatedZoomableController.newInstance(); zoomableController.setMaxScaleFactor(3f); binding.getRoot().setZoomableController(zoomableController); binding.getRoot().setZoomingEnabled(true); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/SliderVideoViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.annotation.SuppressLint; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.ViewGroup; import androidx.annotation.NonNull; import com.google.android.exoplayer2.ui.StyledPlayerView; import java.util.List; import awais.instagrabber.adapters.SliderItemsAdapter; import awais.instagrabber.customviews.VideoPlayerCallbackAdapter; import awais.instagrabber.customviews.VideoPlayerViewHelper; import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.MediaCandidate; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.Utils; import static awais.instagrabber.utils.Utils.settingsHelper; public class SliderVideoViewHolder extends SliderItemViewHolder { private static final String TAG = "SliderVideoViewHolder"; private final LayoutVideoPlayerWithThumbnailBinding binding; private final boolean loadVideoOnItemClick; private VideoPlayerViewHelper videoPlayerViewHelper; @SuppressLint("ClickableViewAccessibility") public SliderVideoViewHolder(@NonNull final LayoutVideoPlayerWithThumbnailBinding binding, final boolean loadVideoOnItemClick) { super(binding.getRoot()); this.binding = binding; this.loadVideoOnItemClick = loadVideoOnItemClick; final GestureDetector.OnGestureListener videoPlayerViewGestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onSingleTapConfirmed(final MotionEvent e) { binding.playerView.performClick(); return true; } }; final GestureDetector gestureDetector = new GestureDetector(itemView.getContext(), videoPlayerViewGestureListener); binding.playerView.setOnTouchListener((v, event) -> { gestureDetector.onTouchEvent(event); return true; }); } public void bind(@NonNull final Media media, final int position, final SliderItemsAdapter.SliderCallback sliderCallback) { final float vol = settingsHelper.getBoolean(PreferenceKeys.MUTED_VIDEOS) ? 0f : 1f; final VideoPlayerViewHelper.VideoPlayerCallback videoPlayerCallback = new VideoPlayerCallbackAdapter() { @Override public void onThumbnailClick() { if (sliderCallback != null) { sliderCallback.onItemClicked(position, media, binding.getRoot()); } } @Override public void onThumbnailLoaded() { if (sliderCallback != null) { sliderCallback.onThumbnailLoaded(position); } } @Override public void onPlayerViewLoaded() { // binding.itemFeedBottom.btnMute.setVisibility(View.VISIBLE); final ViewGroup.LayoutParams layoutParams = binding.playerView.getLayoutParams(); final int requiredWidth = Utils.displayMetrics.widthPixels; final int resultingHeight = NumberUtils.getResultingHeight(requiredWidth, media.getOriginalHeight(), media.getOriginalWidth()); layoutParams.width = requiredWidth; layoutParams.height = resultingHeight; binding.playerView.requestLayout(); // setMuteIcon(vol == 0f && Utils.sessionVolumeFull ? 1f : vol); } @Override public void onPlay() { if (sliderCallback != null) { sliderCallback.onPlayerPlay(position); } } @Override public void onPause() { if (sliderCallback != null) { sliderCallback.onPlayerPause(position); } } @Override public void onRelease() { if (sliderCallback != null) { sliderCallback.onPlayerRelease(position); } } @Override public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) { if (sliderCallback != null) { sliderCallback.onFullScreenModeChanged(isFullScreen, playerView); } } @Override public boolean isInFullScreen() { if (sliderCallback != null) { return sliderCallback.isInFullScreen(); } return false; } }; final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); String videoUrl = null; final List videoVersions = media.getVideoVersions(); if (videoVersions != null && !videoVersions.isEmpty()) { final MediaCandidate videoVersion = videoVersions.get(0); if (videoVersion != null) { videoUrl = videoVersion.getUrl(); } } if (videoUrl == null) return; videoPlayerViewHelper = new VideoPlayerViewHelper(binding.getRoot().getContext(), binding, videoUrl, vol, aspectRatio, ResponseBodyUtils.getThumbUrl(media), loadVideoOnItemClick, videoPlayerCallback); binding.playerView.setOnClickListener(v -> { if (sliderCallback != null) { sliderCallback.onItemClicked(position, media, binding.getRoot()); } }); } public void pause() { if (videoPlayerViewHelper == null) return; videoPlayerViewHelper.pause(); } public void releasePlayer() { if (videoPlayerViewHelper == null) return; videoPlayerViewHelper.releasePlayer(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/StoryListViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.view.View; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.adapters.FeedStoriesListAdapter.OnFeedStoryClickListener; import awais.instagrabber.adapters.HighlightStoriesListAdapter.OnHighlightStoryClickListener; import awais.instagrabber.databinding.ItemNotificationBinding; import awais.instagrabber.repositories.responses.stories.Story; import awais.instagrabber.utils.ResponseBodyUtils; public final class StoryListViewHolder extends RecyclerView.ViewHolder { private final ItemNotificationBinding binding; public StoryListViewHolder(final ItemNotificationBinding binding) { super(binding.getRoot()); this.binding = binding; } public void bind(final Story model, final OnFeedStoryClickListener notificationClickListener) { if (model == null) return; final int storiesCount = model.getMediaCount(); binding.tvComment.setVisibility(View.VISIBLE); binding.tvComment.setText(itemView.getResources().getQuantityString(R.plurals.stories_count, storiesCount, storiesCount)); binding.tvSubComment.setVisibility(View.GONE); binding.tvDate.setText(model.getDateTime()); binding.tvUsername.setText(model.getUser().getUsername()); binding.ivProfilePic.setImageURI(model.getUser().getProfilePicUrl()); binding.ivProfilePic.setOnClickListener(v -> { if (notificationClickListener == null) return; notificationClickListener.onProfileClick(model.getUser().getUsername()); }); if (model.getItems() != null && model.getItems().size() > 0) { binding.ivPreviewPic.setVisibility(View.VISIBLE); binding.ivPreviewPic.setImageURI(ResponseBodyUtils.getThumbUrl(model.getItems().get(0))); } else binding.ivPreviewPic.setVisibility(View.INVISIBLE); float alpha = model.getSeen() != null && model.getSeen().equals(model.getLatestReelMedia()) ? 0.5F : 1.0F; binding.ivProfilePic.setAlpha(alpha); binding.ivPreviewPic.setAlpha(alpha); binding.tvUsername.setAlpha(alpha); binding.tvComment.setAlpha(alpha); binding.tvDate.setAlpha(alpha); itemView.setOnClickListener(v -> { if (notificationClickListener == null) return; notificationClickListener.onFeedStoryClick(model); }); } public void bind(final Story model, final int position, final OnHighlightStoryClickListener notificationClickListener) { if (model == null) return; final int storiesCount = model.getMediaCount(); binding.tvComment.setVisibility(View.VISIBLE); binding.tvComment.setText(itemView.getResources().getQuantityString(R.plurals.stories_count, storiesCount, storiesCount)); binding.tvSubComment.setVisibility(View.GONE); binding.tvUsername.setText(model.getDateTime()); binding.ivProfilePic.setVisibility(View.GONE); binding.ivPreviewPic.setVisibility(View.VISIBLE); binding.ivPreviewPic.setImageURI(model.getCoverImageVersion().getUrl()); itemView.setOnClickListener(v -> { if (notificationClickListener == null) return; notificationClickListener.onHighlightClick(model, position); }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/TabViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.annotation.SuppressLint; import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.core.widget.ImageViewCompat; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.color.MaterialColors; import awais.instagrabber.R; import awais.instagrabber.adapters.TabsAdapter; import awais.instagrabber.databinding.ItemTabOrderPrefBinding; import awais.instagrabber.models.Tab; public class TabViewHolder extends RecyclerView.ViewHolder { private final ItemTabOrderPrefBinding binding; private final TabsAdapter.TabAdapterCallback tabAdapterCallback; private final int highlightColor; private final Drawable originalBgColor; private boolean draggable = true; @SuppressLint("ClickableViewAccessibility") public TabViewHolder(@NonNull final ItemTabOrderPrefBinding binding, @NonNull final TabsAdapter.TabAdapterCallback tabAdapterCallback) { super(binding.getRoot()); this.binding = binding; this.tabAdapterCallback = tabAdapterCallback; highlightColor = MaterialColors.getColor(itemView.getContext(), R.attr.colorControlHighlight, 0); originalBgColor = itemView.getBackground(); binding.handle.setOnTouchListener((v, event) -> { if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { tabAdapterCallback.onStartDrag(this); } return true; }); } public void bind(@NonNull final Tab tab, final boolean isInOthers, final boolean isCurrentFull) { draggable = !isInOthers; binding.icon.setImageResource(tab.getIconResId()); binding.title.setText(tab.getTitle()); binding.handle.setVisibility(isInOthers ? View.GONE : View.VISIBLE); binding.addRemove.setImageResource(isInOthers ? R.drawable.ic_round_add_circle_24 : R.drawable.ic_round_remove_circle_24); final ColorStateList tintList = ColorStateList.valueOf(ContextCompat.getColor( itemView.getContext(), isInOthers ? R.color.green_500 : R.color.red_500)); ImageViewCompat.setImageTintList(binding.addRemove, tintList); binding.addRemove.setOnClickListener(v -> { if (isInOthers) { tabAdapterCallback.onAdd(tab); return; } tabAdapterCallback.onRemove(tab); }); final boolean enabled = tab.isRemovable() && !(isInOthers && isCurrentFull); // All slots are full in current binding.addRemove.setEnabled(enabled); binding.addRemove.setAlpha(enabled ? 1 : 0.5F); } public boolean isDraggable() { return draggable; } public void setDragging(final boolean isDragging) { if (isDragging) { if (highlightColor != 0) { itemView.setBackgroundColor(highlightColor); } else { itemView.setAlpha(0.5F); } return; } itemView.setAlpha(1); itemView.setBackground(originalBgColor); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/TopicClusterViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.GradientDrawable; import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.palette.graphics.Palette; import androidx.recyclerview.widget.RecyclerView; import com.facebook.common.executors.CallerThreadExecutor; import com.facebook.common.references.CloseableReference; import com.facebook.datasource.DataSource; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.imagepipeline.core.ImagePipeline; import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; import com.facebook.imagepipeline.image.CloseableImage; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import java.util.concurrent.atomic.AtomicInteger; import awais.instagrabber.R; import awais.instagrabber.adapters.DiscoverTopicsAdapter; import awais.instagrabber.adapters.SavedCollectionsAdapter; import awais.instagrabber.databinding.ItemDiscoverTopicBinding; import awais.instagrabber.repositories.responses.discover.TopicCluster; import awais.instagrabber.repositories.responses.saved.SavedCollection; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.ResponseBodyUtils; public class TopicClusterViewHolder extends RecyclerView.ViewHolder { private final ItemDiscoverTopicBinding binding; private final DiscoverTopicsAdapter.OnTopicClickListener onTopicClickListener; private final SavedCollectionsAdapter.OnCollectionClickListener onCollectionClickListener; public TopicClusterViewHolder(@NonNull final ItemDiscoverTopicBinding binding, final DiscoverTopicsAdapter.OnTopicClickListener onTopicClickListener, final SavedCollectionsAdapter.OnCollectionClickListener onCollectionClickListener) { super(binding.getRoot()); this.binding = binding; this.onTopicClickListener = onTopicClickListener; this.onCollectionClickListener = onCollectionClickListener; } public void bind(final TopicCluster topicCluster) { if (topicCluster == null) { return; } final AtomicInteger titleColor = new AtomicInteger(-1); final AtomicInteger backgroundColor = new AtomicInteger(-1); if (onTopicClickListener != null) { itemView.setOnClickListener(v -> onTopicClickListener.onTopicClick( topicCluster, binding.cover, titleColor.get(), backgroundColor.get() )); itemView.setOnLongClickListener(v -> { onTopicClickListener.onTopicLongClick(topicCluster.getCoverMedia()); return true; }); } // binding.title.setTransitionName("title-" + topicCluster.getId()); binding.cover.setTransitionName("cover-" + topicCluster.getId()); final String thumbUrl = ResponseBodyUtils.getThumbUrl(topicCluster.getCoverMedia()); if (thumbUrl == null) { binding.cover.setImageURI((String) null); } else { final ImageRequest imageRequest = ImageRequestBuilder .newBuilderWithSource(Uri.parse(thumbUrl)) .build(); final ImagePipeline imagePipeline = Fresco.getImagePipeline(); final DataSource> dataSource = imagePipeline .fetchDecodedImage(imageRequest, CallerThreadExecutor.getInstance()); dataSource.subscribe(new BaseBitmapDataSubscriber() { @Override public void onNewResultImpl(@Nullable Bitmap bitmap) { if (dataSource.isFinished()) { dataSource.close(); } if (bitmap != null) { Palette.from(bitmap).generate(p -> { final Resources resources = itemView.getResources(); int titleTextColor = resources.getColor(R.color.white); if (p != null) { final Palette.Swatch swatch = p.getDominantSwatch(); if (swatch != null) { backgroundColor.set(swatch.getRgb()); GradientDrawable gd = new GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, new int[]{Color.TRANSPARENT, backgroundColor.get()}); titleTextColor = swatch.getTitleTextColor(); binding.background.setBackground(gd); } } titleColor.set(titleTextColor); binding.title.setTextColor(titleTextColor); }); } } @Override public void onFailureImpl(@NonNull DataSource dataSource) { dataSource.close(); } }, CallerThreadExecutor.getInstance()); binding.cover.setImageRequest(imageRequest); } binding.title.setText(topicCluster.getTitle()); } public void bind(final SavedCollection topicCluster) { if (topicCluster == null) { return; } final AtomicInteger titleColor = new AtomicInteger(-1); final AtomicInteger backgroundColor = new AtomicInteger(-1); if (onCollectionClickListener != null) { itemView.setOnClickListener(v -> onCollectionClickListener.onCollectionClick( topicCluster, binding.getRoot(), binding.cover, binding.title, titleColor.get(), backgroundColor.get() )); } // binding.title.setTransitionName("title-" + topicCluster.getCollectionId()); binding.cover.setTransitionName("cover-" + topicCluster.getCollectionId()); final Media coverMedia = topicCluster.getCoverMediaList() == null ? topicCluster.getCoverMedia() : topicCluster.getCoverMediaList().get(0); final String thumbUrl = ResponseBodyUtils.getThumbUrl(coverMedia); if (thumbUrl == null) { binding.cover.setImageURI((String) null); } else { final ImageRequest imageRequest = ImageRequestBuilder .newBuilderWithSource(Uri.parse(thumbUrl)) .build(); final ImagePipeline imagePipeline = Fresco.getImagePipeline(); final DataSource> dataSource = imagePipeline .fetchDecodedImage(imageRequest, CallerThreadExecutor.getInstance()); dataSource.subscribe(new BaseBitmapDataSubscriber() { @Override public void onNewResultImpl(@Nullable Bitmap bitmap) { if (dataSource.isFinished()) { dataSource.close(); } if (bitmap != null) { Palette.from(bitmap).generate(p -> { final Resources resources = itemView.getResources(); int titleTextColor = resources.getColor(R.color.white); if (p != null) { final Palette.Swatch swatch = p.getDominantSwatch(); if (swatch != null) { backgroundColor.set(swatch.getRgb()); GradientDrawable gd = new GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, new int[]{Color.TRANSPARENT, backgroundColor.get()} ); titleTextColor = swatch.getTitleTextColor(); binding.background.setBackground(gd); } } titleColor.set(titleTextColor); binding.title.setTextColor(titleTextColor); }); } } @Override public void onFailureImpl(@NonNull DataSource dataSource) { dataSource.close(); } }, CallerThreadExecutor.getInstance()); binding.cover.setImageRequest(imageRequest); } binding.title.setText(topicCluster.getCollectionName()); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/dialogs/KeywordsFilterDialogViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.dialogs; import android.content.Context; import android.view.View; import android.widget.Button; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.HashSet; import awais.instagrabber.R; import awais.instagrabber.adapters.KeywordsFilterAdapter; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.utils.SettingsHelper; public class KeywordsFilterDialogViewHolder extends RecyclerView.ViewHolder { private final Button deleteButton; private final TextView item; public KeywordsFilterDialogViewHolder(@NonNull View itemView) { super(itemView); deleteButton = itemView.findViewById(R.id.keyword_delete); item = itemView.findViewById(R.id.keyword_text); } public void bind(ArrayList items, int position, Context context, KeywordsFilterAdapter adapter){ item.setText(items.get(position)); deleteButton.setOnClickListener(view -> { final String s = items.get(position); SettingsHelper settingsHelper = new SettingsHelper(context); items.remove(position); settingsHelper.putStringSet(PreferenceKeys.KEYWORD_FILTERS, new HashSet<>(items)); adapter.notifyDataSetChanged(); final String message = context.getString(R.string.removed_keywords, s); Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); }); } public Button getDeleteButton(){ return deleteButton; } public TextView getTextView(){ return item; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectInboxItemViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.content.res.Resources; import android.graphics.Typeface; import android.view.View; import androidx.annotation.NonNull; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; import androidx.recyclerview.widget.RecyclerView; import com.facebook.drawee.view.SimpleDraweeView; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectMessageInboxAdapter.OnItemClickListener; import awais.instagrabber.databinding.LayoutDmInboxItemBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.DMUtils; import awais.instagrabber.utils.TextUtils; public final class DirectInboxItemViewHolder extends RecyclerView.ViewHolder { // private static final String TAG = "DMInboxItemVH"; private final LayoutDmInboxItemBinding binding; private final OnItemClickListener onClickListener; private final List multipleProfilePics; private final int childSmallSize; private final int childTinySize; public DirectInboxItemViewHolder(@NonNull final LayoutDmInboxItemBinding binding, final OnItemClickListener onClickListener) { super(binding.getRoot()); this.binding = binding; this.onClickListener = onClickListener; multipleProfilePics = ImmutableList.of( binding.multiPic1, binding.multiPic2, binding.multiPic3 ); childSmallSize = itemView.getResources().getDimensionPixelSize(R.dimen.dm_inbox_avatar_size_small); childTinySize = itemView.getResources().getDimensionPixelSize(R.dimen.dm_inbox_avatar_size_tiny); } public void bind(final DirectThread thread) { if (thread == null) return; if (onClickListener != null) { itemView.setOnClickListener((v) -> onClickListener.onItemClick(thread)); } setProfilePics(thread); setTitle(thread); final List items = thread.getItems(); if (items == null || items.isEmpty()) return; final DirectItem item = thread.getFirstDirectItem(); if (item == null) return; setDateTime(item); setSubtitle(thread); setReadState(thread); } private void setProfilePics(@NonNull final DirectThread thread) { final List users = thread.getUsers(); if (users.size() > 1) { binding.profilePic.setVisibility(View.GONE); binding.multiPicContainer.setVisibility(View.VISIBLE); for (int i = 0; i < Math.min(3, users.size()); ++i) { final User user = users.get(i); final SimpleDraweeView view = multipleProfilePics.get(i); view.setVisibility(user == null ? View.GONE : View.VISIBLE); if (user == null) return; final String profilePicUrl = user.getProfilePicUrl(); view.setImageURI(profilePicUrl); setChildSize(view, users.size()); if (i == 1) { updateConstraints(view, users.size()); } view.requestLayout(); } return; } binding.profilePic.setVisibility(View.VISIBLE); binding.multiPicContainer.setVisibility(View.GONE); final String profilePicUrl = users.size() == 1 ? users.get(0).getProfilePicUrl() : null; if (profilePicUrl == null) { binding.profilePic.setController(null); return; } binding.profilePic.setImageURI(profilePicUrl); } private void updateConstraints(final SimpleDraweeView view, final int length) { final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) view.getLayoutParams(); if (length >= 2) { layoutParams.endToEnd = ConstraintSet.PARENT_ID; layoutParams.bottomToBottom = ConstraintSet.PARENT_ID; } if (length == 3) { layoutParams.startToStart = ConstraintSet.PARENT_ID; layoutParams.topToTop = ConstraintSet.PARENT_ID; } } private void setChildSize(final SimpleDraweeView view, final int length) { final int size = length == 3 ? childTinySize : childSmallSize; final ConstraintLayout.LayoutParams viewLayoutParams = new ConstraintLayout.LayoutParams(size, size); view.setLayoutParams(viewLayoutParams); } private void setTitle(@NonNull final DirectThread thread) { final String threadTitle = thread.getThreadTitle(); binding.threadTitle.setText(threadTitle); } private void setSubtitle(@NonNull final DirectThread thread) { final Resources resources = itemView.getResources(); final long viewerId = thread.getViewerId(); // final DirectThreadDirectStory directStory = thread.getDirectStory(); // if (directStory != null && !directStory.getItems().isEmpty()) { // final DirectItem item = directStory.getItems().get(0); // final MediaItemType mediaType = item.getVisualMedia().getMedia().getMediaType(); // final String username = DMUtils.getUsername(thread.getUsers(), item.getUserId(), viewerId, resources); // final String subtitle = DMUtils.getMediaSpecificSubtitle(username, resources, mediaType); // binding.subtitle.setText(subtitle); // return; // } final DirectItem item = thread.getFirstDirectItem(); if (item == null) return; final String subtitle = DMUtils.getMessageString(thread, resources, viewerId, item); binding.subtitle.setText(subtitle != null ? subtitle : ""); } private void setDateTime(@NonNull final DirectItem item) { final long timestamp = item.getTimestamp() / 1000; final String dateTimeString = TextUtils.getRelativeDateTimeString(timestamp); binding.tvDate.setText(dateTimeString); } private void setReadState(@NonNull final DirectThread thread) { final boolean read = DMUtils.isRead(thread); binding.unread.setVisibility(read ? View.GONE : View.VISIBLE); binding.threadTitle.setTypeface(null, read ? Typeface.NORMAL : Typeface.BOLD); binding.subtitle.setTypeface(null, read ? Typeface.NORMAL : Typeface.BOLD); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemActionLogViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.graphics.Typeface; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmActionLogBinding; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemActionLog; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.TextRange; import awais.instagrabber.utils.TextUtils; public class DirectItemActionLogViewHolder extends DirectItemViewHolder { private static final String TAG = DirectItemActionLogViewHolder.class.getSimpleName(); private final LayoutDmActionLogBinding binding; public DirectItemActionLogViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, final LayoutDmActionLogBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); binding.tvMessage.setMovementMethod(LinkMovementMethod.getInstance()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { final DirectItemActionLog actionLog = directItemModel.getActionLog(); final String text = actionLog.getDescription(); final SpannableStringBuilder sb = new SpannableStringBuilder(text); final List bold = actionLog.getBold(); if (bold != null && !bold.isEmpty()) { for (final TextRange textRange : bold) { final StyleSpan boldStyleSpan = new StyleSpan(Typeface.BOLD); sb.setSpan(boldStyleSpan, textRange.getStart(), textRange.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); } } final List textAttributes = actionLog.getTextAttributes(); if (textAttributes != null && !textAttributes.isEmpty()) { for (final TextRange textAttribute : textAttributes) { if (!TextUtils.isEmpty(textAttribute.getColor())) { final ForegroundColorSpan colorSpan = new ForegroundColorSpan(itemView.getResources().getColor(R.color.deep_orange_400)); sb.setSpan(colorSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); } if (!TextUtils.isEmpty(textAttribute.getIntent())) { final ClickableSpan clickableSpan = new ClickableSpan() { @Override public void onClick(@NonNull final View widget) { handleDeepLink(textAttribute.getIntent()); } }; sb.setSpan(clickableSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); } } } binding.tvMessage.setText(sb); } @Override protected boolean allowMessageDirectionGravity() { return false; } @Override protected boolean showUserDetailsInGroup() { return false; } @Override protected boolean showMessageInfo() { return false; } @Override protected boolean allowLongClick() { return false; } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemAnimatedMediaViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import com.facebook.drawee.backends.pipeline.Fresco; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.databinding.LayoutDmAnimatedMediaBinding; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.repositories.responses.AnimatedMediaFixedHeight; import awais.instagrabber.repositories.responses.AnimatedMediaImages; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemAnimatedMedia; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.Utils; public class DirectItemAnimatedMediaViewHolder extends DirectItemViewHolder { private final LayoutDmAnimatedMediaBinding binding; public DirectItemAnimatedMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmAnimatedMediaBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem item, final MessageDirection messageDirection) { final DirectItemAnimatedMedia animatedMediaModel = item.getAnimatedMedia(); final AnimatedMediaImages images = animatedMediaModel.getImages(); if (images == null) return; final AnimatedMediaFixedHeight fixedHeight = images.getFixedHeight(); if (fixedHeight == null) return; final String url = fixedHeight.getWebp(); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( fixedHeight.getHeight(), fixedHeight.getWidth(), mediaImageMaxHeight, mediaImageMaxWidth ); binding.ivAnimatedMessage.setVisibility(View.VISIBLE); final ViewGroup.LayoutParams layoutParams = binding.ivAnimatedMessage.getLayoutParams(); final int width = widthHeight.first; final int height = widthHeight.second; layoutParams.width = width; layoutParams.height = height; binding.ivAnimatedMessage.requestLayout(); binding.ivAnimatedMessage.setController(Fresco.newDraweeControllerBuilder() .setUri(url) .setAutoPlayAnimations(true) .build()); } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } @Override protected List getLongClickOptions() { return ImmutableList.of( new DirectItemContextMenu.MenuItem(R.id.detail, R.string.dms_inbox_giphy, item -> { Utils.openURL(itemView.getContext(), "https://giphy.com/gifs/" + item.getAnimatedMedia().getId()); return null; }) ); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemDefaultViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.content.Context; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmTextBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; public class DirectItemDefaultViewHolder extends DirectItemViewHolder { private final LayoutDmTextBinding binding; public DirectItemDefaultViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmTextBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { final Context context = itemView.getContext(); binding.tvMessage.setText(context.getText(R.string.dms_inbox_raven_message_unknown)); } @Override protected boolean showBackground() { return true; } @Override protected boolean allowLongClick() { return false; } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLikeViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmLikeBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; public class DirectItemLikeViewHolder extends DirectItemViewHolder { public DirectItemLikeViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmLikeBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) {} @Override protected boolean canForward() { return false; } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemLinkViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmLinkBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemLink; import awais.instagrabber.repositories.responses.directmessages.DirectItemLinkContext; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; public class DirectItemLinkViewHolder extends DirectItemViewHolder { private final LayoutDmLinkBinding binding; public DirectItemLinkViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, final LayoutDmLinkBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; final int width = windowWidth - margin - dmRadiusSmall; final ViewGroup.LayoutParams layoutParams = binding.preview.getLayoutParams(); layoutParams.width = width; binding.preview.requestLayout(); setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem item, final MessageDirection messageDirection) { final DirectItemLink link = item.getLink(); if (link == null) return; final DirectItemLinkContext linkContext = link.getLinkContext(); if (linkContext == null) return; final String linkImageUrl = linkContext.getLinkImageUrl(); if (TextUtils.isEmpty(linkImageUrl)) { binding.preview.setVisibility(View.GONE); } else { binding.preview.setVisibility(View.VISIBLE); binding.preview.setImageURI(linkImageUrl); } if (TextUtils.isEmpty(linkContext.getLinkTitle())) { binding.title.setVisibility(View.GONE); } else { binding.title.setVisibility(View.VISIBLE); binding.title.setText(linkContext.getLinkTitle()); } if (TextUtils.isEmpty(linkContext.getLinkSummary())) { binding.summary.setVisibility(View.GONE); } else { binding.summary.setVisibility(View.VISIBLE); binding.summary.setText(linkContext.getLinkSummary()); } if (TextUtils.isEmpty(linkContext.getLinkUrl())) { binding.url.setVisibility(View.GONE); } else { binding.url.setVisibility(View.VISIBLE); binding.url.setText(linkContext.getLinkUrl()); } binding.text.setText(link.getText()); setupListeners(linkContext); } private void setupListeners(final DirectItemLinkContext linkContext) { setupRamboTextListeners(binding.text); final View.OnClickListener onClickListener = v -> openURL(linkContext.getLinkUrl()); binding.preview.setOnClickListener(onClickListener); // binding.preview.setOnLongClickListener(v -> itemView.performLongClick()); binding.title.setOnClickListener(onClickListener); binding.summary.setOnClickListener(onClickListener); binding.url.setOnClickListener(onClickListener); } @Override protected boolean showBackground() { return true; } @Override protected List getLongClickOptions() { return ImmutableList.of( new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy, item -> { final DirectItemLink link = item.getLink(); if (link == null || TextUtils.isEmpty(link.getText())) return null; Utils.copyText(itemView.getContext(), link.getText()); return null; }) ); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaShareViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.RoundingParams; import com.google.common.collect.ImmutableList; import java.util.List; import java.util.Objects; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmMediaShareBinding; import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Caption; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemClip; import awais.instagrabber.repositories.responses.directmessages.DirectItemFelixShare; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.Utils; public class DirectItemMediaShareViewHolder extends DirectItemViewHolder { private static final String TAG = DirectItemMediaShareViewHolder.class.getSimpleName(); private final LayoutDmMediaShareBinding binding; private final RoundingParams incomingRoundingParams; private final RoundingParams outgoingRoundingParams; private DirectItemType itemType; private Caption caption; public DirectItemMediaShareViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmMediaShareBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; incomingRoundingParams = RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius); outgoingRoundingParams = RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem item, final MessageDirection messageDirection) { binding.topBg.setBackgroundResource(messageDirection == MessageDirection.INCOMING ? R.drawable.bg_media_share_top_incoming : R.drawable.bg_media_share_top_outgoing); Media media = getMedia(item); if (media == null) return; itemView.post(() -> { setupUser(media); setupCaption(media); }); final int index; final Media toDisplay; final MediaItemType mediaType = media.getType(); switch (mediaType) { case MEDIA_TYPE_SLIDER: toDisplay = media.getCarouselMedia().stream() .filter(m -> media.getCarouselShareChildMediaId() != null && media.getCarouselShareChildMediaId().equals(m.getId())) .findAny() .orElse(media.getCarouselMedia().get(0)); index = media.getCarouselMedia().indexOf(toDisplay); break; default: toDisplay = media; index = 0; } itemView.post(() -> { setupTypeIndicator(mediaType); setupPreview(toDisplay, messageDirection); }); itemView.setOnClickListener(v -> openMedia(media, index)); } private void setupTypeIndicator(final MediaItemType mediaType) { final boolean showTypeIcon = mediaType == MediaItemType.MEDIA_TYPE_VIDEO || mediaType == MediaItemType.MEDIA_TYPE_SLIDER; if (!showTypeIcon) { binding.typeIcon.setVisibility(View.GONE); } else { binding.typeIcon.setVisibility(View.VISIBLE); binding.typeIcon.setImageResource(mediaType == MediaItemType.MEDIA_TYPE_VIDEO ? R.drawable.ic_video_24 : R.drawable.ic_checkbox_multiple_blank_stroke); } } private void setupPreview(@NonNull final Media media, final MessageDirection messageDirection) { final String url = ResponseBodyUtils.getThumbUrl(media); if (Objects.equals(url, binding.mediaPreview.getTag())) { return; } final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING ? incomingRoundingParams : outgoingRoundingParams; binding.mediaPreview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) .setRoundingParams(roundingParams) .build()); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( media.getOriginalHeight(), media.getOriginalWidth(), mediaImageMaxHeight, mediaImageMaxWidth ); final ViewGroup.LayoutParams layoutParams = binding.mediaPreview.getLayoutParams(); layoutParams.width = widthHeight.first; layoutParams.height = widthHeight.second; binding.mediaPreview.requestLayout(); binding.mediaPreview.setTag(url); binding.mediaPreview.setImageURI(url); } private void setupCaption(@NonNull final Media media) { caption = media.getCaption(); if (caption != null) { binding.caption.setVisibility(View.VISIBLE); binding.caption.setText(caption.getText()); binding.caption.setEllipsize(TextUtils.TruncateAt.END); binding.caption.setMaxLines(2); } else { binding.caption.setVisibility(View.GONE); } } private void setupUser(@NonNull final Media media) { final User user = media.getUser(); if (user != null) { binding.username.setVisibility(View.VISIBLE); binding.profilePic.setVisibility(View.VISIBLE); binding.username.setText(user.getUsername()); binding.profilePic.setImageURI(user.getProfilePicUrl()); } else { binding.username.setVisibility(View.GONE); binding.profilePic.setVisibility(View.GONE); } } @Nullable private Media getMedia(@NonNull final DirectItem item) { Media media = null; itemType = item.getItemType(); if (itemType == DirectItemType.MEDIA_SHARE) { media = item.getMediaShare(); } else if (itemType == DirectItemType.CLIP) { final DirectItemClip clip = item.getClip(); if (clip == null) return null; media = clip.getClip(); } else if (itemType == DirectItemType.FELIX_SHARE) { final DirectItemFelixShare felixShare = item.getFelixShare(); if (felixShare == null) return null; media = felixShare.getVideo(); } return media; } @Override protected int getReactionsTranslationY() { return reactionTranslationYType2; } @Override public int getSwipeDirection() { if (itemType != null && (itemType == DirectItemType.CLIP || itemType == DirectItemType.FELIX_SHARE)) { return ItemTouchHelper.ACTION_STATE_IDLE; } return super.getSwipeDirection(); } @Override protected List getLongClickOptions() { final ImmutableList.Builder builder = ImmutableList.builder(); if (caption != null && !TextUtils.isEmpty(caption.getText())) { builder.add(new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy_caption, item -> { Utils.copyText(itemView.getContext(), caption.getText()); return null; })); } return builder.build(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemMediaViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.RoundingParams; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmMediaBinding; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.ResponseBodyUtils; public class DirectItemMediaViewHolder extends DirectItemViewHolder { private final LayoutDmMediaBinding binding; private final RoundingParams incomingRoundingParams; private final RoundingParams outgoingRoundingParams; public DirectItemMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmMediaBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; incomingRoundingParams = RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius); outgoingRoundingParams = RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING ? incomingRoundingParams : outgoingRoundingParams; binding.mediaPreview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) .setRoundingParams(roundingParams) .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) .build()); final Media media = directItemModel.getMedia(); itemView.setOnClickListener(v -> openMedia(media, -1)); final MediaItemType modelMediaType = media.getType(); binding.typeIcon.setVisibility(modelMediaType == MediaItemType.MEDIA_TYPE_VIDEO || modelMediaType == MediaItemType.MEDIA_TYPE_SLIDER ? View.VISIBLE : View.GONE); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( media.getOriginalHeight(), media.getOriginalWidth(), mediaImageMaxHeight, mediaImageMaxWidth ); final ViewGroup.LayoutParams layoutParams = binding.mediaPreview.getLayoutParams(); final int width = widthHeight.first; layoutParams.width = width; layoutParams.height = widthHeight.second; binding.mediaPreview.requestLayout(); binding.bgTime.getLayoutParams().width = width; binding.bgTime.requestLayout(); final String thumbUrl = ResponseBodyUtils.getThumbUrl(media); binding.mediaPreview.setImageURI(thumbUrl); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemPlaceholderViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmStoryShareBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; public class DirectItemPlaceholderViewHolder extends DirectItemViewHolder { private final LayoutDmStoryShareBinding binding; public DirectItemPlaceholderViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, final LayoutDmStoryShareBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { binding.shareInfo.setText(directItemModel.getPlaceholder().getTitle()); binding.text.setVisibility(View.VISIBLE); binding.text.setText(directItemModel.getPlaceholder().getMessage()); binding.ivMediaPreview.setVisibility(View.GONE); binding.typeIcon.setVisibility(View.GONE); } @Override protected boolean allowLongClick() { return false; } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemProfileViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.content.res.Resources; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.RoundingParams; import com.facebook.drawee.view.SimpleDraweeView; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmProfileBinding; import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; public class DirectItemProfileViewHolder extends DirectItemViewHolder { private final LayoutDmProfileBinding binding; private final ImmutableList previewViews; public DirectItemProfileViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmProfileBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); previewViews = ImmutableList.of( binding.preview1, binding.preview2, binding.preview3, binding.preview4, binding.preview5, binding.preview6 ); } @Override public void bindItem(@NonNull final DirectItem item, final MessageDirection messageDirection) { binding.getRoot().setBackgroundResource(messageDirection == MessageDirection.INCOMING ? R.drawable.bg_speech_bubble_incoming : R.drawable.bg_speech_bubble_outgoing); if (item.getItemType() == DirectItemType.PROFILE) { setProfile(item); } else if (item.getItemType() == DirectItemType.LOCATION) { setLocation(item); } else { return; } for (final SimpleDraweeView previewView : previewViews) { previewView.setImageURI((String) null); } final List previewMedias = item.getPreviewMedias(); if (previewMedias == null || previewMedias.size() <= 0) { binding.firstRow.setVisibility(View.GONE); binding.secondRow.setVisibility(View.GONE); return; } final Resources resources = itemView.getResources(); if (previewMedias.size() <= 3) { binding.firstRow.setVisibility(View.VISIBLE); binding.secondRow.setVisibility(View.GONE); binding.preview1.setHierarchy(new GenericDraweeHierarchyBuilder(resources) .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, 0, dmRadius)) .build()); binding.preview3.setHierarchy(new GenericDraweeHierarchyBuilder(resources) .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, dmRadius, 0)) .build()); } if (previewMedias.size() > 3) { binding.preview4.setHierarchy(new GenericDraweeHierarchyBuilder(resources) .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, 0, dmRadius)) .build()); binding.preview6.setHierarchy(new GenericDraweeHierarchyBuilder(resources) .setRoundingParams(RoundingParams.fromCornersRadii(0, 0, dmRadius, 0)) .build()); } for (int i = 0; i < previewMedias.size(); i++) { final Media previewMedia = previewMedias.get(i); if (previewMedia == null) continue; final String url = ResponseBodyUtils.getThumbUrl(previewMedia); if (url == null) continue; previewViews.get(i).setImageURI(url); } } private void setProfile(@NonNull final DirectItem item) { final User profile = item.getProfile(); if (profile == null) return; binding.profilePic.setImageURI(profile.getProfilePicUrl()); binding.username.setText(profile.getUsername()); final String fullName = profile.getFullName(); if (!TextUtils.isEmpty(fullName)) { binding.fullName.setVisibility(View.VISIBLE); binding.fullName.setText(fullName); } else { binding.fullName.setVisibility(View.GONE); } binding.isVerified.setVisibility(profile.isVerified() ? View.VISIBLE : View.GONE); itemView.setOnClickListener(v -> openProfile(profile.getUsername())); } private void setLocation(@NonNull final DirectItem item) { final Location location = item.getLocation(); if (location == null) return; binding.profilePic.setVisibility(View.GONE); binding.username.setText(location.getName()); final String address = location.getAddress(); if (!TextUtils.isEmpty(address)) { binding.fullName.setText(address); binding.fullName.setVisibility(View.VISIBLE); } else { binding.fullName.setVisibility(View.GONE); } binding.isVerified.setVisibility(View.GONE); itemView.setOnClickListener(v -> openLocation(location.getPk())); } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemRavenMediaViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.RoundingParams; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmRavenMediaBinding; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.models.enums.RavenMediaViewMode; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemVisualMedia; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; public class DirectItemRavenMediaViewHolder extends DirectItemViewHolder { private final LayoutDmRavenMediaBinding binding; private final int maxWidth; public DirectItemRavenMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmRavenMediaBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; maxWidth = windowWidth - margin - dmRadiusSmall; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { final DirectItemVisualMedia visualMedia = directItemModel.getVisualMedia(); final Media media = visualMedia.getMedia(); if (media == null) return; setExpiryInfo(visualMedia); setPreview(visualMedia, messageDirection); final boolean expired = TextUtils.isEmpty(media.getId()); if (expired) return; itemView.setOnClickListener(v -> openMedia(media, -1)); /*final boolean isExpired = visualMedia == null || (mediaModel = visualMedia.getMedia()) == null || TextUtils.isEmpty(mediaModel.getThumbUrl()) && mediaModel.getPk() < 1; RavenExpiringMediaActionSummary mediaActionSummary = null; if (visualMedia != null) { mediaActionSummary = visualMedia.getExpiringMediaActionSummary(); } binding.mediaExpiredIcon.setVisibility(isExpired ? View.VISIBLE : View.GONE); int textRes = R.string.dms_inbox_raven_media_unknown; if (isExpired) textRes = R.string.dms_inbox_raven_media_expired; if (!isExpired) { if (mediaActionSummary != null) { final ActionType expiringMediaType = mediaActionSummary.getType(); if (expiringMediaType == ActionType.DELIVERED) textRes = R.string.dms_inbox_raven_media_delivered; else if (expiringMediaType == ActionType.SENT) textRes = R.string.dms_inbox_raven_media_sent; else if (expiringMediaType == ActionType.OPENED) textRes = R.string.dms_inbox_raven_media_opened; else if (expiringMediaType == ActionType.REPLAYED) textRes = R.string.dms_inbox_raven_media_replayed; else if (expiringMediaType == ActionType.SENDING) textRes = R.string.dms_inbox_raven_media_sending; else if (expiringMediaType == ActionType.BLOCKED) textRes = R.string.dms_inbox_raven_media_blocked; else if (expiringMediaType == ActionType.SUGGESTED) textRes = R.string.dms_inbox_raven_media_suggested; else if (expiringMediaType == ActionType.SCREENSHOT) textRes = R.string.dms_inbox_raven_media_screenshot; else if (expiringMediaType == ActionType.CANNOT_DELIVER) textRes = R.string.dms_inbox_raven_media_cant_deliver; } final RavenMediaViewMode ravenMediaViewMode = visualMedia.getViewType(); if (ravenMediaViewMode == RavenMediaViewMode.PERMANENT || ravenMediaViewMode == RavenMediaViewMode.REPLAYABLE) { final MediaItemType mediaType = mediaModel.getMediaType(); textRes = -1; binding.typeIcon.setVisibility(mediaType == MediaItemType.MEDIA_TYPE_VIDEO || mediaType == MediaItemType.MEDIA_TYPE_SLIDER ? View.VISIBLE : View.GONE); final Pair widthHeight = NumberUtils.calculateWidthHeight( mediaModel.getHeight(), mediaModel.getWidth(), maxHeight, maxWidth ); final ViewGroup.LayoutParams layoutParams = binding.ivMediaPreview.getLayoutParams(); layoutParams.width = widthHeight.first != null ? widthHeight.first : 0; layoutParams.height = widthHeight.second != null ? widthHeight.second : 0; binding.ivMediaPreview.requestLayout(); binding.ivMediaPreview.setImageURI(mediaModel.getThumbUrl()); } } if (textRes != -1) { binding.tvMessage.setText(context.getText(textRes)); binding.tvMessage.setVisibility(View.VISIBLE); }*/ } private void setExpiryInfo(final DirectItemVisualMedia visualMedia) { final Media media = visualMedia.getMedia(); final RavenMediaViewMode viewMode = visualMedia.getViewMode(); if (viewMode != RavenMediaViewMode.PERMANENT) { final MediaItemType mediaType = media.getType(); final boolean expired = TextUtils.isEmpty(media.getId()); final int info; switch (mediaType) { case MEDIA_TYPE_IMAGE: if (expired) { info = R.string.raven_image_expired; break; } info = R.string.raven_image_info; break; case MEDIA_TYPE_VIDEO: if (expired) { info = R.string.raven_video_expired; break; } info = R.string.raven_video_info; break; default: if (expired) { info = R.string.raven_msg_expired; break; } info = R.string.raven_msg_info; break; } binding.expiryInfo.setVisibility(View.VISIBLE); binding.expiryInfo.setText(info); return; } binding.expiryInfo.setVisibility(View.GONE); } private void setPreview(final DirectItemVisualMedia visualMedia, final MessageDirection messageDirection) { final Media media = visualMedia.getMedia(); final boolean expired = TextUtils.isEmpty(media.getId()); if (expired) { binding.preview.setVisibility(View.GONE); binding.typeIcon.setVisibility(View.GONE); return; } final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING ? RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius) : RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); binding.preview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) .setRoundingParams(roundingParams) .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) .build()); final MediaItemType modelMediaType = media.getType(); binding.typeIcon.setVisibility(modelMediaType == MediaItemType.MEDIA_TYPE_VIDEO || modelMediaType == MediaItemType.MEDIA_TYPE_SLIDER ? View.VISIBLE : View.GONE); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( media.getOriginalHeight(), media.getOriginalWidth(), mediaImageMaxHeight, maxWidth ); final ViewGroup.LayoutParams layoutParams = binding.preview.getLayoutParams(); layoutParams.width = widthHeight.first; layoutParams.height = widthHeight.second; binding.preview.requestLayout(); final String thumbUrl = ResponseBodyUtils.getThumbUrl(media); binding.preview.setImageURI(thumbUrl); } @Override protected boolean allowLongClick() { return false; // disabling until confirmed } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemReelShareViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.Gravity; import android.view.View; import androidx.annotation.NonNull; import androidx.constraintlayout.widget.ConstraintLayout; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.RoundingParams; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmReelShareBinding; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemReelShare; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; public class DirectItemReelShareViewHolder extends DirectItemViewHolder { private final LayoutDmReelShareBinding binding; private String type; public DirectItemReelShareViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmReelShareBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem item, final MessageDirection messageDirection) { final DirectItemReelShare reelShare = item.getReelShare(); type = reelShare.getType(); if (type == null) return; final boolean isSelf = isSelf(item); final Media media = reelShare.getMedia(); if (media == null) return; final User user = media.getUser(); if (user == null) return; final boolean expired = media.getType() == null; if (expired) { binding.preview.setVisibility(View.GONE); binding.typeIcon.setVisibility(View.GONE); binding.quoteLine.setVisibility(View.GONE); binding.reaction.setVisibility(View.GONE); } else { binding.preview.setVisibility(View.VISIBLE); binding.typeIcon.setVisibility(View.VISIBLE); binding.quoteLine.setVisibility(View.VISIBLE); binding.reaction.setVisibility(View.VISIBLE); } setGravity(messageDirection, expired); if (type.equals("reply")) { setReply(messageDirection, reelShare, isSelf); } if (type.equals("reaction")) { setReaction(messageDirection, reelShare, isSelf, expired); } if (type.equals("mention")) { setMention(isSelf); } if (!expired) { setPreview(media); itemView.setOnClickListener(v -> openMedia(media, -1)); } } private void setGravity(final MessageDirection messageDirection, final boolean expired) { final boolean isIncoming = messageDirection == MessageDirection.INCOMING; binding.shareInfo.setGravity(isIncoming ? Gravity.START : Gravity.END); if (!expired) { binding.quoteLine.setVisibility(isIncoming ? View.VISIBLE : View.GONE); binding.quoteLineEnd.setVisibility(isIncoming ? View.GONE : View.VISIBLE); } final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) binding.quoteLine.getLayoutParams(); layoutParams.horizontalBias = isIncoming ? 0 : 1; final ConstraintLayout.LayoutParams messageLayoutParams = (ConstraintLayout.LayoutParams) binding.message.getLayoutParams(); messageLayoutParams.startToStart = isIncoming ? ConstraintLayout.LayoutParams.PARENT_ID : ConstraintLayout.LayoutParams.UNSET; messageLayoutParams.endToEnd = isIncoming ? ConstraintLayout.LayoutParams.UNSET : ConstraintLayout.LayoutParams.PARENT_ID; messageLayoutParams.setMarginStart(isIncoming ? messageInfoPaddingSmall : 0); messageLayoutParams.setMarginEnd(isIncoming ? 0 : messageInfoPaddingSmall); final ConstraintLayout.LayoutParams reactionLayoutParams = (ConstraintLayout.LayoutParams) binding.reaction.getLayoutParams(); final int previewId = binding.preview.getId(); if (isIncoming) { reactionLayoutParams.startToEnd = previewId; reactionLayoutParams.endToEnd = previewId; reactionLayoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET; reactionLayoutParams.endToStart = ConstraintLayout.LayoutParams.UNSET; } else { reactionLayoutParams.startToStart = previewId; reactionLayoutParams.endToStart = previewId; reactionLayoutParams.startToEnd = ConstraintLayout.LayoutParams.UNSET; reactionLayoutParams.endToEnd = ConstraintLayout.LayoutParams.UNSET; } } private void setReply(final MessageDirection messageDirection, final DirectItemReelShare reelShare, final boolean isSelf) { final int info = isSelf ? R.string.replied_story_outgoing : R.string.replied_story_incoming; binding.shareInfo.setText(info); binding.reaction.setVisibility(View.GONE); final String text = reelShare.getText(); if (TextUtils.isEmpty(text)) { binding.message.setVisibility(View.GONE); return; } setMessage(messageDirection, text); } private void setReaction(final MessageDirection messageDirection, final DirectItemReelShare reelShare, final boolean isSelf, final boolean expired) { final int info = isSelf ? R.string.reacted_story_outgoing : R.string.reacted_story_incoming; binding.shareInfo.setText(info); binding.message.setVisibility(View.GONE); final String text = reelShare.getText(); if (TextUtils.isEmpty(text)) { binding.reaction.setVisibility(View.GONE); return; } if (expired) { setMessage(messageDirection, text); return; } binding.reaction.setVisibility(View.VISIBLE); binding.reaction.setText(text); } private void setMention(final boolean isSelf) { final int info = isSelf ? R.string.mentioned_story_outgoing : R.string.mentioned_story_incoming; binding.shareInfo.setText(info); binding.message.setVisibility(View.GONE); binding.reaction.setVisibility(View.GONE); } private void setMessage(final MessageDirection messageDirection, final String text) { binding.message.setVisibility(View.VISIBLE); binding.message.setBackgroundResource(messageDirection == MessageDirection.INCOMING ? R.drawable.bg_speech_bubble_incoming : R.drawable.bg_speech_bubble_outgoing); binding.message.setText(text); } private void setPreview(final Media media) { final MediaItemType mediaType = media.getType(); if (mediaType == null) return; binding.typeIcon.setVisibility(mediaType == MediaItemType.MEDIA_TYPE_VIDEO || mediaType == MediaItemType.MEDIA_TYPE_SLIDER ? View.VISIBLE : View.GONE); final RoundingParams roundingParams = RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadiusSmall, dmRadiusSmall, dmRadiusSmall); binding.preview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) .setRoundingParams(roundingParams) .build()); final String thumbUrl = ResponseBodyUtils.getThumbUrl(media); binding.preview.setImageURI(thumbUrl); } @Override protected boolean canForward() { return false; } @Override protected List getLongClickOptions() { final ImmutableList.Builder builder = ImmutableList.builder(); if (type != null && type.equals("reply")) { builder.add(new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy_reply, item -> { final DirectItemReelShare reelShare = item.getReelShare(); if (reelShare == null) return null; final String text = reelShare.getText(); if (TextUtils.isEmpty(text)) return null; Utils.copyText(itemView.getContext(), text); return null; })); } return builder.build(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemStoryShareViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.content.res.Resources; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.RoundingParams; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmStoryShareBinding; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; public class DirectItemStoryShareViewHolder extends DirectItemViewHolder { private final LayoutDmStoryShareBinding binding; public DirectItemStoryShareViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmStoryShareBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem item, final MessageDirection messageDirection) { final Resources resources = itemView.getResources(); int format = R.string.story_share; final String reelType = item.getStoryShare().getReelType(); if (reelType == null || item.getStoryShare().getMedia() == null) { setExpiredStoryInfo(item); return; } if (reelType.equals("highlight_reel")) { format = R.string.story_share_highlight; } final User user = item.getStoryShare().getMedia().getUser(); final String info = resources.getString(format, user != null ? user.getUsername() : ""); binding.shareInfo.setText(info); binding.text.setVisibility(View.GONE); binding.ivMediaPreview.setController(null); final DirectItemStoryShare storyShare = item.getStoryShare(); if (storyShare == null) return; setText(storyShare); final Media media = storyShare.getMedia(); setupPreview(messageDirection, media); itemView.setOnClickListener(v -> openStory(storyShare)); } private void setupPreview(final MessageDirection messageDirection, final Media storyShareMedia) { final MediaItemType mediaType = storyShareMedia.getType(); binding.typeIcon.setVisibility(mediaType == MediaItemType.MEDIA_TYPE_VIDEO ? View.VISIBLE : View.GONE); final RoundingParams roundingParams = messageDirection == MessageDirection.INCOMING ? RoundingParams.fromCornersRadii(dmRadiusSmall, dmRadius, dmRadius, dmRadius) : RoundingParams.fromCornersRadii(dmRadius, dmRadiusSmall, dmRadius, dmRadius); binding.ivMediaPreview.setHierarchy(new GenericDraweeHierarchyBuilder(itemView.getResources()) .setRoundingParams(roundingParams) .setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP) .build()); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( storyShareMedia.getOriginalHeight(), storyShareMedia.getOriginalWidth(), mediaImageMaxHeight, mediaImageMaxWidth ); final ViewGroup.LayoutParams layoutParams = binding.ivMediaPreview.getLayoutParams(); layoutParams.width = widthHeight.first; layoutParams.height = widthHeight.second; binding.ivMediaPreview.requestLayout(); final String thumbUrl = ResponseBodyUtils.getThumbUrl(storyShareMedia); binding.ivMediaPreview.setImageURI(thumbUrl); } private void setText(final DirectItemStoryShare storyShare) { final String text = storyShare.getText(); if (!TextUtils.isEmpty(text)) { binding.text.setText(text); binding.text.setVisibility(View.VISIBLE); return; } binding.text.setVisibility(View.GONE); } private void setExpiredStoryInfo(final DirectItem item) { binding.shareInfo.setText(item.getStoryShare().getTitle()); binding.text.setVisibility(View.VISIBLE); binding.text.setText(item.getStoryShare().getMessage()); binding.ivMediaPreview.setVisibility(View.GONE); binding.typeIcon.setVisibility(View.GONE); } @Override protected boolean canForward() { return false; } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemTextViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import androidx.annotation.NonNull; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmTextBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; public class DirectItemTextViewHolder extends DirectItemViewHolder { private final LayoutDmTextBinding binding; public DirectItemTextViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmTextBinding binding, final User currentUser, final DirectThread thread, @NonNull final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { final String text = directItemModel.getText(); if (text == null) return; binding.tvMessage.setText(text); setupRamboTextListeners(binding.tvMessage); } @Override protected boolean showBackground() { return true; } @Override protected List getLongClickOptions() { return ImmutableList.of( new DirectItemContextMenu.MenuItem(R.id.copy, R.string.copy, item -> { if (TextUtils.isEmpty(item.getText())) return null; Utils.copyText(itemView.getContext(), item.getText()); return null; }) ); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVideoCallEventViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.style.ClickableSpan; import android.text.style.ForegroundColorSpan; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmActionLogBinding; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemVideoCallEvent; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.TextRange; import awais.instagrabber.utils.TextUtils; public class DirectItemVideoCallEventViewHolder extends DirectItemViewHolder { private final LayoutDmActionLogBinding binding; public DirectItemVideoCallEventViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, final LayoutDmActionLogBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { final DirectItemVideoCallEvent videoCallEvent = directItemModel.getVideoCallEvent(); final String text = videoCallEvent.getDescription(); final SpannableStringBuilder sb = new SpannableStringBuilder(text); final List textAttributes = videoCallEvent.getTextAttributes(); if (textAttributes != null && !textAttributes.isEmpty()) { for (final TextRange textAttribute : textAttributes) { if (!TextUtils.isEmpty(textAttribute.getColor())) { final ForegroundColorSpan colorSpan = new ForegroundColorSpan(itemView.getResources().getColor(R.color.deep_orange_400)); sb.setSpan(colorSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); } if (!TextUtils.isEmpty(textAttribute.getIntent())) { final ClickableSpan clickableSpan = new ClickableSpan() { @Override public void onClick(@NonNull final View widget) { } }; sb.setSpan(clickableSpan, textAttribute.getStart(), textAttribute.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); } } } binding.tvMessage.setMaxLines(1); binding.tvMessage.setText(sb); } @Override protected boolean allowMessageDirectionGravity() { return false; } @Override protected boolean showUserDetailsInGroup() { return false; } @Override protected boolean showMessageInfo() { return false; } @Override protected boolean allowLongClick() { return false; } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.annotation.SuppressLint; import android.content.res.ColorStateList; import android.content.res.Resources; import android.graphics.Point; import android.graphics.drawable.Drawable; import android.view.Gravity; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewPropertyAnimator; import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.FrameLayout; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.widget.ImageViewCompat; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import androidx.transition.TransitionManager; import com.google.android.material.transition.MaterialFade; import com.google.common.collect.ImmutableList; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.LinkedList; import java.util.List; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemInternalLongClickListener; import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.customviews.DirectItemFrameLayout; import awais.instagrabber.customviews.RamboTextViewV2; import awais.instagrabber.customviews.helpers.SwipeAndRestoreItemTouchHelperCallback.SwipeableViewHolder; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; import awais.instagrabber.repositories.responses.directmessages.DirectItemReactions; import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.DMUtils; import awais.instagrabber.utils.DeepLinkParser; import awais.instagrabber.utils.ResponseBodyUtils; public abstract class DirectItemViewHolder extends RecyclerView.ViewHolder implements SwipeableViewHolder { private static final String TAG = DirectItemViewHolder.class.getSimpleName(); // private static final List THREAD_CHANGING_OPTIONS = ImmutableList.of(R.id.unsend); private final LayoutDmBaseBinding binding; private final User currentUser; private final DirectThread thread; private final int groupMessageWidth; private final List userIds; private final DirectItemCallback callback; private final int reactionAdjustMargin; private final AccelerateDecelerateInterpolator accelerateDecelerateInterpolator = new AccelerateDecelerateInterpolator(); protected final int margin; protected final int dmRadius; protected final int dmRadiusSmall; protected final int messageInfoPaddingSmall; protected final int mediaImageMaxHeight; protected final int windowWidth; protected final int mediaImageMaxWidth; protected final int reactionTranslationYType1; protected final int reactionTranslationYType2; private boolean selected = false; private DirectItemInternalLongClickListener longClickListener; private DirectItem item; private ViewPropertyAnimator shrinkGrowAnimator; private MessageDirection messageDirection; // private View.OnLayoutChangeListener layoutChangeListener; public DirectItemViewHolder(@NonNull final LayoutDmBaseBinding binding, @NonNull final User currentUser, @NonNull final DirectThread thread, @NonNull final DirectItemCallback callback) { super(binding.getRoot()); this.binding = binding; this.currentUser = currentUser; this.thread = thread; this.callback = callback; userIds = thread.getUsers() .stream() .map(User::getPk) .collect(Collectors.toList()); binding.ivProfilePic.setVisibility(thread.isGroup() ? View.VISIBLE : View.GONE); binding.ivProfilePic.setOnClickListener(null); final Resources resources = itemView.getResources(); margin = resources.getDimensionPixelSize(R.dimen.dm_message_item_margin); final int avatarSize = resources.getDimensionPixelSize(R.dimen.dm_message_item_avatar_size); dmRadius = resources.getDimensionPixelSize(R.dimen.dm_message_card_radius); dmRadiusSmall = resources.getDimensionPixelSize(R.dimen.dm_message_card_radius_small); messageInfoPaddingSmall = resources.getDimensionPixelSize(R.dimen.dm_message_info_padding_small); windowWidth = resources.getDisplayMetrics().widthPixels; mediaImageMaxHeight = resources.getDimensionPixelSize(R.dimen.dm_media_img_max_height); reactionAdjustMargin = resources.getDimensionPixelSize(R.dimen.dm_reaction_adjust_margin); final int groupWidthCorrection = avatarSize + messageInfoPaddingSmall * 3; mediaImageMaxWidth = windowWidth - margin - (thread.isGroup() ? groupWidthCorrection : messageInfoPaddingSmall * 2); // messageInfoPaddingSmall is used cuz it's also 4dp, 1 avatar margin + 2 paddings = 3 groupMessageWidth = windowWidth - margin - groupWidthCorrection; reactionTranslationYType1 = resources.getDimensionPixelSize(R.dimen.dm_reaction_translation_y_type_1); reactionTranslationYType2 = resources.getDimensionPixelSize(R.dimen.dm_reaction_translation_y_type_2); } public void bind(final int position, final DirectItem item) { if (item == null) return; this.item = item; messageDirection = isSelf(item) ? MessageDirection.OUTGOING : MessageDirection.INCOMING; // Asynchronous binding causes some weird behaviour // itemView.post(() -> bindBase(item, messageDirection, position)); // itemView.post(() -> bindItem(item, messageDirection)); // itemView.post(() -> setupLongClickListener(position, messageDirection)); bindBase(item, messageDirection, position); bindItem(item, messageDirection); setupLongClickListener(position, messageDirection); } private void bindBase(@NonNull final DirectItem item, final MessageDirection messageDirection, final int position) { final FrameLayout.LayoutParams containerLayoutParams = (FrameLayout.LayoutParams) binding.container.getLayoutParams(); final DirectItemType itemType = item.getItemType() == null ? DirectItemType.UNKNOWN : item.getItemType(); setMessageDirectionGravity(messageDirection, containerLayoutParams); setGroupUserDetails(item, messageDirection); setBackground(messageDirection); setMessageInfo(item, messageDirection); switch (itemType) { case REEL_SHARE: case STORY_SHARE: // i think they could have texts? // containerLayoutParams.setMarginStart(0); // containerLayoutParams.setMarginEnd(0); case TEXT: case LINK: case UNKNOWN: binding.messageInfo.setPadding(0, 0, dmRadius, dmRadiusSmall); break; default: if (showMessageInfo()) { binding.messageInfo.setPadding(0, 0, messageInfoPaddingSmall, dmRadiusSmall); } } setupReply(item, messageDirection); setReactions(item, position); if (item.getRepliedToMessage() == null && item.getShowForwardAttribution()) { setForwardInfo(messageDirection); } } private void setBackground(final MessageDirection messageDirection) { if (showBackground()) { binding.background.setBackgroundResource(messageDirection == MessageDirection.INCOMING ? R.drawable.bg_speech_bubble_incoming : R.drawable.bg_speech_bubble_outgoing); return; } binding.background.setBackgroundResource(0); } private void setGroupUserDetails(final DirectItem item, final MessageDirection messageDirection) { if (showUserDetailsInGroup()) { binding.ivProfilePic.setVisibility(messageDirection == MessageDirection.INCOMING && thread.isGroup() ? View.VISIBLE : View.GONE); binding.tvUsername.setVisibility(messageDirection == MessageDirection.INCOMING && thread.isGroup() ? View.VISIBLE : View.GONE); if (messageDirection == MessageDirection.INCOMING && thread.isGroup()) { final List allUsers = new LinkedList(thread.getUsers()); allUsers.addAll(thread.getLeftUsers()); final User user = getUser(item.getUserId(), allUsers); if (user != null) { binding.tvUsername.setText(user.getUsername()); binding.ivProfilePic.setImageURI(user.getProfilePicUrl()); } ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) binding.chatMessageLayout.getLayoutParams(); layoutParams.matchConstraintMaxWidth = groupMessageWidth; binding.chatMessageLayout.setLayoutParams(layoutParams); } return; } binding.ivProfilePic.setVisibility(View.GONE); binding.tvUsername.setVisibility(View.GONE); } private void setMessageDirectionGravity(final MessageDirection messageDirection, final FrameLayout.LayoutParams containerLayoutParams) { if (allowMessageDirectionGravity()) { containerLayoutParams.setMarginStart(messageDirection == MessageDirection.OUTGOING ? margin : 0); containerLayoutParams.setMarginEnd(messageDirection == MessageDirection.INCOMING ? margin : 0); containerLayoutParams.gravity = messageDirection == MessageDirection.INCOMING ? Gravity.START : Gravity.END; return; } containerLayoutParams.gravity = Gravity.CENTER; } private void setMessageInfo(@NonNull final DirectItem item, final MessageDirection messageDirection) { if (showMessageInfo()) { binding.messageInfo.setVisibility(View.VISIBLE); binding.deliveryStatus.setVisibility(messageDirection == MessageDirection.OUTGOING ? View.VISIBLE : View.GONE); if (item.getDate() != null) { final DateTimeFormatter dateFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT); binding.messageTime.setText(dateFormatter.format(item.getDate())); } if (messageDirection == MessageDirection.OUTGOING) { if (item.isPending()) { binding.deliveryStatus.setImageResource(R.drawable.ic_check_24); } else { final boolean read = DMUtils.isRead(item, thread.getLastSeenAt(), userIds ); binding.deliveryStatus.setImageResource(R.drawable.ic_check_all_24); ImageViewCompat.setImageTintList( binding.deliveryStatus, ColorStateList.valueOf(itemView.getResources().getColor(read ? R.color.blue_500 : R.color.grey_500)) ); } } return; } binding.messageInfo.setVisibility(View.GONE); } private void setupReply(final DirectItem item, final MessageDirection messageDirection) { if (item.getRepliedToMessage() != null) { final List allUsers = new LinkedList(thread.getUsers()); allUsers.addAll(thread.getLeftUsers()); setReply(item, messageDirection, allUsers); } else { binding.quoteLine.setVisibility(View.GONE); binding.replyContainer.setVisibility(View.GONE); binding.replyInfo.setVisibility(View.GONE); } } private void setReply(final DirectItem item, final MessageDirection messageDirection, final List users) { final DirectItem replied = item.getRepliedToMessage(); final DirectItemType itemType = replied.getItemType(); final Resources resources = itemView.getResources(); String text = null; String url = null; switch (itemType) { case TEXT: text = replied.getText(); break; case LINK: text = replied.getLink().getText(); break; case PLACEHOLDER: text = replied.getPlaceholder().getMessage(); break; case MEDIA: url = ResponseBodyUtils.getThumbUrl(replied.getMedia()); break; case RAVEN_MEDIA: url = ResponseBodyUtils.getThumbUrl(replied.getVisualMedia().getMedia()); break; case VOICE_MEDIA: text = resources.getString(R.string.voice_message); break; case MEDIA_SHARE: Media mediaShare = replied.getMediaShare(); if (mediaShare.getType() == MediaItemType.MEDIA_TYPE_SLIDER) { mediaShare = mediaShare.getCarouselMedia().get(0); } url = ResponseBodyUtils.getThumbUrl(mediaShare); break; case REEL_SHARE: text = replied.getReelShare().getText(); break; // Below types cannot be replied to // case LIKE: // text = "❤️"; // break; // case PROFILE: // text = "@" + replied.getProfile().getUsername(); // break; // case CLIP: // url = ResponseBodyUtils.getThumbUrl(replied.getClip().getClip().getImageVersions2()); // break; // case FELIX_SHARE: // url = ResponseBodyUtils.getThumbUrl(replied.getFelixShare().getVideo().getImageVersions2()); // break; // case STORY_SHARE: // final DirectItemMedia media = replied.getStoryShare().getMedia(); // if (media == null) break; // url = ResponseBodyUtils.getThumbUrl(media.getImageVersions2()); // break; // case LOCATION } if (text == null && url == null) { binding.quoteLine.setVisibility(View.GONE); binding.replyContainer.setVisibility(View.GONE); binding.replyInfo.setVisibility(View.GONE); return; } setReplyGravity(messageDirection); final String info = setReplyInfo(item, replied, users, resources); binding.replyInfo.setVisibility(View.VISIBLE); binding.replyInfo.setText(info); binding.quoteLine.setVisibility(View.VISIBLE); binding.replyContainer.setVisibility(View.VISIBLE); if (url != null) { binding.replyText.setVisibility(View.GONE); binding.replyImage.setVisibility(View.VISIBLE); binding.replyImage.setImageURI(url); return; } binding.replyImage.setVisibility(View.GONE); final Drawable background = binding.replyText.getBackground().mutate(); background.setTint(replied.getUserId() != currentUser.getPk() ? resources.getColor(R.color.grey_600) : resources.getColor(R.color.deep_purple_400)); binding.replyText.setBackgroundDrawable(background); binding.replyText.setVisibility(View.VISIBLE); binding.replyText.setText(text); } private String setReplyInfo(final DirectItem item, final DirectItem replied, final List users, final Resources resources) { final long repliedToUserId = replied.getUserId(); if (repliedToUserId == item.getUserId() && item.getUserId() == currentUser.getPk()) { // User replied to own message return resources.getString(R.string.replied_to_yourself); } if (repliedToUserId == item.getUserId()) { // opposite user replied to their own message return resources.getString(R.string.replied_to_themself); } final User user = getUser(repliedToUserId, users); final String repliedToUsername = user != null ? user.getUsername() : ""; if (item.getUserId() == currentUser.getPk()) { return thread.isGroup() ? resources.getString(R.string.replied_you_group, repliedToUsername) : resources.getString(R.string.replied_you); } if (repliedToUserId == currentUser.getPk()) { return resources.getString(R.string.replied_to_you); } return resources.getString(R.string.replied_group, repliedToUsername); } private void setForwardInfo(final MessageDirection direction) { binding.replyInfo.setVisibility(View.VISIBLE); binding.replyInfo.setText(direction == MessageDirection.OUTGOING ? R.string.forward_outgoing : R.string.forward_incoming); } private void setReplyGravity(final MessageDirection messageDirection) { final boolean isIncoming = messageDirection == MessageDirection.INCOMING; final ConstraintLayout.LayoutParams quoteLineLayoutParams = (ConstraintLayout.LayoutParams) binding.quoteLine.getLayoutParams(); final ConstraintLayout.LayoutParams replyContainerLayoutParams = (ConstraintLayout.LayoutParams) binding.replyContainer.getLayoutParams(); final ConstraintLayout.LayoutParams replyInfoLayoutParams = (ConstraintLayout.LayoutParams) binding.replyInfo.getLayoutParams(); final int profilePicId = binding.ivProfilePic.getId(); final int replyContainerId = binding.replyContainer.getId(); final int quoteLineId = binding.quoteLine.getId(); quoteLineLayoutParams.startToEnd = isIncoming ? profilePicId : replyContainerId; quoteLineLayoutParams.endToStart = isIncoming ? replyContainerId : ConstraintLayout.LayoutParams.UNSET; quoteLineLayoutParams.endToEnd = isIncoming ? ConstraintLayout.LayoutParams.UNSET : ConstraintLayout.LayoutParams.PARENT_ID; replyContainerLayoutParams.startToEnd = isIncoming ? quoteLineId : profilePicId; replyContainerLayoutParams.endToEnd = isIncoming ? ConstraintLayout.LayoutParams.PARENT_ID : ConstraintLayout.LayoutParams.UNSET; replyContainerLayoutParams.endToStart = isIncoming ? ConstraintLayout.LayoutParams.UNSET : quoteLineId; replyInfoLayoutParams.startToEnd = isIncoming ? quoteLineId : ConstraintLayout.LayoutParams.UNSET; replyInfoLayoutParams.endToStart = isIncoming ? ConstraintLayout.LayoutParams.UNSET : quoteLineId; } private void setReactions(final DirectItem item, final int position) { binding.getRoot().post(() -> { MaterialFade materialFade = new MaterialFade(); materialFade.addTarget(binding.emojis); TransitionManager.beginDelayedTransition(binding.getRoot(), materialFade); final DirectItemReactions reactions = item.getReactions(); final List emojis = reactions != null ? reactions.getEmojis() : null; if (emojis == null || emojis.isEmpty()) { binding.container.setPadding(messageInfoPaddingSmall, messageInfoPaddingSmall, messageInfoPaddingSmall, 0); binding.reactionsWrapper.setVisibility(View.GONE); return; } binding.reactionsWrapper.setVisibility(View.VISIBLE); binding.reactionsWrapper.setTranslationY(getReactionsTranslationY()); binding.container.setPadding(messageInfoPaddingSmall, messageInfoPaddingSmall, messageInfoPaddingSmall, reactionAdjustMargin); binding.emojis.setEmojis(emojis.stream() .map(DirectItemEmojiReaction::getEmoji) .collect(Collectors.toList())); // binding.emojis.setEmojis(ImmutableList.of("😣", // "😖", // "😫", // "😩", // "🥺", // "😢", // "😭", // "😤", // "😠", // "😡", // "🤬")); binding.emojis.setOnClickListener(v -> callback.onReactionClick(item, position)); // final List reactedUsers = emojis.stream() // .map(DirectItemEmojiReaction::getSenderId) // .distinct() // .map(userId -> getUser(userId, users)) // .collect(Collectors.toList()); // for (final DirectUser user : reactedUsers) { // if (user == null) continue; // final ProfilePicView profilePicView = new ProfilePicView(itemView.getContext()); // profilePicView.setSize(ProfilePicView.Size.TINY); // profilePicView.setImageURI(user.getProfilePicUrl()); // binding.reactions.addView(profilePicView); // } }); } protected boolean isSelf(final DirectItem directItem) { return directItem.getUserId() == currentUser.getPk(); } public void setItemView(final View view) { this.binding.message.addView(view); } public abstract void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection); @Nullable protected User getUser(final long userId, final List users) { if (userId == currentUser.getPk()) { return currentUser; } if (users == null) return null; for (final User user : users) { if (userId != user.getPk()) continue; return user; } return null; } protected boolean allowMessageDirectionGravity() { return true; } protected boolean showUserDetailsInGroup() { return true; } protected boolean showBackground() { return false; } protected boolean showMessageInfo() { return true; } protected boolean allowLongClick() { return true; } protected boolean allowReaction() { return true; } protected boolean canForward() { return true; } protected List getLongClickOptions() { return null; } protected int getReactionsTranslationY() { return reactionTranslationYType1; } @CallSuper public void cleanup() { // if (layoutChangeListener != null) { // binding.container.removeOnLayoutChangeListener(layoutChangeListener); // } } protected void setupRamboTextListeners(@NonNull final RamboTextViewV2 textView) { textView.addOnHashtagListener(autoLinkItem -> callback.onHashtagClick(autoLinkItem.getOriginalText().trim())); textView.addOnMentionClickListener(autoLinkItem -> openProfile(autoLinkItem.getOriginalText().trim())); textView.addOnEmailClickListener(autoLinkItem -> callback.onEmailClick(autoLinkItem.getOriginalText().trim())); textView.addOnURLClickListener(autoLinkItem -> openURL(autoLinkItem.getOriginalText().trim())); } protected void openProfile(final String username) { callback.onMentionClick(username); } protected void openLocation(final long locationId) { callback.onLocationClick(locationId); } protected void openURL(final String url) { callback.onURLClick(url); } protected void openMedia(final Media media, final int index) { callback.onMediaClick(media, index); } protected void openStory(final DirectItemStoryShare storyShare) { callback.onStoryClick(storyShare); } protected void handleDeepLink(final String deepLinkText) { if (deepLinkText == null) return; final DeepLinkParser.DeepLink deepLink = DeepLinkParser.parse(deepLinkText); if (deepLink == null) return; switch (deepLink.getType()) { case USER: callback.onMentionClick(deepLink.getValue()); break; } } @SuppressLint("ClickableViewAccessibility") private void setupLongClickListener(final int position, final MessageDirection messageDirection) { if (!allowLongClick()) return; binding.getRoot().setOnItemLongClickListener(new DirectItemFrameLayout.OnItemLongClickListener() { @Override public void onLongClickStart(final View view) { itemView.post(() -> shrink()); } @Override public void onLongClickCancel(final View view) { itemView.post(() -> grow()); } @Override public void onLongClick(final View view, final float x, final float y) { // if (longClickListener == null) return false; // longClickListener.onLongClick(position, this); itemView.post(() -> grow()); setSelected(true); showLongClickOptions(new Point((int) x, (int) y), messageDirection); } }); } private void showLongClickOptions(final Point location, final MessageDirection messageDirection) { final List longClickOptions = getLongClickOptions(); final ImmutableList.Builder builder = ImmutableList.builder(); if (longClickOptions != null) { builder.addAll(longClickOptions); } if (canForward()) { builder.add(new DirectItemContextMenu.MenuItem(R.id.forward, R.string.forward)); } if (thread.getInputMode() != 1 && messageDirection == MessageDirection.OUTGOING) { builder.add(new DirectItemContextMenu.MenuItem(R.id.unsend, R.string.dms_inbox_unsend)); } final boolean showReactions = thread.getInputMode() != 1 && allowReaction(); final ImmutableList menuItems = builder.build(); if (!showReactions && menuItems.isEmpty()) return; final DirectItemContextMenu menu = new DirectItemContextMenu(itemView.getContext(), showReactions, menuItems); menu.setOnDismissListener(() -> setSelected(false)); menu.setOnReactionClickListener(emoji -> callback.onReaction(item, emoji)); menu.setOnOptionSelectListener((itemId, cb) -> callback.onOptionSelect(item, itemId, cb)); menu.setOnAddReactionListener(() -> { menu.dismiss(); itemView.postDelayed(() -> callback.onAddReactionListener(item), 300); }); menu.show(itemView, location); } public void setLongClickListener(final DirectItemInternalLongClickListener longClickListener) { this.longClickListener = longClickListener; } public void setSelected(final boolean selected) { this.selected = selected; } private void shrink() { if (shrinkGrowAnimator != null) { shrinkGrowAnimator.cancel(); } shrinkGrowAnimator = itemView.animate() .scaleX(0.8f) .scaleY(0.8f) .setInterpolator(accelerateDecelerateInterpolator) .setDuration(ViewConfiguration.getLongPressTimeout() - ViewConfiguration.getTapTimeout()); shrinkGrowAnimator.start(); } private void grow() { if (shrinkGrowAnimator != null) { shrinkGrowAnimator.cancel(); } shrinkGrowAnimator = itemView.animate() .scaleX(1f) .scaleY(1f) .setInterpolator(accelerateDecelerateInterpolator) .setDuration(200) .withEndAction(() -> shrinkGrowAnimator = null); shrinkGrowAnimator.start(); } @Override public int getSwipeDirection() { if (item == null || messageDirection == null) return ItemTouchHelper.ACTION_STATE_IDLE; return messageDirection == MessageDirection.OUTGOING ? ItemTouchHelper.START : ItemTouchHelper.END; } public enum MessageDirection { INCOMING, OUTGOING } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemVoiceMediaViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.os.Handler; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.common.collect.ImmutableList; import com.google.common.primitives.Floats; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.customviews.DirectItemContextMenu; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.databinding.LayoutDmVoiceMediaBinding; import awais.instagrabber.repositories.responses.Audio; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemVoiceMedia; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.utils.TextUtils; import static com.google.android.exoplayer2.C.TIME_UNSET; public class DirectItemVoiceMediaViewHolder extends DirectItemViewHolder { private static final String TAG = "DirectItemVoiceMediaVH"; private final LayoutDmVoiceMediaBinding binding; private final DefaultDataSourceFactory dataSourceFactory; private SimpleExoPlayer player; private Handler handler; private Runnable positionChecker; private Player.EventListener listener; public DirectItemVoiceMediaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmVoiceMediaBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; this.dataSourceFactory = new DefaultDataSourceFactory(binding.getRoot().getContext(), "instagram"); setItemView(binding.getRoot()); binding.voiceMedia.getLayoutParams().width = mediaImageMaxWidth; } @Override public void bindItem(final DirectItem directItemModel, final MessageDirection messageDirection) { final DirectItemVoiceMedia voiceMedia = directItemModel.getVoiceMedia(); if (voiceMedia == null) return; final Media media = voiceMedia.getMedia(); if (media == null) return; final Audio audio = media.getAudio(); if (audio == null) return; final List waveformData = audio.getWaveformData(); binding.waveformSeekBar.setSample(Floats.toArray(waveformData)); binding.waveformSeekBar.setEnabled(false); final String text = String.format("%s/%s", TextUtils.millisToTimeString(0), TextUtils.millisToTimeString(audio.getDuration())); binding.duration.setText(text); final AudioItemState audioItemState = new AudioItemState(); player = new SimpleExoPlayer.Builder(itemView.getContext()).build(); player.setVolume(1); player.setPlayWhenReady(true); player.setRepeatMode(Player.REPEAT_MODE_OFF); handler = new Handler(); final long initialDelay = 0; final long recurringDelay = 60; positionChecker = new Runnable() { @Override public void run() { if (handler != null) { handler.removeCallbacks(this); } if (player == null) return; final long currentPosition = player.getCurrentPosition(); final long duration = player.getDuration(); // Log.d(TAG, "currentPosition: " + currentPosition + ", duration: " + duration); if (duration == TIME_UNSET) return; // final float progress = ((float) currentPosition / duration /* * 100 */); final int progress = (int) ((float) currentPosition / duration * 100); // Log.d(TAG, "progress: " + progress); final String text = String.format("%s/%s", TextUtils.millisToTimeString(currentPosition), TextUtils.millisToTimeString(duration)); binding.duration.setText(text); binding.waveformSeekBar.setProgress(progress); if (handler != null) { handler.postDelayed(this, recurringDelay); } } }; player.addListener(listener = new Player.EventListener() { @Override public void onPlaybackStateChanged(final int state) { if (!audioItemState.isPrepared() && state == Player.STATE_READY) { binding.playPause.setIconResource(R.drawable.ic_round_pause_24); audioItemState.setPrepared(true); binding.playPause.setVisibility(View.VISIBLE); binding.progressBar.setVisibility(View.GONE); if (handler != null) { handler.postDelayed(positionChecker, initialDelay); } return; } if (state == Player.STATE_ENDED) { // binding.waveformSeekBar.setProgressInPercentage(0); binding.waveformSeekBar.setProgress(0); binding.playPause.setIconResource(R.drawable.ic_round_play_arrow_24); if (handler != null) { handler.removeCallbacks(positionChecker); } } } @Override public void onPlayerError(final ExoPlaybackException error) { Log.e(TAG, "onPlayerError: ", error); } }); final ProgressiveMediaSource.Factory sourceFactory = new ProgressiveMediaSource.Factory(dataSourceFactory); final MediaItem mediaItem = MediaItem.fromUri(audio.getAudioSrc()); final ProgressiveMediaSource mediaSource = sourceFactory.createMediaSource(mediaItem); player.setMediaSource(mediaSource); binding.playPause.setOnClickListener(v -> { if (player == null) return; if (!audioItemState.isPrepared()) { player.prepare(); binding.playPause.setVisibility(View.GONE); binding.progressBar.setVisibility(View.VISIBLE); return; } if (player.isPlaying()) { binding.playPause.setIconResource(R.drawable.ic_round_play_arrow_24); player.pause(); return; } binding.playPause.setIconResource(R.drawable.ic_round_pause_24); if (player.getPlaybackState() == Player.STATE_ENDED) { player.seekTo(0); if (handler != null) { handler.postDelayed(positionChecker, initialDelay); } } binding.waveformSeekBar.setEnabled(true); player.play(); }); } @Override public void cleanup() { super.cleanup(); if (handler != null && positionChecker != null) { handler.removeCallbacks(positionChecker); handler = null; positionChecker = null; } if (player != null) { player.release(); if (listener != null) { player.removeListener(listener); } player = null; } } @Override protected boolean canForward() { return false; } @Override protected List getLongClickOptions() { return ImmutableList.of( new DirectItemContextMenu.MenuItem(R.id.download, R.string.action_download) ); } private static class AudioItemState { private boolean prepared; private AudioItemState() {} public boolean isPrepared() { return prepared; } public void setPrepared(final boolean prepared) { this.prepared = prepared; } } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectItemXmaViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import com.facebook.drawee.backends.pipeline.Fresco; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.databinding.LayoutDmAnimatedMediaBinding; import awais.instagrabber.databinding.LayoutDmBaseBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemXma; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.XmaUrlInfo; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; public class DirectItemXmaViewHolder extends DirectItemViewHolder { private final LayoutDmAnimatedMediaBinding binding; public DirectItemXmaViewHolder(@NonNull final LayoutDmBaseBinding baseBinding, @NonNull final LayoutDmAnimatedMediaBinding binding, final User currentUser, final DirectThread thread, final DirectItemCallback callback) { super(baseBinding, currentUser, thread, callback); this.binding = binding; setItemView(binding.getRoot()); } @Override public void bindItem(final DirectItem item, final MessageDirection messageDirection) { final DirectItemXma xma = item.getXma(); final XmaUrlInfo playableUrlInfo = xma.getPlayableUrlInfo(); final XmaUrlInfo previewUrlInfo = xma.getPreviewUrlInfo(); if (playableUrlInfo == null && previewUrlInfo == null) { binding.ivAnimatedMessage.setController(null); return; } final XmaUrlInfo urlInfo = playableUrlInfo != null ? playableUrlInfo : previewUrlInfo; final String url = urlInfo.getUrl(); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight( urlInfo.getHeight(), urlInfo.getWidth(), mediaImageMaxHeight, mediaImageMaxWidth ); binding.ivAnimatedMessage.setVisibility(View.VISIBLE); final ViewGroup.LayoutParams layoutParams = binding.ivAnimatedMessage.getLayoutParams(); final int width = widthHeight.first; final int height = widthHeight.second; layoutParams.width = width; layoutParams.height = height; binding.ivAnimatedMessage.requestLayout(); binding.ivAnimatedMessage.setController(Fresco.newDraweeControllerBuilder() .setUri(url) .setAutoPlayAnimations(true) .build()); } @Override public int getSwipeDirection() { return ItemTouchHelper.ACTION_STATE_IDLE; } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectPendingUserViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.graphics.drawable.Drawable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUser; import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUserCallback; import awais.instagrabber.customviews.VerticalImageSpan; import awais.instagrabber.databinding.LayoutDmPendingUserItemBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.Utils; public class DirectPendingUserViewHolder extends RecyclerView.ViewHolder { private static final String TAG = DirectPendingUserViewHolder.class.getSimpleName(); private final LayoutDmPendingUserItemBinding binding; private final PendingUserCallback callback; private final int drawableSize; private VerticalImageSpan verifiedSpan; public DirectPendingUserViewHolder(@NonNull final LayoutDmPendingUserItemBinding binding, final PendingUserCallback callback) { super(binding.getRoot()); this.binding = binding; this.callback = callback; drawableSize = Utils.convertDpToPx(24); } public void bind(final int position, final PendingUser pendingUser) { if (pendingUser == null) return; binding.getRoot().setOnClickListener(v -> { if (callback == null) return; callback.onClick(position, pendingUser); }); setUsername(pendingUser); binding.requester.setText(itemView.getResources().getString(R.string.added_by, pendingUser.getRequester())); binding.profilePic.setImageURI(pendingUser.getUser().getProfilePicUrl()); if (pendingUser.isInProgress()) { binding.approve.setVisibility(View.GONE); binding.deny.setVisibility(View.GONE); binding.progress.setVisibility(View.VISIBLE); return; } binding.approve.setVisibility(View.VISIBLE); binding.deny.setVisibility(View.VISIBLE); binding.progress.setVisibility(View.GONE); binding.approve.setOnClickListener(v -> { if (callback == null) return; callback.onApprove(position, pendingUser); }); binding.deny.setOnClickListener(v -> { if (callback == null) return; callback.onDeny(position, pendingUser); }); } private void setUsername(final PendingUser pendingUser) { final User user = pendingUser.getUser(); final SpannableStringBuilder sb = new SpannableStringBuilder(user.getUsername()); if (user.isVerified()) { if (verifiedSpan == null) { final Drawable verifiedDrawable = AppCompatResources.getDrawable(itemView.getContext(), R.drawable.verified); if (verifiedDrawable != null) { final Drawable drawable = verifiedDrawable.mutate(); drawable.setBounds(0, 0, drawableSize, drawableSize); verifiedSpan = new VerticalImageSpan(drawable); } } try { if (verifiedSpan != null) { sb.append(" "); sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } catch (Exception e) { Log.e(TAG, "bind: ", e); } } binding.username.setText(sb); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectReactionViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.view.View; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectReactionsAdapter.OnReactionClickListener; import awais.instagrabber.customviews.emoji.Emoji; import awais.instagrabber.databinding.LayoutDmUserItemBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; import awais.instagrabber.utils.emoji.EmojiParser; public class DirectReactionViewHolder extends RecyclerView.ViewHolder { private final LayoutDmUserItemBinding binding; private final long viewerId; private final String itemId; private final OnReactionClickListener onReactionClickListener; private final EmojiParser emojiParser; public DirectReactionViewHolder(final LayoutDmUserItemBinding binding, final long viewerId, final String itemId, final OnReactionClickListener onReactionClickListener) { super(binding.getRoot()); this.binding = binding; this.viewerId = viewerId; this.itemId = itemId; this.onReactionClickListener = onReactionClickListener; binding.info.setVisibility(View.GONE); binding.secondaryImage.setVisibility(View.VISIBLE); emojiParser = EmojiParser.Companion.getInstance(itemView.getContext()); } public void bind(final DirectItemEmojiReaction reaction, @Nullable final User user) { itemView.setOnClickListener(v -> { if (onReactionClickListener == null) return; onReactionClickListener.onReactionClick(itemId, reaction); }); setUser(user); setReaction(reaction); } private void setReaction(final DirectItemEmojiReaction reaction) { final Emoji emoji = emojiParser.getEmoji(reaction.getEmoji()); if (emoji == null) { binding.secondaryImage.setImageDrawable(null); return; } binding.secondaryImage.setImageDrawable(emoji.getDrawable()); } private void setUser(final User user) { if (user == null) { binding.fullName.setText(""); binding.username.setText(""); binding.profilePic.setImageURI((String) null); return; } binding.fullName.setText(user.getFullName()); if (user.getPk() == viewerId) { binding.username.setText(R.string.tap_to_remove); } else { binding.username.setText(user.getUsername()); } binding.profilePic.setImageURI(user.getProfilePicUrl()); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/DirectUserViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.graphics.drawable.Drawable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserClickListener; import awais.instagrabber.adapters.DirectUsersAdapter.OnDirectUserLongClickListener; import awais.instagrabber.customviews.VerticalImageSpan; import awais.instagrabber.databinding.LayoutDmUserItemBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.Utils; public class DirectUserViewHolder extends RecyclerView.ViewHolder { private static final String TAG = DirectUserViewHolder.class.getSimpleName(); private final LayoutDmUserItemBinding binding; private final OnDirectUserClickListener onClickListener; private final OnDirectUserLongClickListener onLongClickListener; private final int drawableSize; private VerticalImageSpan verifiedSpan; public DirectUserViewHolder(@NonNull final LayoutDmUserItemBinding binding, final OnDirectUserClickListener onClickListener, final OnDirectUserLongClickListener onLongClickListener) { super(binding.getRoot()); this.binding = binding; this.onClickListener = onClickListener; this.onLongClickListener = onLongClickListener; drawableSize = Utils.convertDpToPx(24); } public void bind(final int position, final User user, final boolean isAdmin, final boolean isInviter, final boolean showSelection, final boolean isSelected) { if (user == null) return; binding.getRoot().setOnClickListener(v -> { if (onClickListener == null) return; onClickListener.onClick(position, user, isSelected); }); binding.getRoot().setOnLongClickListener(v -> { if (onLongClickListener == null) return false; return onLongClickListener.onLongClick(position, user); }); setFullName(user); binding.username.setText(user.getUsername()); binding.profilePic.setImageURI(user.getProfilePicUrl()); setInfo(isAdmin, isInviter); setSelection(showSelection, isSelected); } private void setFullName(final User user) { final SpannableStringBuilder sb = new SpannableStringBuilder(user.getFullName()); if (user.isVerified()) { if (verifiedSpan == null) { final Drawable verifiedDrawable = AppCompatResources.getDrawable(itemView.getContext(), R.drawable.verified); if (verifiedDrawable != null) { final Drawable drawable = verifiedDrawable.mutate(); drawable.setBounds(0, 0, drawableSize, drawableSize); verifiedSpan = new VerticalImageSpan(drawable); } } try { if (verifiedSpan != null) { sb.append(" "); sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } catch (Exception e) { Log.e(TAG, "bind: ", e); } } binding.fullName.setText(sb); } private void setInfo(final boolean isAdmin, final boolean isInviter) { if (!isAdmin && !isInviter) { binding.info.setVisibility(View.GONE); return; } if (isAdmin) { binding.info.setText(R.string.admin); return; } binding.info.setText(R.string.inviter); } private void setSelection(final boolean showSelection, final boolean isSelected) { binding.select.setVisibility(showSelection ? View.VISIBLE : View.GONE); binding.getRoot().setSelected(isSelected); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/directmessages/RecipientThreadViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.directmessages; import android.content.res.Resources; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.UserSearchResultsAdapter.OnRecipientClickListener; import awais.instagrabber.databinding.LayoutDmUserItemBinding; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; public class RecipientThreadViewHolder extends RecyclerView.ViewHolder { private static final String TAG = RecipientThreadViewHolder.class.getSimpleName(); private final LayoutDmUserItemBinding binding; private final OnRecipientClickListener onThreadClickListener; private final float translateAmount; public RecipientThreadViewHolder(@NonNull final LayoutDmUserItemBinding binding, final OnRecipientClickListener onThreadClickListener) { super(binding.getRoot()); this.binding = binding; this.onThreadClickListener = onThreadClickListener; binding.info.setVisibility(View.GONE); final Resources resources = itemView.getResources(); final int avatarSize = resources.getDimensionPixelSize(R.dimen.dm_inbox_avatar_size); translateAmount = ((float) avatarSize) / 7; } public void bind(final int position, final DirectThread thread, final boolean showSelection, final boolean isSelected) { if (thread == null || thread.getUsers().size() == 0) return; binding.getRoot().setOnClickListener(v -> { if (onThreadClickListener == null) return; onThreadClickListener.onClick(position, RankedRecipient.of(thread), isSelected); }); binding.fullName.setText(thread.getThreadTitle()); setUsername(thread); setProfilePic(thread); setSelection(showSelection, isSelected); } private void setProfilePic(final DirectThread thread) { final List users = thread.getUsers(); binding.profilePic.setImageURI(users.get(0).getProfilePicUrl()); binding.profilePic.setScaleX(1); binding.profilePic.setScaleY(1); binding.profilePic.setTranslationX(0); binding.profilePic.setTranslationY(0); if (users.size() > 1) { binding.profilePic2.setVisibility(View.VISIBLE); binding.profilePic2.setImageURI(users.get(1).getProfilePicUrl()); binding.profilePic2.setTranslationX(translateAmount); binding.profilePic2.setTranslationY(translateAmount); final float scaleAmount = 0.75f; binding.profilePic2.setScaleX(scaleAmount); binding.profilePic2.setScaleY(scaleAmount); binding.profilePic.setScaleX(scaleAmount); binding.profilePic.setScaleY(scaleAmount); binding.profilePic.setTranslationX(-translateAmount); binding.profilePic.setTranslationY(-translateAmount); return; } binding.profilePic2.setVisibility(View.GONE); } private void setUsername(final DirectThread thread) { if (thread.isGroup()) { binding.username.setVisibility(View.GONE); return; } binding.username.setVisibility(View.VISIBLE); // for a non-group thread, the thread title is the username so set the full name in the username text view binding.username.setText(thread.getUsers().get(0).getFullName()); } private void setSelection(final boolean showSelection, final boolean isSelected) { binding.select.setVisibility(showSelection ? View.VISIBLE : View.GONE); binding.getRoot().setSelected(isSelected); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedItemViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.feed; import android.graphics.drawable.Drawable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.transition.TransitionManager; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.RelativeLayout; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.customviews.VerticalImageSpan; import awais.instagrabber.databinding.ItemFeedTopBinding; import awais.instagrabber.databinding.LayoutPostViewBottomBinding; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Caption; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import static android.text.TextUtils.TruncateAt.END; public abstract class FeedItemViewHolder extends RecyclerView.ViewHolder { public static final int MAX_LINES_COLLAPSED = 5; private final ItemFeedTopBinding topBinding; private final LayoutPostViewBottomBinding bottomBinding; private final ViewGroup bottomFrame; private final FeedAdapterV2.FeedItemCallback feedItemCallback; public FeedItemViewHolder(@NonNull final ViewGroup root, final FeedAdapterV2.FeedItemCallback feedItemCallback) { super(root); this.bottomFrame = root; this.topBinding = ItemFeedTopBinding.bind(root); this.bottomBinding = LayoutPostViewBottomBinding.bind(root); this.feedItemCallback = feedItemCallback; } public void bind(final Media media) { if (media == null) { return; } setupProfilePic(media); bottomBinding.date.setText(media.getDate()); setupComments(media); setupCaption(media); setupActions(media); if (media.getType() != MediaItemType.MEDIA_TYPE_SLIDER) { bottomBinding.download.setOnClickListener(v -> feedItemCallback.onDownloadClick(media, -1, bottomBinding.download) ); } bindItem(media); bottomFrame.post(() -> setupLocation(media)); } private void setupComments(@NonNull final Media feedModel) { final long commentsCount = feedModel.getCommentCount(); bottomBinding.commentsCount.setText(String.valueOf(commentsCount)); bottomBinding.comment.setOnClickListener(v -> feedItemCallback.onCommentsClick(feedModel)); } private void setupProfilePic(@NonNull final Media media) { final User user = media.getUser(); if (user == null) { topBinding.profilePic.setVisibility(View.GONE); topBinding.title.setVisibility(View.GONE); topBinding.subtitle.setVisibility(View.GONE); return; } topBinding.profilePic.setOnClickListener(v -> feedItemCallback.onProfilePicClick(media)); topBinding.profilePic.setImageURI(user.getProfilePicUrl()); setupTitle(media); } private void setupTitle(@NonNull final Media media) { // final int titleLen = profileModel.getUsername().length() + 1; // final SpannableString spannableString = new SpannableString(); // spannableString.setSpan(new CommentMentionClickSpan(), 0, titleLen, 0); final User user = media.getUser(); if (user == null) return; setUsername(user); topBinding.title.setOnClickListener(v -> feedItemCallback.onNameClick(media)); final String fullName = user.getFullName(); if (TextUtils.isEmpty(fullName)) { topBinding.subtitle.setVisibility(View.GONE); } else { topBinding.subtitle.setVisibility(View.VISIBLE); topBinding.subtitle.setText(fullName); } topBinding.subtitle.setOnClickListener(v -> feedItemCallback.onNameClick(media)); } private void setupCaption(final Media media) { bottomBinding.caption.clearOnMentionClickListeners(); bottomBinding.caption.clearOnHashtagClickListeners(); bottomBinding.caption.clearOnURLClickListeners(); bottomBinding.caption.clearOnEmailClickListeners(); final Caption caption = media.getCaption(); if (caption == null) { bottomBinding.caption.setVisibility(View.GONE); return; } final CharSequence postCaption = caption.getText(); final boolean captionEmpty = TextUtils.isEmpty(postCaption); bottomBinding.caption.setVisibility(captionEmpty ? View.GONE : View.VISIBLE); if (captionEmpty) return; bottomBinding.caption.setText(postCaption); bottomBinding.caption.setMaxLines(MAX_LINES_COLLAPSED); bottomBinding.caption.setEllipsize(END); bottomBinding.caption.setOnClickListener(v -> bottomFrame.post(() -> { TransitionManager.beginDelayedTransition(bottomFrame); if (bottomBinding.caption.getMaxLines() == MAX_LINES_COLLAPSED) { bottomBinding.caption.setMaxLines(Integer.MAX_VALUE); bottomBinding.caption.setEllipsize(null); return; } bottomBinding.caption.setMaxLines(MAX_LINES_COLLAPSED); bottomBinding.caption.setEllipsize(END); })); bottomBinding.caption.addOnMentionClickListener(autoLinkItem -> feedItemCallback.onMentionClick(autoLinkItem.getOriginalText())); bottomBinding.caption.addOnHashtagListener(autoLinkItem -> feedItemCallback.onHashtagClick(autoLinkItem.getOriginalText())); bottomBinding.caption.addOnEmailClickListener(autoLinkItem -> feedItemCallback.onEmailClick(autoLinkItem.getOriginalText())); bottomBinding.caption.addOnURLClickListener(autoLinkItem -> feedItemCallback.onURLClick(autoLinkItem.getOriginalText())); } private void setupLocation(@NonNull final Media media) { final Location location = media.getLocation(); if (location == null) { topBinding.location.setVisibility(View.GONE); } else { final String locationName = location.getName(); if (TextUtils.isEmpty(locationName)) { topBinding.location.setVisibility(View.GONE); } else { topBinding.location.setVisibility(View.VISIBLE); topBinding.location.setText(locationName); topBinding.location.setOnClickListener(v -> feedItemCallback.onLocationClick(media)); } } } private void setupActions(@NonNull final Media media) { // temporary - to be set up later bottomBinding.like.setVisibility(View.GONE); bottomBinding.save.setVisibility(View.GONE); bottomBinding.translate.setVisibility(View.GONE); bottomBinding.share.setVisibility(View.GONE); } private void setUsername(final User user) { final SpannableStringBuilder sb = new SpannableStringBuilder(user.getUsername()); final int drawableSize = Utils.convertDpToPx(24); if (user.isVerified()) { final Drawable verifiedDrawable = itemView.getResources().getDrawable(R.drawable.verified); VerticalImageSpan verifiedSpan = null; if (verifiedDrawable != null) { final Drawable drawable = verifiedDrawable.mutate(); drawable.setBounds(0, 0, drawableSize, drawableSize); verifiedSpan = new VerticalImageSpan(drawable); } try { if (verifiedSpan != null) { sb.append(" "); sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } catch (Exception e) { Log.e("FeedItemViewHolder", "setUsername: ", e); } } topBinding.title.setText(sb); } public abstract void bindItem(final Media media); } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedPhotoViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.feed; import android.net.Uri; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchy; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.databinding.ItemFeedPhotoBinding; import awais.instagrabber.databinding.LayoutPostViewBottomBinding; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; public class FeedPhotoViewHolder extends FeedItemViewHolder { private static final String TAG = "FeedPhotoViewHolder"; private final ItemFeedPhotoBinding binding; private final FeedAdapterV2.FeedItemCallback feedItemCallback; public FeedPhotoViewHolder(@NonNull final ItemFeedPhotoBinding binding, final FeedAdapterV2.FeedItemCallback feedItemCallback) { super(binding.getRoot(), feedItemCallback); this.binding = binding; this.feedItemCallback = feedItemCallback; final LayoutPostViewBottomBinding bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); bottom.viewsCount.setVisibility(View.GONE); // binding.itemFeedBottom.btnMute.setVisibility(View.GONE); binding.imageViewer.setAllowTouchInterceptionWhileZoomed(false); final GenericDraweeHierarchy hierarchy = new GenericDraweeHierarchyBuilder(itemView.getContext().getResources()) .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) .build(); binding.imageViewer.setHierarchy(hierarchy); } @Override public void bindItem(final Media media) { if (media == null) return; binding.getRoot().post(() -> { setDimensions(media); final String thumbnailUrl = ResponseBodyUtils.getThumbUrl(media); String url = ResponseBodyUtils.getImageUrl(media); if (TextUtils.isEmpty(url)) url = thumbnailUrl; final ImageRequest requestBuilder = ImageRequestBuilder.newBuilderWithSource(Uri.parse(url)) // .setLocalThumbnailPreviewsEnabled(true) // .setProgressiveRenderingEnabled(true) .build(); binding.imageViewer.setController(Fresco.newDraweeControllerBuilder() .setImageRequest(requestBuilder) .setOldController(binding.imageViewer.getController()) .setLowResImageRequest(ImageRequest.fromUri(thumbnailUrl)) .build()); binding.imageViewer.setTapListener(new GestureDetector.SimpleOnGestureListener() { @Override public boolean onSingleTapConfirmed(final MotionEvent e) { if (feedItemCallback != null) { feedItemCallback.onPostClick(media); return true; } return false; } }); }); } private void setDimensions(final Media feedModel) { final float aspectRatio = (float) feedModel.getOriginalWidth() / feedModel.getOriginalHeight(); binding.imageViewer.setAspectRatio(aspectRatio); } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedSliderViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.feed; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import androidx.annotation.NonNull; import androidx.viewpager2.widget.ViewPager2; import java.util.List; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.adapters.SliderCallbackAdapter; import awais.instagrabber.adapters.SliderItemsAdapter; import awais.instagrabber.databinding.ItemFeedSliderBinding; import awais.instagrabber.databinding.LayoutPostViewBottomBinding; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.Utils; public class FeedSliderViewHolder extends FeedItemViewHolder { private static final String TAG = "FeedSliderViewHolder"; // private static final boolean shouldAutoPlay = settingsHelper.getBoolean(Constants.AUTOPLAY_VIDEOS); private final ItemFeedSliderBinding binding; private final FeedAdapterV2.FeedItemCallback feedItemCallback; private final LayoutPostViewBottomBinding bottom; public FeedSliderViewHolder(@NonNull final ItemFeedSliderBinding binding, final FeedAdapterV2.FeedItemCallback feedItemCallback) { super(binding.getRoot(), feedItemCallback); this.binding = binding; this.feedItemCallback = feedItemCallback; bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); bottom.viewsCount.setVisibility(View.GONE); // bottom.btnMute.setVisibility(View.GONE); final ViewGroup.LayoutParams layoutParams = binding.mediaList.getLayoutParams(); layoutParams.height = Utils.displayMetrics.widthPixels + 1; binding.mediaList.setLayoutParams(layoutParams); // final Context context = binding.getRoot().getContext(); } @Override public void bindItem(final Media feedModel) { final List sliderItems = feedModel.getCarouselMedia(); final int sliderItemLen = sliderItems != null ? sliderItems.size() : 0; if (sliderItemLen <= 0) return; final String text = "1/" + sliderItemLen; binding.mediaCounter.setText(text); binding.mediaList.setOffscreenPageLimit(1); final SliderItemsAdapter adapter = new SliderItemsAdapter(false, new SliderCallbackAdapter() { @Override public void onItemClicked(final int position, final Media media, final View view) { feedItemCallback.onSliderClick(feedModel, position); } }); binding.mediaList.setAdapter(adapter); binding.mediaList.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @Override public void onPageSelected(final int position) { if (position >= sliderItemLen) return; final String text = (position + 1) + "/" + sliderItemLen; binding.mediaCounter.setText(text); setDimensions(binding.mediaList, sliderItems.get(position)); bottom.download.setOnClickListener(v -> feedItemCallback.onDownloadClick(feedModel, position, bottom.download) ); } }); setDimensions(binding.mediaList, sliderItems.get(0)); bottom.download.setOnClickListener(v -> feedItemCallback.onDownloadClick(feedModel, 0, bottom.download) ); adapter.submitList(sliderItems); } private void setDimensions(final View view, final Media model) { final ViewGroup.LayoutParams layoutParams = binding.mediaList.getLayoutParams(); int requiredWidth = layoutParams.width; if (requiredWidth <= 0) { final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { view.getViewTreeObserver().removeOnPreDrawListener(this); setLayoutParamDimens(binding.mediaList, model); return true; } }; view.getViewTreeObserver().addOnPreDrawListener(preDrawListener); return; } setLayoutParamDimens(binding.mediaList, model); } private void setLayoutParamDimens(final View view, final Media model) { final int requiredWidth = view.getMeasuredWidth(); final ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); final int spanHeight = NumberUtils.getResultingHeight(requiredWidth, model.getOriginalHeight(), model.getOriginalWidth()); layoutParams.height = spanHeight == 0 ? requiredWidth + 1 : spanHeight; view.requestLayout(); } // private void autoPlay(final int position) { // if (!shouldAutoPlay) { // return; // } // final ChildMediaItemsAdapter adapter = (ChildMediaItemsAdapter) binding.mediaList.getAdapter(); // if (adapter == null) { // return; // } // final ViewerPostModel sliderItem = adapter.getItemAtPosition(position); // if (sliderItem.getItemType() != MediaItemType.MEDIA_TYPE_VIDEO) { // return; // } // final ViewSwitcher viewSwitcher = (ViewSwitcher) binding.mediaList.getChildAt(position); // loadPlayer(binding.getRoot().getContext(), // position, sliderItem.getDisplayUrl(), // viewSwitcher, // cacheDataSourceFactory != null ? cacheDataSourceFactory : dataSourceFactory, // playerChangeListener); // } // public void startPlayingVideo() { // final int playerPosition = 0; // autoPlay(playerPosition); // } // // public void stopPlayingVideo() { // if (pagerPlayer == null) { // return; // } // pagerPlayer.setPlayWhenReady(false); // } // private interface PlayerChangeListener { // void playerChanged(final int position, final SimpleExoPlayer player); // } // // private static void loadPlayer(final Context context, // final int position, // final String displayUrl, // final ViewSwitcher viewSwitcher, // final DataSource.Factory factory, // final PlayerChangeListener playerChangeListener) { // if (viewSwitcher == null) { // return; // } // SimpleExoPlayer player = (SimpleExoPlayer) viewSwitcher.getTag(); // if (player != null) { // player.setPlayWhenReady(true); // return; // } // player = new SimpleExoPlayer.Builder(context).build(); // final PlayerView playerView = (PlayerView) viewSwitcher.getChildAt(1); // playerView.setPlayer(player); // if (viewSwitcher.getDisplayedChild() == 0) { // viewSwitcher.showNext(); // } // playerView.setControllerShowTimeoutMs(1000); // float vol = settingsHelper.getBoolean(Constants.MUTED_VIDEOS) ? 0f : 1f; // if (vol == 0f && Utils.sessionVolumeFull) vol = 1f; // player.setVolume(vol); // player.setPlayWhenReady(Utils.settingsHelper.getBoolean(Constants.AUTOPLAY_VIDEOS)); // final MediaItem mediaItem = MediaItem.fromUri(displayUrl); // final ProgressiveMediaSource mediaSource = new ProgressiveMediaSource.Factory(factory).createMediaSource(mediaItem); // player.setRepeatMode(Player.REPEAT_MODE_ALL); // player.setMediaSource(mediaSource); // player.prepare(); // player.setVolume(vol); // playerChangeListener.playerChanged(position, player); // viewSwitcher.setTag(player); // } } ================================================ FILE: app/src/main/java/awais/instagrabber/adapters/viewholder/feed/FeedVideoViewHolder.java ================================================ package awais.instagrabber.adapters.viewholder.feed; import android.content.Context; import android.os.Handler; import android.os.Looper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.constraintlayout.widget.ConstraintLayout; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.customviews.VideoPlayerCallbackAdapter; import awais.instagrabber.customviews.VideoPlayerViewHelper; import awais.instagrabber.databinding.ItemFeedVideoBinding; import awais.instagrabber.databinding.LayoutPostViewBottomBinding; import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.MediaCandidate; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.Utils; import static awais.instagrabber.utils.Utils.settingsHelper; public class FeedVideoViewHolder extends FeedItemViewHolder { private static final String TAG = "FeedVideoViewHolder"; private final ItemFeedVideoBinding binding; private final FeedAdapterV2.FeedItemCallback feedItemCallback; private final Handler handler; private final DefaultDataSourceFactory dataSourceFactory; private final LayoutPostViewBottomBinding bottom; private CacheDataSourceFactory cacheDataSourceFactory; private Media media; // private final Runnable loadRunnable = new Runnable() { // @Override // public void run() { // // loadPlayer(feedModel); // } // }; public FeedVideoViewHolder(@NonNull final ItemFeedVideoBinding binding, final FeedAdapterV2.FeedItemCallback feedItemCallback) { super(binding.getRoot(), feedItemCallback); bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); this.binding = binding; this.feedItemCallback = feedItemCallback; bottom.viewsCount.setVisibility(View.VISIBLE); handler = new Handler(Looper.getMainLooper()); final Context context = binding.getRoot().getContext(); dataSourceFactory = new DefaultDataSourceFactory(context, "instagram"); final SimpleCache simpleCache = Utils.getSimpleCacheInstance(context); if (simpleCache != null) { cacheDataSourceFactory = new CacheDataSourceFactory(simpleCache, dataSourceFactory); } } @Override public void bindItem(final Media media) { // Log.d(TAG, "Binding post: " + feedModel.getPostId()); this.media = media; final String viewCount = itemView.getResources().getQuantityString(R.plurals.views_count, (int) media.getViewCount(), media.getViewCount()); bottom.viewsCount.setText(viewCount); final LayoutVideoPlayerWithThumbnailBinding videoPost = LayoutVideoPlayerWithThumbnailBinding.inflate(LayoutInflater.from(itemView.getContext()), binding.getRoot(), false); final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) videoPost.getRoot().getLayoutParams(); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(media.getOriginalHeight(), media.getOriginalWidth(), (int) (Utils.displayMetrics.heightPixels * 0.8), Utils.displayMetrics.widthPixels); layoutParams.width = ConstraintLayout.LayoutParams.MATCH_PARENT; layoutParams.height = widthHeight.second; final View postView = videoPost.getRoot(); binding.postContainer.addView(postView); final float vol = settingsHelper.getBoolean(PreferenceKeys.MUTED_VIDEOS) ? 0f : 1f; final VideoPlayerViewHelper.VideoPlayerCallback videoPlayerCallback = new VideoPlayerCallbackAdapter() { @Override public void onThumbnailClick() { feedItemCallback.onPostClick(media); } @Override public void onPlayerViewLoaded() { final ViewGroup.LayoutParams layoutParams = videoPost.playerView.getLayoutParams(); final int requiredWidth = Utils.displayMetrics.widthPixels; final int resultingHeight = NumberUtils.getResultingHeight(requiredWidth, media.getOriginalHeight(), media.getOriginalWidth()); layoutParams.width = requiredWidth; layoutParams.height = resultingHeight; videoPost.playerView.requestLayout(); } }; final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); String videoUrl = null; final List videoVersions = media.getVideoVersions(); if (videoVersions != null && !videoVersions.isEmpty()) { final MediaCandidate videoVersion = videoVersions.get(0); videoUrl = videoVersion.getUrl(); } final VideoPlayerViewHelper videoPlayerViewHelper = new VideoPlayerViewHelper(binding.getRoot().getContext(), videoPost, videoUrl, vol, aspectRatio, ResponseBodyUtils.getThumbUrl(media), false, // null, videoPlayerCallback); videoPost.thumbnail.post(() -> { if (media.getOriginalHeight() > 0.8 * Utils.displayMetrics.heightPixels) { final ViewGroup.LayoutParams tLayoutParams = videoPost.thumbnail.getLayoutParams(); tLayoutParams.height = (int) (0.8 * Utils.displayMetrics.heightPixels); videoPost.thumbnail.requestLayout(); } }); } public Media getCurrentFeedModel() { return media; } // public void stopPlaying() { // // Log.d(TAG, "Stopping post: " + feedModel.getPostId() + ", player: " + player + ", player.isPlaying: " + (player != null && player.isPlaying())); // handler.removeCallbacks(loadRunnable); // if (player != null) { // player.release(); // } // if (videoPost.root.getDisplayedChild() == 1) { // videoPost.root.showPrevious(); // } // } // // public void startPlaying() { // handler.removeCallbacks(loadRunnable); // handler.postDelayed(loadRunnable, 800); // } } ================================================ FILE: app/src/main/java/awais/instagrabber/animations/CubicBezierInterpolator.java ================================================ package awais.instagrabber.animations; import android.graphics.PointF; import android.view.animation.Interpolator; public class CubicBezierInterpolator implements Interpolator { public static final CubicBezierInterpolator DEFAULT = new CubicBezierInterpolator(0.25, 0.1, 0.25, 1); public static final CubicBezierInterpolator EASE_OUT = new CubicBezierInterpolator(0, 0, .58, 1); public static final CubicBezierInterpolator EASE_OUT_QUINT = new CubicBezierInterpolator(.23, 1, .32, 1); public static final CubicBezierInterpolator EASE_IN = new CubicBezierInterpolator(.42, 0, 1, 1); public static final CubicBezierInterpolator EASE_BOTH = new CubicBezierInterpolator(.42, 0, .58, 1); protected PointF start; protected PointF end; protected PointF a = new PointF(); protected PointF b = new PointF(); protected PointF c = new PointF(); public CubicBezierInterpolator(PointF start, PointF end) throws IllegalArgumentException { if (start.x < 0 || start.x > 1) { throw new IllegalArgumentException("startX value must be in the range [0, 1]"); } if (end.x < 0 || end.x > 1) { throw new IllegalArgumentException("endX value must be in the range [0, 1]"); } this.start = start; this.end = end; } public CubicBezierInterpolator(float startX, float startY, float endX, float endY) { this(new PointF(startX, startY), new PointF(endX, endY)); } public CubicBezierInterpolator(double startX, double startY, double endX, double endY) { this((float) startX, (float) startY, (float) endX, (float) endY); } @Override public float getInterpolation(float time) { return getBezierCoordinateY(getXForTime(time)); } protected float getBezierCoordinateY(float time) { c.y = 3 * start.y; b.y = 3 * (end.y - start.y) - c.y; a.y = 1 - c.y - b.y; return time * (c.y + time * (b.y + time * a.y)); } protected float getXForTime(float time) { float x = time; float z; for (int i = 1; i < 14; i++) { z = getBezierCoordinateX(x) - time; if (Math.abs(z) < 1e-3) { break; } x -= z / getXDerivate(x); } return x; } private float getXDerivate(float t) { return c.x + t * (2 * b.x + 3 * a.x * t); } private float getBezierCoordinateX(float time) { c.x = 3 * start.x; b.x = 3 * (end.x - start.x) - c.x; a.x = 1 - c.x - b.x; return time * (c.x + time * (b.x + time * a.x)); } } ================================================ FILE: app/src/main/java/awais/instagrabber/animations/FabAnimation.java ================================================ package awais.instagrabber.animations; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.view.View; // https://medium.com/better-programming/animated-fab-button-with-more-options-2dcf7118fff6 public class FabAnimation { public static boolean rotateFab(final View v, boolean rotate) { v.animate().setDuration(200) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); } }) .rotation(rotate ? 135f : 0f); return rotate; } public static void showIn(final View v) { v.setVisibility(View.VISIBLE); v.setAlpha(0f); v.setTranslationY(v.getHeight()); v.animate() .setDuration(200) .translationY(0) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); } }) .alpha(1f) .start(); } public static void showOut(final View v) { v.setVisibility(View.VISIBLE); v.setAlpha(1f); v.setTranslationY(0); v.animate() .setDuration(200) .translationY(v.getHeight()) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { v.setVisibility(View.GONE); super.onAnimationEnd(animation); } }).alpha(0f) .start(); } public static void init(final View v) { v.setVisibility(View.GONE); v.setTranslationY(v.getHeight()); v.setAlpha(0f); } } ================================================ FILE: app/src/main/java/awais/instagrabber/animations/ResizeAnimation.java ================================================ package awais.instagrabber.animations; import android.view.View; import android.view.animation.Animation; import android.view.animation.Transformation; public class ResizeAnimation extends Animation { private static final String TAG = "ResizeAnimation"; final View view; final int startHeight; final int targetHeight; final int startWidth; final int targetWidth; public ResizeAnimation(final View view, final int startHeight, final int startWidth, final int targetHeight, final int targetWidth) { this.view = view; this.startHeight = startHeight; this.targetHeight = targetHeight; this.startWidth = startWidth; this.targetWidth = targetWidth; } @Override protected void applyTransformation(final float interpolatedTime, final Transformation t) { // Log.d(TAG, "applyTransformation: interpolatedTime: " + interpolatedTime); view.getLayoutParams().height = (int) (startHeight + (targetHeight - startHeight) * interpolatedTime); view.getLayoutParams().width = (int) (startWidth + (targetWidth - startWidth) * interpolatedTime); view.requestLayout(); } @Override public void initialize(final int width, final int height, final int parentWidth, final int parentHeight) { super.initialize(width, height, parentWidth, parentHeight); } @Override public boolean willChangeBounds() { return true; } } ================================================ FILE: app/src/main/java/awais/instagrabber/animations/RevealOutlineAnimation.java ================================================ package awais.instagrabber.animations; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.graphics.Outline; import android.graphics.Rect; import android.view.View; import android.view.ViewOutlineProvider; /** * A {@link ViewOutlineProvider} that has helper functions to create reveal animations. * This class should be extended so that subclasses can define the reveal shape as the * animation progresses from 0 to 1. */ public abstract class RevealOutlineAnimation extends ViewOutlineProvider { protected Rect mOutline; protected float mOutlineRadius; public RevealOutlineAnimation() { mOutline = new Rect(); } /** * Returns whether elevation should be removed for the duration of the reveal animation. */ abstract boolean shouldRemoveElevationDuringAnimation(); /** * Sets the progress, from 0 to 1, of the reveal animation. */ abstract void setProgress(float progress); public ValueAnimator createRevealAnimator(final View revealView, boolean isReversed) { ValueAnimator va = isReversed ? ValueAnimator.ofFloat(1f, 0f) : ValueAnimator.ofFloat(0f, 1f); final float elevation = revealView.getElevation(); va.addListener(new AnimatorListenerAdapter() { private boolean mIsClippedToOutline; private ViewOutlineProvider mOldOutlineProvider; public void onAnimationStart(Animator animation) { mIsClippedToOutline = revealView.getClipToOutline(); mOldOutlineProvider = revealView.getOutlineProvider(); revealView.setOutlineProvider(RevealOutlineAnimation.this); revealView.setClipToOutline(true); if (shouldRemoveElevationDuringAnimation()) { revealView.setTranslationZ(-elevation); } } public void onAnimationEnd(Animator animation) { revealView.setOutlineProvider(mOldOutlineProvider); revealView.setClipToOutline(mIsClippedToOutline); if (shouldRemoveElevationDuringAnimation()) { revealView.setTranslationZ(0); } } }); va.addUpdateListener(v -> { float progress = (Float) v.getAnimatedValue(); setProgress(progress); revealView.invalidateOutline(); }); return va; } @Override public void getOutline(View v, Outline outline) { outline.setRoundRect(mOutline, mOutlineRadius); } public float getRadius() { return mOutlineRadius; } public void getOutline(Rect out) { out.set(mOutline); } } ================================================ FILE: app/src/main/java/awais/instagrabber/animations/RoundedRectRevealOutlineProvider.java ================================================ /* * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package awais.instagrabber.animations; import android.graphics.Rect; /** * A {@link RevealOutlineAnimation} that provides an outline that interpolates between two radii * and two {@link Rect}s. *

* An example usage of this provider is an outline that starts out as a circle and ends * as a rounded rectangle. */ public class RoundedRectRevealOutlineProvider extends RevealOutlineAnimation { private final float mStartRadius; private final float mEndRadius; private final Rect mStartRect; private final Rect mEndRect; public RoundedRectRevealOutlineProvider(float startRadius, float endRadius, Rect startRect, Rect endRect) { mStartRadius = startRadius; mEndRadius = endRadius; mStartRect = startRect; mEndRect = endRect; } @Override public boolean shouldRemoveElevationDuringAnimation() { return false; } @Override public void setProgress(float progress) { mOutlineRadius = (1 - progress) * mStartRadius + progress * mEndRadius; mOutline.left = (int) ((1 - progress) * mStartRect.left + progress * mEndRect.left); mOutline.top = (int) ((1 - progress) * mStartRect.top + progress * mEndRect.top); mOutline.right = (int) ((1 - progress) * mStartRect.right + progress * mEndRect.right); mOutline.bottom = (int) ((1 - progress) * mStartRect.bottom + progress * mEndRect.bottom); } } ================================================ FILE: app/src/main/java/awais/instagrabber/animations/ScaleAnimation.java ================================================ package awais.instagrabber.animations; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.view.View; import android.view.animation.AccelerateDecelerateInterpolator; public class ScaleAnimation { private final View view; public ScaleAnimation(View view) { this.view = view; } public void start() { AnimatorSet set = new AnimatorSet(); ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 2.0f); ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 2.0f); set.setDuration(150); set.setInterpolator(new AccelerateDecelerateInterpolator()); set.playTogether(scaleY, scaleX); set.start(); } public void stop() { AnimatorSet set = new AnimatorSet(); ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1.0f); // scaleY.setDuration(250); // scaleY.setInterpolator(new DecelerateInterpolator()); ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1.0f); // scaleX.setDuration(250); // scaleX.setInterpolator(new DecelerateInterpolator()); set.setDuration(150); set.setInterpolator(new AccelerateDecelerateInterpolator()); set.playTogether(scaleY, scaleX); set.start(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/asyncs/DiscoverPostFetchService.java ================================================ package awais.instagrabber.asyncs; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; import awais.instagrabber.repositories.responses.WrappedMedia; import awais.instagrabber.webservices.DiscoverService; import awais.instagrabber.webservices.ServiceCallback; public class DiscoverPostFetchService implements PostFetcher.PostFetchService { private static final String TAG = "DiscoverPostFetchService"; private final DiscoverService discoverService; private final DiscoverService.TopicalExploreRequest topicalExploreRequest; private boolean moreAvailable = false; public DiscoverPostFetchService(final DiscoverService.TopicalExploreRequest topicalExploreRequest) { this.topicalExploreRequest = topicalExploreRequest; discoverService = DiscoverService.getInstance(); } @Override public void fetch(final FetchListener> fetchListener) { discoverService.topicalExplore(topicalExploreRequest, new ServiceCallback() { @Override public void onSuccess(final TopicalExploreFeedResponse result) { if (result == null) { onFailure(new RuntimeException("result is null")); return; } moreAvailable = result.getMoreAvailable(); topicalExploreRequest.setMaxId(result.getNextMaxId()); final List items = result.getItems(); final List posts; if (items == null) { posts = Collections.emptyList(); } else { posts = items.stream() .map(WrappedMedia::getMedia) .filter(Objects::nonNull) .collect(Collectors.toList()); } if (fetchListener != null) { fetchListener.onResult(posts); } } @Override public void onFailure(final Throwable t) { if (fetchListener != null) { fetchListener.onFailure(t); } } }); } @Override public void reset() { topicalExploreRequest.setMaxId(null); } @Override public boolean hasNextPage() { return moreAvailable; } } ================================================ FILE: app/src/main/java/awais/instagrabber/asyncs/FeedPostFetchService.java ================================================ package awais.instagrabber.asyncs; import java.util.ArrayList; import java.util.List; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.webservices.FeedService; import awais.instagrabber.webservices.ServiceCallback; import static awais.instagrabber.utils.Utils.settingsHelper; public class FeedPostFetchService implements PostFetcher.PostFetchService { private static final String TAG = "FeedPostFetchService"; private final FeedService feedService; private String nextCursor; private boolean hasNextPage; public FeedPostFetchService() { feedService = FeedService.getInstance(); } @Override public void fetch(final FetchListener> fetchListener) { final List feedModels = new ArrayList<>(); final String cookie = settingsHelper.getString(Constants.COOKIE); final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); feedModels.clear(); feedService.fetch(csrfToken, deviceUuid, nextCursor, new ServiceCallback() { @Override public void onSuccess(final PostsFetchResponse result) { if (result == null && feedModels.size() > 0) { fetchListener.onResult(feedModels); return; } else if (result == null) return; nextCursor = result.getNextCursor(); hasNextPage = result.getHasNextPage(); final List mediaResults = result.getFeedModels(); feedModels.addAll(mediaResults); if (fetchListener != null) { // if (feedModels.size() < 15 && hasNextPage) { // feedService.fetch(csrfToken, nextCursor, this); // } else { fetchListener.onResult(feedModels); // } } } @Override public void onFailure(final Throwable t) { if (fetchListener != null) { fetchListener.onFailure(t); } } }); } @Override public void reset() { nextCursor = null; } @Override public boolean hasNextPage() { return hasNextPage; } } ================================================ FILE: app/src/main/java/awais/instagrabber/asyncs/HashtagPostFetchService.java ================================================ package awais.instagrabber.asyncs; import java.util.List; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.repositories.responses.Hashtag; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.ServiceCallback; import awais.instagrabber.webservices.TagsService; import kotlinx.coroutines.Dispatchers; public class HashtagPostFetchService implements PostFetcher.PostFetchService { private final TagsService tagsService; private final GraphQLRepository graphQLRepository; private final Hashtag hashtagModel; private String nextMaxId; private boolean moreAvailable; private final boolean isLoggedIn; public HashtagPostFetchService(final Hashtag hashtagModel, final boolean isLoggedIn) { this.hashtagModel = hashtagModel; this.isLoggedIn = isLoggedIn; tagsService = isLoggedIn ? TagsService.getInstance() : null; graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); } @Override public void fetch(final FetchListener> fetchListener) { final ServiceCallback cb = new ServiceCallback() { @Override public void onSuccess(final PostsFetchResponse result) { if (result == null) return; nextMaxId = result.getNextCursor(); moreAvailable = result.getHasNextPage(); if (fetchListener != null) { fetchListener.onResult(result.getFeedModels()); } } @Override public void onFailure(final Throwable t) { // Log.e(TAG, "onFailure: ", t); if (fetchListener != null) { fetchListener.onFailure(t); } } }; if (isLoggedIn) tagsService.fetchPosts(hashtagModel.getName().toLowerCase(), nextMaxId, cb); else graphQLRepository.fetchHashtagPosts( hashtagModel.getName().toLowerCase(), nextMaxId, CoroutineUtilsKt.getContinuation((postsFetchResponse, throwable) -> { if (throwable != null) { cb.onFailure(throwable); return; } cb.onSuccess(postsFetchResponse); }, Dispatchers.getIO()) ); } @Override public void reset() { nextMaxId = null; } @Override public boolean hasNextPage() { return moreAvailable; } } ================================================ FILE: app/src/main/java/awais/instagrabber/asyncs/LocationPostFetchService.java ================================================ package awais.instagrabber.asyncs; import java.util.List; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.LocationService; import awais.instagrabber.webservices.ServiceCallback; import kotlinx.coroutines.Dispatchers; public class LocationPostFetchService implements PostFetcher.PostFetchService { private final LocationService locationService; private final GraphQLRepository graphQLRepository; private final Location locationModel; private String nextMaxId; private boolean moreAvailable; private final boolean isLoggedIn; public LocationPostFetchService(final Location locationModel, final boolean isLoggedIn) { this.locationModel = locationModel; this.isLoggedIn = isLoggedIn; locationService = isLoggedIn ? LocationService.getInstance() : null; graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); } @Override public void fetch(final FetchListener> fetchListener) { final ServiceCallback cb = new ServiceCallback() { @Override public void onSuccess(final PostsFetchResponse result) { if (result == null) return; nextMaxId = result.getNextCursor(); moreAvailable = result.getHasNextPage(); if (fetchListener != null) { fetchListener.onResult(result.getFeedModels()); } } @Override public void onFailure(final Throwable t) { // Log.e(TAG, "onFailure: ", t); if (fetchListener != null) { fetchListener.onFailure(t); } } }; if (isLoggedIn) locationService.fetchPosts(locationModel.getPk(), nextMaxId, cb); else graphQLRepository.fetchLocationPosts( locationModel.getPk(), nextMaxId, CoroutineUtilsKt.getContinuation((postsFetchResponse, throwable) -> { if (throwable != null) { cb.onFailure(throwable); return; } cb.onSuccess(postsFetchResponse); }, Dispatchers.getIO()) ); } @Override public void reset() { nextMaxId = null; } @Override public boolean hasNextPage() { return moreAvailable; } } ================================================ FILE: app/src/main/java/awais/instagrabber/asyncs/ProfilePostFetchService.java ================================================ package awais.instagrabber.asyncs; import java.util.List; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.ProfileRepository; import awais.instagrabber.webservices.ServiceCallback; import kotlin.coroutines.Continuation; import kotlinx.coroutines.Dispatchers; public class ProfilePostFetchService implements PostFetcher.PostFetchService { private static final String TAG = "ProfilePostFetchService"; private final ProfileRepository profileRepository; private final GraphQLRepository graphQLRepository; private final User profileModel; private final boolean isLoggedIn; private String nextMaxId; private boolean moreAvailable; public ProfilePostFetchService(final User profileModel, final boolean isLoggedIn) { this.profileModel = profileModel; this.isLoggedIn = isLoggedIn; graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); profileRepository = isLoggedIn ? ProfileRepository.Companion.getInstance() : null; } @Override public void fetch(final FetchListener> fetchListener) { final Continuation cb = CoroutineUtilsKt.getContinuation((result, t) -> { if (t != null) { if (fetchListener != null) { fetchListener.onFailure(t); } return; } if (result == null) return; nextMaxId = result.getNextCursor(); moreAvailable = result.getHasNextPage(); if (fetchListener != null) { fetchListener.onResult(result.getFeedModels()); } }, Dispatchers.getIO()); if (isLoggedIn) profileRepository.fetchPosts(profileModel.getPk(), nextMaxId, cb); else graphQLRepository.fetchProfilePosts( profileModel.getPk(), 30, nextMaxId, profileModel, cb ); } @Override public void reset() { nextMaxId = null; } @Override public boolean hasNextPage() { return moreAvailable; } } ================================================ FILE: app/src/main/java/awais/instagrabber/asyncs/SavedPostFetchService.java ================================================ package awais.instagrabber.asyncs; import java.util.List; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.models.enums.PostItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.ProfileRepository; import kotlin.coroutines.Continuation; import kotlinx.coroutines.Dispatchers; public class SavedPostFetchService implements PostFetcher.PostFetchService { private final ProfileRepository profileRepository; private final GraphQLRepository graphQLRepository; private final long profileId; private final PostItemType type; private final boolean isLoggedIn; private String nextMaxId; private final String collectionId; private boolean moreAvailable; public SavedPostFetchService(final long profileId, final PostItemType type, final boolean isLoggedIn, final String collectionId) { this.profileId = profileId; this.type = type; this.isLoggedIn = isLoggedIn; this.collectionId = collectionId; graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); profileRepository = isLoggedIn ? ProfileRepository.Companion.getInstance() : null; } @Override public void fetch(final FetchListener> fetchListener) { final Continuation callback = CoroutineUtilsKt.getContinuation((result, t) -> { if (t != null) { if (fetchListener != null) { fetchListener.onFailure(t); } return; } if (result == null) return; nextMaxId = result.getNextCursor(); moreAvailable = result.getHasNextPage(); if (fetchListener != null) { fetchListener.onResult(result.getFeedModels()); } }, Dispatchers.getIO()); switch (type) { case LIKED: profileRepository.fetchLiked(nextMaxId, callback); break; case TAGGED: if (isLoggedIn) profileRepository.fetchTagged(profileId, nextMaxId, callback); else graphQLRepository.fetchTaggedPosts( profileId, 30, nextMaxId, callback ); break; case COLLECTION: case SAVED: profileRepository.fetchSaved(nextMaxId, collectionId, callback); break; } } @Override public void reset() { nextMaxId = null; } @Override public boolean hasNextPage() { return moreAvailable; } } ================================================ FILE: app/src/main/java/awais/instagrabber/backup/BarinstaBackupAgent.kt ================================================ package awais.instagrabber.backup import android.app.backup.BackupAgent import android.app.backup.BackupDataInput import android.app.backup.BackupDataOutput import android.app.backup.FullBackupDataOutput import android.os.ParcelFileDescriptor import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.utils.Utils.settingsHelper class BarinstaBackupAgent : BackupAgent() { override fun onFullBackup(data: FullBackupDataOutput?) { super.onFullBackup(if (settingsHelper.getBoolean(PreferenceKeys.PREF_AUTO_BACKUP_ENABLED)) data else null) } // no key-value backups override fun onBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput?, newState: ParcelFileDescriptor?) {} override fun onRestore(data: BackupDataInput, appVersionCode: Int, newState: ParcelFileDescriptor) {} } ================================================ FILE: app/src/main/java/awais/instagrabber/broadcasts/DMRefreshBroadcastReceiver.java ================================================ package awais.instagrabber.broadcasts; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; public class DMRefreshBroadcastReceiver extends BroadcastReceiver { public static final String ACTION_REFRESH_DM = "action_refresh_dm"; private final OnDMRefreshCallback callback; public DMRefreshBroadcastReceiver(final OnDMRefreshCallback callback) { this.callback = callback; } @Override public void onReceive(final Context context, final Intent intent) { if (callback == null) return; final String action = intent.getAction(); if (action == null) return; if (!action.equals(ACTION_REFRESH_DM)) return; callback.onReceive(); } public interface OnDMRefreshCallback { void onReceive(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/BarinstaFragmentNavigator.kt ================================================ package awais.instagrabber.customviews import android.content.Context import androidx.fragment.app.FragmentManager import androidx.navigation.NavBackStackEntry import androidx.navigation.NavOptions import androidx.navigation.Navigator import androidx.navigation.fragment.FragmentNavigator import androidx.navigation.navOptions import awais.instagrabber.R import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.utils.Utils private val defaultNavOptions = navOptions { anim { enter = R.anim.slide_in_right exit = R.anim.slide_out_left popEnter = android.R.anim.slide_in_left popExit = android.R.anim.slide_out_right } } private val emptyNavOptions = navOptions {} /** * Needs to replace FragmentNavigator and replacing is done with name in annotation. * Navigation method will use defaults for fragments transitions animations. */ @Navigator.Name("fragment") class BarinstaFragmentNavigator( context: Context, fragmentManager: FragmentManager, containerId: Int ) : FragmentNavigator(context, fragmentManager, containerId) { override fun navigate( entries: List, navOptions: NavOptions?, navigatorExtras: Navigator.Extras? ) { val disableTransitions = Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_DISABLE_SCREEN_TRANSITIONS) if (disableTransitions) { super.navigate(entries, navOptions, navigatorExtras) return } // this will try to fill in empty animations with defaults when no shared element transitions // https://developer.android.com/guide/navigation/navigation-animate-transitions#shared-element val hasSharedElements = navigatorExtras != null && navigatorExtras is Extras val navOptions1 = if (hasSharedElements) navOptions else navOptions.fillEmptyAnimationsWithDefaults() super.navigate(entries, navOptions1, navigatorExtras) } private fun NavOptions?.fillEmptyAnimationsWithDefaults(): NavOptions = this?.copyNavOptionsWithDefaultAnimations() ?: defaultNavOptions private fun NavOptions.copyNavOptionsWithDefaultAnimations(): NavOptions = let { originalNavOptions -> navOptions { launchSingleTop = originalNavOptions.shouldLaunchSingleTop() popUpTo(originalNavOptions.popUpToId) { inclusive = originalNavOptions.isPopUpToInclusive() saveState = originalNavOptions.shouldPopUpToSaveState() } originalNavOptions.popUpToRoute?.let { popUpTo(it) { inclusive = originalNavOptions.isPopUpToInclusive() saveState = originalNavOptions.shouldPopUpToSaveState() } } restoreState = originalNavOptions.shouldRestoreState() anim { enter = if (originalNavOptions.enterAnim == emptyNavOptions.enterAnim) defaultNavOptions.enterAnim else originalNavOptions.enterAnim exit = if (originalNavOptions.exitAnim == emptyNavOptions.exitAnim) defaultNavOptions.exitAnim else originalNavOptions.exitAnim popEnter = if (originalNavOptions.popEnterAnim == emptyNavOptions.popEnterAnim) defaultNavOptions.popEnterAnim else originalNavOptions.popEnterAnim popExit = if (originalNavOptions.popExitAnim == emptyNavOptions.popExitAnim) defaultNavOptions.popExitAnim else originalNavOptions.popExitAnim } } } private companion object { private const val TAG = "FragmentNavigator" } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/BarinstaNavHostFragment.kt ================================================ package awais.instagrabber.customviews import androidx.navigation.NavHostController import androidx.navigation.fragment.NavHostFragment class BarinstaNavHostFragment : NavHostFragment() { override fun onCreateNavHostController(navHostController: NavHostController) { super.onCreateNavHostController(navHostController) navHostController.navigatorProvider.addNavigator( // this replaces FragmentNavigator BarinstaFragmentNavigator(requireContext(), childFragmentManager, id) ) } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/ChatMessageLayout.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.view.View; import android.widget.FrameLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import awais.instagrabber.R; public class ChatMessageLayout extends FrameLayout { private FrameLayout viewPartMain; private View viewPartInfo; private TypedArray a; private int viewPartInfoWidth; private int viewPartInfoHeight; // private boolean withGroupHeader = false; public ChatMessageLayout(@NonNull final Context context) { super(context); } public ChatMessageLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); a = context.obtainStyledAttributes(attrs, R.styleable.ChatMessageLayout, 0, 0); } public ChatMessageLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); a = context.obtainStyledAttributes(attrs, R.styleable.ChatMessageLayout, defStyleAttr, 0); } public ChatMessageLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); a = context.obtainStyledAttributes(attrs, R.styleable.ChatMessageLayout, defStyleAttr, defStyleRes); } // public void setWithGroupHeader(boolean withGroupHeader) { // this.withGroupHeader = withGroupHeader; // } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); try { viewPartMain = findViewById(a.getResourceId(R.styleable.ChatMessageLayout_viewPartMain, -1)); viewPartInfo = findViewById(a.getResourceId(R.styleable.ChatMessageLayout_viewPartInfo, -1)); } catch (Exception e) { e.printStackTrace(); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize; // heightSize = MeasureSpec.getSize(heightMeasureSpec); if (viewPartMain == null || viewPartInfo == null || widthSize <= 0) { return; } final View firstChild = viewPartMain.getChildAt(0); if (firstChild == null) return; final int firstChildId = firstChild.getId(); int availableWidth = widthSize - getPaddingLeft() - getPaddingRight(); // int availableHeight = heightSize - getPaddingTop() - getPaddingBottom(); final LayoutParams viewPartMainLayoutParams = (LayoutParams) viewPartMain.getLayoutParams(); final int viewPartMainWidth = viewPartMain.getMeasuredWidth() + viewPartMainLayoutParams.leftMargin + viewPartMainLayoutParams.rightMargin; final int viewPartMainHeight = viewPartMain.getMeasuredHeight() + viewPartMainLayoutParams.topMargin + viewPartMainLayoutParams.bottomMargin; final LayoutParams viewPartInfoLayoutParams = (LayoutParams) viewPartInfo.getLayoutParams(); viewPartInfoWidth = viewPartInfo.getMeasuredWidth() + viewPartInfoLayoutParams.leftMargin + viewPartInfoLayoutParams.rightMargin; viewPartInfoHeight = viewPartInfo.getMeasuredHeight() + viewPartInfoLayoutParams.topMargin + viewPartInfoLayoutParams.bottomMargin; widthSize = getPaddingLeft() + getPaddingRight(); heightSize = getPaddingTop() + getPaddingBottom(); if (firstChildId == R.id.media_container) { widthSize += viewPartMainWidth; heightSize += viewPartMainHeight; } else if (firstChildId == R.id.raven_media_container || firstChildId == R.id.profile_container || firstChildId == R.id.voice_media || firstChildId == R.id.story_container || firstChildId == R.id.media_share_container || firstChildId == R.id.link_container || firstChildId == R.id.ivAnimatedMessage || firstChildId == R.id.reel_share_container) { widthSize += viewPartMainWidth; heightSize += viewPartMainHeight + viewPartInfoHeight; } else { int viewPartMainLineCount = 1; float viewPartMainLastLineWidth = 0; final TextView textMessage; if (firstChild instanceof TextView) { textMessage = (TextView) firstChild; } else textMessage = null; if (textMessage != null) { viewPartMainLineCount = textMessage.getLineCount(); viewPartMainLastLineWidth = viewPartMainLineCount > 0 ? textMessage.getLayout().getLineWidth(viewPartMainLineCount - 1) : 0; // also include start left padding viewPartMainLastLineWidth += textMessage.getPaddingLeft(); } final float lastLineWithInfoWidth = viewPartMainLastLineWidth + viewPartInfoWidth; if (viewPartMainLineCount > 1 && lastLineWithInfoWidth <= viewPartMain.getMeasuredWidth()) { widthSize += viewPartMainWidth; heightSize += viewPartMainHeight; } else if (viewPartMainLineCount > 1 && (lastLineWithInfoWidth > availableWidth)) { widthSize += viewPartMainWidth; heightSize += viewPartMainHeight + viewPartInfoHeight; } else if (viewPartMainLineCount == 1 && (viewPartMainWidth + viewPartInfoWidth > availableWidth)) { widthSize += viewPartMain.getMeasuredWidth(); heightSize += viewPartMainHeight + viewPartInfoHeight; } else { heightSize += viewPartMainHeight; widthSize += viewPartMainWidth + viewPartInfoWidth; } // if (isInEditMode()) { // TextView wDebugView = (TextView) ((ViewGroup) this.getParent()).findViewWithTag("debug"); // wDebugView.setText(lastLineWithInfoWidth // + "\n" + availableWidth // + "\n" + viewPartMain.getMeasuredWidth() // + "\n" + (lastLineWithInfoWidth <= viewPartMain.getMeasuredWidth()) // + "\n" + (lastLineWithInfoWidth > availableWidth) // + "\n" + (viewPartMainWidth + viewPartInfoWidth > availableWidth)); // } } setMeasuredDimension(widthSize, heightSize); super.onMeasure(MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY)); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (viewPartMain == null || viewPartInfo == null) { return; } // if (withGroupHeader) { // viewPartMain.layout( // getPaddingLeft(), // getPaddingTop() - Utils.convertDpToPx(4), // viewPartMain.getWidth() + getPaddingLeft(), // viewPartMain.getHeight() + getPaddingTop()); // // } else { viewPartMain.layout( getPaddingLeft(), getPaddingTop(), viewPartMain.getWidth() + getPaddingLeft(), viewPartMain.getHeight() + getPaddingTop()); // } viewPartInfo.layout( right - left - viewPartInfoWidth - getPaddingRight(), bottom - top - getPaddingBottom() - viewPartInfoHeight, right - left - getPaddingRight(), bottom - top - getPaddingBottom()); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/CircularImageView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.content.res.Resources; import android.graphics.Color; import android.util.AttributeSet; import androidx.annotation.Nullable; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchy; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.GenericDraweeHierarchyInflater; import com.facebook.drawee.generic.RoundingParams; import com.facebook.drawee.view.SimpleDraweeView; import awais.instagrabber.R; public class CircularImageView extends SimpleDraweeView { public CircularImageView(Context context, GenericDraweeHierarchy hierarchy) { super(context); setHierarchy(hierarchy); } public CircularImageView(final Context context) { super(context); inflateHierarchy(context, null); } public CircularImageView(final Context context, final AttributeSet attrs) { super(context, attrs); inflateHierarchy(context, attrs); } public CircularImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); inflateHierarchy(context, attrs); } protected void inflateHierarchy(Context context, @Nullable AttributeSet attrs) { Resources resources = context.getResources(); final RoundingParams roundingParams = RoundingParams.asCircle(); GenericDraweeHierarchyBuilder builder = new GenericDraweeHierarchyBuilder(resources) .setRoundingParams(roundingParams) .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER); GenericDraweeHierarchyInflater.updateBuilder(builder, context, attrs); setAspectRatio(builder.getDesiredAspectRatio()); setHierarchy(builder.build()); setBackgroundResource(R.drawable.shape_oval_light); } /* types: 0 clear, 1 green (feed bestie / has story), 2 red (live) */ public void setStoriesBorder(final int type) { // private final int borderSize = 8; final int color = type == 2 ? Color.RED : Color.GREEN; RoundingParams roundingParams = getHierarchy().getRoundingParams(); if (roundingParams == null) { roundingParams = RoundingParams.asCircle().setRoundingMethod(RoundingParams.RoundingMethod.BITMAP_ONLY); } roundingParams.setBorder(color, type == 0 ? 0f : 5.0f); getHierarchy().setRoundingParams(roundingParams); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/CommentMentionClickSpan.java ================================================ package awais.instagrabber.customviews; import android.text.TextPaint; import android.text.style.ClickableSpan; import android.view.View; import androidx.annotation.NonNull; public final class CommentMentionClickSpan extends ClickableSpan { @Override public void onClick(@NonNull final View widget) { } @Override public void updateDrawState(@NonNull final TextPaint ds) { ds.setColor(ds.linkColor); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/DirectItemContextMenu.java ================================================ package awais.instagrabber.customviews; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.Resources; import android.graphics.Point; import android.graphics.Rect; import android.util.TypedValue; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.ImageView; import android.widget.PopupWindow; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.widget.AppCompatEditText; import androidx.appcompat.widget.AppCompatImageView; import androidx.appcompat.widget.AppCompatTextView; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.util.Pair; import java.util.List; import java.util.function.Function; import awais.instagrabber.R; import awais.instagrabber.animations.RoundedRectRevealOutlineProvider; import awais.instagrabber.customviews.emoji.Emoji; import awais.instagrabber.customviews.emoji.ReactionsManager; import awais.instagrabber.databinding.LayoutDirectItemOptionsBinding; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import static android.view.View.MeasureSpec.makeMeasureSpec; public class DirectItemContextMenu extends PopupWindow { private static final String TAG = DirectItemContextMenu.class.getSimpleName(); private static final int DO_NOT_UPDATE_FLAG = -1; private static final int DURATION = 300; private final Context context; private final boolean showReactions; private final ReactionsManager reactionsManager; private final int emojiSize; private final int emojiMargin; private final int emojiMarginHalf; private final Rect startRect = new Rect(); private final Rect endRect = new Rect(); private final TimeInterpolator revealInterpolator = new AccelerateDecelerateInterpolator(); private final AnimatorListenerAdapter exitAnimationListener; private final TypedValue selectableItemBackgroundBorderless; private final TypedValue selectableItemBackground; private final int dividerHeight; private final int optionHeight; private final int optionPadding; private final int addAdjust; private final boolean hasOptions; private final List options; private final int widthWithoutReactions; private AnimatorSet openCloseAnimator; private Point location; private Point point; private OnReactionClickListener onReactionClickListener; private OnOptionSelectListener onOptionSelectListener; private OnAddReactionClickListener onAddReactionListener; public DirectItemContextMenu(@NonNull final Context context, final boolean showReactions, final List options) { super(context); this.context = context; this.showReactions = showReactions; this.options = options; if (!showReactions && (options == null || options.isEmpty())) { throw new IllegalArgumentException("showReactions is set false and options are empty"); } reactionsManager = ReactionsManager.getInstance(context); final Resources resources = context.getResources(); emojiSize = resources.getDimensionPixelSize(R.dimen.reaction_picker_emoji_size); emojiMargin = resources.getDimensionPixelSize(R.dimen.reaction_picker_emoji_margin); emojiMarginHalf = emojiMargin / 2; addAdjust = resources.getDimensionPixelSize(R.dimen.reaction_picker_add_padding_adjustment); dividerHeight = resources.getDimensionPixelSize(R.dimen.horizontal_divider_height); optionHeight = resources.getDimensionPixelSize(R.dimen.reaction_picker_option_height); optionPadding = resources.getDimensionPixelSize(R.dimen.dm_message_card_radius); widthWithoutReactions = resources.getDimensionPixelSize(R.dimen.dm_item_context_min_width); exitAnimationListener = new AnimatorListenerAdapter() { @Override public void onAnimationEnd(final Animator animation) { openCloseAnimator = null; point = null; getContentView().post(DirectItemContextMenu.super::dismiss); } }; selectableItemBackgroundBorderless = new TypedValue(); context.getTheme().resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, selectableItemBackgroundBorderless, true); selectableItemBackground = new TypedValue(); context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, selectableItemBackground, true); hasOptions = options != null && !options.isEmpty(); } public void show(@NonNull View rootView, @NonNull final Point location) { final View content = createContentView(); content.measure(makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); setup(content); // rootView.getParent().requestDisallowInterceptTouchEvent(true); // final Point correctedLocation = new Point(location.x, location.y - emojiSize * 2); this.location = location; showAtLocation(rootView, Gravity.TOP | Gravity.START, location.x, location.y); // fixPopupLocation(popupWindow, correctedLocation); animateOpen(); } private void setup(final View content) { setContentView(content); setWindowLayoutMode(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); setFocusable(true); setOutsideTouchable(true); setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); setBackgroundDrawable(null); } public void setOnOptionSelectListener(final OnOptionSelectListener onOptionSelectListener) { this.onOptionSelectListener = onOptionSelectListener; } public void setOnReactionClickListener(final OnReactionClickListener onReactionClickListener) { this.onReactionClickListener = onReactionClickListener; } public void setOnAddReactionListener(final OnAddReactionClickListener onAddReactionListener) { this.onAddReactionListener = onAddReactionListener; } private void animateOpen() { final View contentView = getContentView(); contentView.setVisibility(View.INVISIBLE); contentView.post(() -> { final AnimatorSet openAnim = new AnimatorSet(); // Rectangular reveal. final ValueAnimator revealAnim = createOpenCloseOutlineProvider().createRevealAnimator(contentView, false); revealAnim.setDuration(DURATION); revealAnim.setInterpolator(revealInterpolator); ValueAnimator fadeIn = ValueAnimator.ofFloat(0, 1); fadeIn.setDuration(DURATION); fadeIn.setInterpolator(revealInterpolator); fadeIn.addUpdateListener(anim -> { float alpha = (float) anim.getAnimatedValue(); contentView.setAlpha(revealAnim.isStarted() ? alpha : 0); }); openAnim.play(fadeIn); openAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { contentView.setAlpha(1f); openCloseAnimator = null; } }); openCloseAnimator = openAnim; openAnim.playSequentially(revealAnim); contentView.setVisibility(View.VISIBLE); openAnim.start(); }); } protected void animateClose() { endRect.setEmpty(); if (openCloseAnimator != null) { openCloseAnimator.cancel(); } final View contentView = getContentView(); final AnimatorSet closeAnim = new AnimatorSet(); // Rectangular reveal (reversed). final ValueAnimator revealAnim = createOpenCloseOutlineProvider().createRevealAnimator(contentView, true); revealAnim.setDuration(DURATION); revealAnim.setInterpolator(revealInterpolator); closeAnim.play(revealAnim); ValueAnimator fadeOut = ValueAnimator.ofFloat(contentView.getAlpha(), 0); fadeOut.setDuration(DURATION); fadeOut.setInterpolator(revealInterpolator); fadeOut.addUpdateListener(anim -> { float alpha = (float) anim.getAnimatedValue(); contentView.setAlpha(revealAnim.isStarted() ? alpha : contentView.getAlpha()); }); closeAnim.playTogether(fadeOut); closeAnim.addListener(exitAnimationListener); openCloseAnimator = closeAnim; closeAnim.start(); } private RoundedRectRevealOutlineProvider createOpenCloseOutlineProvider() { final View contentView = getContentView(); final int radius = context.getResources().getDimensionPixelSize(R.dimen.dm_message_card_radius_small); // Log.d(TAG, "createOpenCloseOutlineProvider: " + locationOnScreen(contentView) + " " + contentView.getMeasuredWidth() + " " + contentView // .getMeasuredHeight()); if (point == null) { point = locationOnScreen(contentView); } final int left = location.x - point.x; final int top = location.y - point.y; startRect.set(left, top, left, top); endRect.set(0, 0, contentView.getMeasuredWidth(), contentView.getMeasuredHeight()); return new RoundedRectRevealOutlineProvider(radius, radius, startRect, endRect); } public void dismiss() { animateClose(); } private View createContentView() { final LayoutInflater layoutInflater = LayoutInflater.from(context); final LayoutDirectItemOptionsBinding binding = LayoutDirectItemOptionsBinding.inflate(layoutInflater, null, false); Pair firstLastEmojiView = null; if (showReactions) { firstLastEmojiView = addReactions(layoutInflater, binding.container); } if (hasOptions) { View divider = null; if (showReactions) { if (firstLastEmojiView == null) { throw new IllegalStateException("firstLastEmojiView is null even though reactions were added"); } // add divider if reactions were added divider = addDivider(binding.container, firstLastEmojiView.first.getId(), firstLastEmojiView.first.getId(), firstLastEmojiView.second.getId()); ((ConstraintLayout.LayoutParams) firstLastEmojiView.first.getLayoutParams()).bottomToTop = divider.getId(); } addOptions(layoutInflater, binding.container, divider); } return binding.getRoot(); } private Pair addReactions(final LayoutInflater layoutInflater, final ConstraintLayout container) { final List reactions = reactionsManager.getReactions(); AppCompatImageView prevSquareImageView = null; View firstImageView = null; View lastImageView = null; for (int i = 0; i < reactions.size(); i++) { final Emoji reaction = reactions.get(i); final AppCompatImageView imageView = getEmojiImageView(); final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) imageView.getLayoutParams(); if (i == 0 && !hasOptions) { // only connect bottom to parent bottom if there are no options layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID; } if (i == 0) { layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; firstImageView = imageView; layoutParams.setMargins(emojiMargin, emojiMargin, emojiMarginHalf, emojiMargin); } else { layoutParams.startToEnd = prevSquareImageView.getId(); final ConstraintLayout.LayoutParams prevViewLayoutParams = (ConstraintLayout.LayoutParams) prevSquareImageView.getLayoutParams(); prevViewLayoutParams.endToStart = imageView.getId(); // always connect the other image view's top and bottom to the first image view top and bottom layoutParams.topToTop = firstImageView.getId(); layoutParams.bottomToBottom = firstImageView.getId(); layoutParams.setMargins(emojiMarginHalf, emojiMargin, emojiMarginHalf, emojiMargin); } imageView.setImageDrawable(reaction.getDrawable()); imageView.setOnClickListener(view -> { if (onReactionClickListener != null) { onReactionClickListener.onClick(reaction); } dismiss(); }); container.addView(imageView); prevSquareImageView = imageView; } // add the + icon if (prevSquareImageView != null) { final AppCompatImageView imageView = getEmojiImageView(); final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) imageView.getLayoutParams(); layoutParams.topToTop = firstImageView.getId(); layoutParams.bottomToBottom = firstImageView.getId(); layoutParams.startToEnd = prevSquareImageView.getId(); final ConstraintLayout.LayoutParams prevViewLayoutParams = (ConstraintLayout.LayoutParams) prevSquareImageView.getLayoutParams(); prevViewLayoutParams.endToStart = imageView.getId(); layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; layoutParams.setMargins(emojiMarginHalf - addAdjust, emojiMargin - addAdjust, emojiMargin - addAdjust, emojiMargin - addAdjust); imageView.setImageResource(R.drawable.ic_add); imageView.setOnClickListener(view -> { if (onAddReactionListener != null) { onAddReactionListener.onAdd(); } dismiss(); }); lastImageView = imageView; container.addView(imageView); } return new Pair<>(firstImageView, lastImageView); } @NonNull private AppCompatImageView getEmojiImageView() { final AppCompatImageView imageView = new AppCompatImageView(context); final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(emojiSize, emojiSize); imageView.setBackgroundResource(selectableItemBackgroundBorderless.resourceId); imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); imageView.setId(SquareImageView.generateViewId()); imageView.setLayoutParams(layoutParams); return imageView; } private void addOptions(final LayoutInflater layoutInflater, final ConstraintLayout container, @Nullable final View divider) { View prevOptionView = null; if (!showReactions) { container.getLayoutParams().width = widthWithoutReactions; } for (int i = 0; i < options.size(); i++) { final MenuItem menuItem = options.get(i); final AppCompatTextView textView = getTextView(); final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) textView.getLayoutParams(); layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; if (i == 0) { if (divider != null) { layoutParams.topToBottom = divider.getId(); ((ConstraintLayout.LayoutParams) divider.getLayoutParams()).bottomToTop = textView.getId(); } else { // if divider is null mean reactions were not added, so connect top to top of parent layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; layoutParams.topMargin = emojiMargin; // material design spec (https://material.io/components/menus#specs) } } else { layoutParams.topToBottom = prevOptionView.getId(); final ConstraintLayout.LayoutParams prevLayoutParams = (ConstraintLayout.LayoutParams) prevOptionView.getLayoutParams(); prevLayoutParams.bottomToTop = textView.getId(); } if (i == options.size() - 1) { layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID; layoutParams.bottomMargin = emojiMargin; // material design spec (https://material.io/components/menus#specs) } textView.setText(context.getString(menuItem.getTitleRes())); textView.setOnClickListener(v -> { if (onOptionSelectListener != null) { onOptionSelectListener.onSelect(menuItem.getItemId(), menuItem.getCallback()); } dismiss(); }); container.addView(textView); prevOptionView = textView; } } private AppCompatTextView getTextView() { final AppCompatTextView textView = new AppCompatTextView(context); textView.setId(AppCompatEditText.generateViewId()); textView.setBackgroundResource(selectableItemBackground.resourceId); textView.setGravity(Gravity.CENTER_VERTICAL); textView.setPaddingRelative(optionPadding, 0, optionPadding, 0); textView.setTextAppearance(context, R.style.TextAppearance_MaterialComponents_Body1); final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, optionHeight); textView.setLayoutParams(layoutParams); return textView; } private View addDivider(final ConstraintLayout container, final int topViewId, final int startViewId, final int endViewId) { final View dividerView = new View(context); dividerView.setId(View.generateViewId()); dividerView.setBackgroundResource(R.drawable.pref_list_divider_material); final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, dividerHeight); layoutParams.topToBottom = topViewId; layoutParams.startToStart = startViewId; layoutParams.endToEnd = endViewId; dividerView.setLayoutParams(layoutParams); container.addView(dividerView); return dividerView; } @NonNull private Point locationOnScreen(@NonNull final View view) { final int[] location = new int[2]; view.getLocationOnScreen(location); return new Point(location[0], location[1]); } public static class MenuItem { @IdRes private final int itemId; @StringRes private final int titleRes; /** * Callback function */ private final Function callback; public MenuItem(@IdRes final int itemId, @StringRes final int titleRes) { this(itemId, titleRes, null); } public MenuItem(@IdRes final int itemId, @StringRes final int titleRes, @Nullable final Function callback) { this.itemId = itemId; this.titleRes = titleRes; this.callback = callback; } public int getItemId() { return itemId; } public int getTitleRes() { return titleRes; } public Function getCallback() { return callback; } } public interface OnOptionSelectListener { void onSelect(int itemId, @Nullable Function callback); } public interface OnReactionClickListener { void onClick(Emoji emoji); } public interface OnAddReactionClickListener { void onAdd(); } // @NonNull // private Rect getGlobalVisibleRect(@NonNull final View view) { // final Rect rect = new Rect(); // view.getGlobalVisibleRect(rect); // return rect; // } // private void fixPopupLocation(@NonNull final PopupWindow popupWindow, @NonNull final Point desiredLocation) { // popupWindow.getContentView().post(() -> { // final Point actualLocation = locationOnScreen(popupWindow.getContentView()); // // if (!(actualLocation.x == desiredLocation.x && actualLocation.y == desiredLocation.y)) { // final int differenceX = actualLocation.x - desiredLocation.x; // final int differenceY = actualLocation.y - desiredLocation.y; // // final int fixedOffsetX; // final int fixedOffsetY; // // if (actualLocation.x > desiredLocation.x) { // fixedOffsetX = desiredLocation.x - differenceX; // } else { // fixedOffsetX = desiredLocation.x + differenceX; // } // // if (actualLocation.y > desiredLocation.y) { // fixedOffsetY = desiredLocation.y - differenceY; // } else { // fixedOffsetY = desiredLocation.y + differenceY; // } // // popupWindow.update(fixedOffsetX, fixedOffsetY, DO_NOT_UPDATE_FLAG, DO_NOT_UPDATE_FLAG); // } // }); // } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/DirectItemFrameLayout.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.os.Handler; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; public class DirectItemFrameLayout extends FrameLayout { private static final String TAG = DirectItemFrameLayout.class.getSimpleName(); private boolean longPressed = false; private float touchX; private float touchY; private OnItemLongClickListener onItemLongClickListener; private int touchSlop; private final Handler handler = new Handler(); private final Runnable longPressRunnable = () -> { longPressed = true; if (onItemLongClickListener != null) { onItemLongClickListener.onLongClick(this, touchX, touchY); } }; private final Runnable longPressStartRunnable = () -> { if (onItemLongClickListener != null) { onItemLongClickListener.onLongClickStart(this); } }; public DirectItemFrameLayout(@NonNull final Context context) { super(context); init(context); } public DirectItemFrameLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); init(context); } public DirectItemFrameLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); init(context); } public DirectItemFrameLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(context); } private void init(final Context context) { ViewConfiguration vc = ViewConfiguration.get(context); touchSlop = vc.getScaledTouchSlop(); } public void setOnItemLongClickListener(final OnItemLongClickListener onItemLongClickListener) { this.onItemLongClickListener = onItemLongClickListener; } @Override public boolean dispatchTouchEvent(final MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: longPressed = false; handler.postDelayed(longPressRunnable, ViewConfiguration.getLongPressTimeout()); handler.postDelayed(longPressStartRunnable, ViewConfiguration.getTapTimeout()); touchX = ev.getRawX(); touchY = ev.getRawY(); break; case MotionEvent.ACTION_MOVE: final float diffX = touchX - ev.getRawX(); final float diffXAbs = Math.abs(diffX); final boolean isMoved = diffXAbs > touchSlop || Math.abs(touchY - ev.getRawY()) > touchSlop; if (longPressed || isMoved) { handler.removeCallbacks(longPressStartRunnable); handler.removeCallbacks(longPressRunnable); if (!longPressed) { if (onItemLongClickListener != null) { onItemLongClickListener.onLongClickCancel(this); } } // if (diffXAbs > touchSlop) { // setTranslationX(-diffX); // } } break; case MotionEvent.ACTION_UP: handler.removeCallbacks(longPressRunnable); handler.removeCallbacks(longPressStartRunnable); if (longPressed) { return true; } if (onItemLongClickListener != null) { onItemLongClickListener.onLongClickCancel(this); } break; case MotionEvent.ACTION_CANCEL: handler.removeCallbacks(longPressRunnable); handler.removeCallbacks(longPressStartRunnable); if (onItemLongClickListener != null) { onItemLongClickListener.onLongClickCancel(this); } break; } final boolean dispatchTouchEvent = super.dispatchTouchEvent(ev); if (ev.getAction() == MotionEvent.ACTION_DOWN && !dispatchTouchEvent) { return true; } return dispatchTouchEvent; } public interface OnItemLongClickListener { void onLongClickStart(View view); void onLongClickCancel(View view); void onLongClick(View view, float x, float y); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/FixedImageView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.util.AttributeSet; import androidx.appcompat.widget.AppCompatImageView; public final class FixedImageView extends AppCompatImageView { public FixedImageView(final Context context) { super(context); } public FixedImageView(final Context context, final AttributeSet attrs) { super(context, attrs); } public FixedImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onMeasure(final int wMeasure, final int hMeasure) { super.onMeasure(wMeasure, wMeasure); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/FormattedNumberTextView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.util.AttributeSet; import android.util.Log; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatTextView; import androidx.transition.ChangeBounds; import androidx.transition.Transition; import androidx.transition.TransitionManager; import androidx.transition.TransitionSet; import java.time.Duration; import awais.instagrabber.customviews.helpers.ChangeText; import awais.instagrabber.utils.NumberUtils; public class FormattedNumberTextView extends AppCompatTextView { private static final String TAG = FormattedNumberTextView.class.getSimpleName(); private static final Transition TRANSITION; private long number = Long.MIN_VALUE; private boolean showAbbreviation = true; private boolean animateChanges = false; private boolean toggleOnClick = true; private boolean autoToggleToAbbreviation = true; private long autoToggleTimeoutMs = Duration.ofSeconds(2).toMillis(); private boolean initDone = false; static { final TransitionSet transitionSet = new TransitionSet(); final ChangeText changeText = new ChangeText().setChangeBehavior(ChangeText.CHANGE_BEHAVIOR_OUT_IN); transitionSet.addTransition(changeText).addTransition(new ChangeBounds()); TRANSITION = transitionSet; } public FormattedNumberTextView(@NonNull final Context context) { super(context); init(); } public FormattedNumberTextView(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); init(); } public FormattedNumberTextView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { if (initDone) return; setupClickToggle(); initDone = true; } private void setupClickToggle() { setOnClickListener(null); } private OnClickListener getWrappedClickListener(@Nullable final OnClickListener l) { if (!toggleOnClick) { return l; } return v -> { toggleAbbreviation(); if (l != null) { l.onClick(this); } }; } public void setNumber(final long number) { if (this.number == number) return; this.number = number; format(); } public void clearNumber() { if (number == Long.MIN_VALUE) return; number = Long.MIN_VALUE; format(); } public void setShowAbbreviation(final boolean showAbbreviation) { if (this.showAbbreviation && showAbbreviation) return; this.showAbbreviation = showAbbreviation; format(); } public boolean isShowAbbreviation() { return showAbbreviation; } private void toggleAbbreviation() { if (number == Long.MIN_VALUE) return; setShowAbbreviation(!showAbbreviation); } public void setToggleOnClick(final boolean toggleOnClick) { this.toggleOnClick = toggleOnClick; } public boolean isToggleOnClick() { return toggleOnClick; } public void setAutoToggleToAbbreviation(final boolean autoToggleToAbbreviation) { this.autoToggleToAbbreviation = autoToggleToAbbreviation; } public boolean isAutoToggleToAbbreviation() { return autoToggleToAbbreviation; } public void setAutoToggleTimeoutMs(final long autoToggleTimeoutMs) { this.autoToggleTimeoutMs = autoToggleTimeoutMs; } public long getAutoToggleTimeoutMs() { return autoToggleTimeoutMs; } public void setAnimateChanges(final boolean animateChanges) { this.animateChanges = animateChanges; } public boolean isAnimateChanges() { return animateChanges; } @Override public void setOnClickListener(@Nullable final OnClickListener l) { super.setOnClickListener(getWrappedClickListener(l)); } private void format() { post(() -> { if (animateChanges) { try { TransitionManager.beginDelayedTransition((ViewGroup) getParent(), TRANSITION); } catch (Exception e) { Log.e(TAG, "format: ", e); } } if (number == Long.MIN_VALUE) { setText(null); return; } if (showAbbreviation) { setText(NumberUtils.abbreviate(number, null)); return; } setText(String.valueOf(number)); if (autoToggleToAbbreviation) { getHandler().postDelayed(() -> setShowAbbreviation(true), autoToggleTimeoutMs); } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/InsetsAnimationLinearLayout.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.view.WindowInsetsAnimation; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.view.NestedScrollingParent3; import androidx.core.view.NestedScrollingParentHelper; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import java.util.Arrays; import awais.instagrabber.customviews.helpers.SimpleImeAnimationController; import awais.instagrabber.utils.ViewUtils; import static androidx.core.view.ViewCompat.TYPE_TOUCH; public final class InsetsAnimationLinearLayout extends LinearLayout implements NestedScrollingParent3 { private final NestedScrollingParentHelper nestedScrollingParentHelper = new NestedScrollingParentHelper(this); private final SimpleImeAnimationController imeAnimController = new SimpleImeAnimationController(); private final int[] tempIntArray2 = new int[2]; private final int[] startViewLocation = new int[2]; private View currentNestedScrollingChild; private int dropNextY; private boolean scrollImeOffScreenWhenVisible = true; private boolean scrollImeOnScreenWhenNotVisible = true; private boolean scrollImeOffScreenWhenVisibleOnFling = false; private boolean scrollImeOnScreenWhenNotVisibleOnFling = false; public InsetsAnimationLinearLayout(final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public InsetsAnimationLinearLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public final boolean getScrollImeOffScreenWhenVisible() { return scrollImeOffScreenWhenVisible; } public final void setScrollImeOffScreenWhenVisible(boolean scrollImeOffScreenWhenVisible) { this.scrollImeOffScreenWhenVisible = scrollImeOffScreenWhenVisible; } public final boolean getScrollImeOnScreenWhenNotVisible() { return scrollImeOnScreenWhenNotVisible; } public final void setScrollImeOnScreenWhenNotVisible(boolean scrollImeOnScreenWhenNotVisible) { this.scrollImeOnScreenWhenNotVisible = scrollImeOnScreenWhenNotVisible; } public boolean getScrollImeOffScreenWhenVisibleOnFling() { return scrollImeOffScreenWhenVisibleOnFling; } public void setScrollImeOffScreenWhenVisibleOnFling(final boolean scrollImeOffScreenWhenVisibleOnFling) { this.scrollImeOffScreenWhenVisibleOnFling = scrollImeOffScreenWhenVisibleOnFling; } public boolean getScrollImeOnScreenWhenNotVisibleOnFling() { return scrollImeOnScreenWhenNotVisibleOnFling; } public void setScrollImeOnScreenWhenNotVisibleOnFling(final boolean scrollImeOnScreenWhenNotVisibleOnFling) { this.scrollImeOnScreenWhenNotVisibleOnFling = scrollImeOnScreenWhenNotVisibleOnFling; } public SimpleImeAnimationController getImeAnimController() { return imeAnimController; } @Override public boolean onStartNestedScroll(@NonNull final View child, @NonNull final View target, final int axes, final int type) { return (axes & SCROLL_AXIS_VERTICAL) != 0 && type == TYPE_TOUCH; } @Override public void onNestedScrollAccepted(@NonNull final View child, @NonNull final View target, final int axes, final int type) { nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type); currentNestedScrollingChild = child; } @Override public void onNestedPreScroll(@NonNull final View target, final int dx, final int dy, @NonNull final int[] consumed, final int type) { if (imeAnimController.isInsetAnimationRequestPending()) { consumed[0] = dx; consumed[1] = dy; } else { int deltaY = dy; if (dropNextY != 0) { consumed[1] = dropNextY; deltaY = dy - dropNextY; dropNextY = 0; } if (deltaY < 0) { if (imeAnimController.isInsetAnimationInProgress()) { consumed[1] -= imeAnimController.insetBy(-deltaY); } else if (scrollImeOffScreenWhenVisible && !imeAnimController.isInsetAnimationRequestPending()) { WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); if (rootWindowInsets != null) { if (rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { startControlRequest(); consumed[1] = deltaY; } } } } } } @Override public void onNestedScroll(@NonNull final View target, final int dxConsumed, final int dyConsumed, final int dxUnconsumed, final int dyUnconsumed, final int type, @NonNull final int[] consumed) { if (dyUnconsumed > 0) { if (imeAnimController.isInsetAnimationInProgress()) { consumed[1] = -imeAnimController.insetBy(-dyUnconsumed); } else if (scrollImeOnScreenWhenNotVisible && !imeAnimController.isInsetAnimationRequestPending()) { WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); if (rootWindowInsets != null) { if (!rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { startControlRequest(); consumed[1] = dyUnconsumed; } } } } } @Override public boolean onNestedFling(@NonNull final View target, final float velocityX, final float velocityY, final boolean consumed) { if (imeAnimController.isInsetAnimationInProgress()) { imeAnimController.animateToFinish(velocityY); return true; } else { boolean imeVisible = false; final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(this); if (rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime())) { imeVisible = true; } if (velocityY > 0 && scrollImeOnScreenWhenNotVisibleOnFling && !imeVisible) { imeAnimController.startAndFling(this, velocityY); return true; } else if (velocityY < 0 && scrollImeOffScreenWhenVisibleOnFling && imeVisible) { imeAnimController.startAndFling(this, velocityY); return true; } else { return false; } } } @Override public void onStopNestedScroll(@NonNull final View target, final int type) { nestedScrollingParentHelper.onStopNestedScroll(target, type); if (imeAnimController.isInsetAnimationInProgress() && !imeAnimController.isInsetAnimationFinishing()) { imeAnimController.animateToFinish(null); } reset(); } @Override public void dispatchWindowInsetsAnimationPrepare(@NonNull final WindowInsetsAnimation animation) { super.dispatchWindowInsetsAnimationPrepare(animation); ViewUtils.suppressLayoutCompat(this, false); } private void startControlRequest() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { return; } ViewUtils.suppressLayoutCompat(this, true); if (currentNestedScrollingChild != null) { currentNestedScrollingChild.getLocationInWindow(startViewLocation); } imeAnimController.startControlRequest(this, windowInsetsAnimationControllerCompat -> onControllerReady()); } private void onControllerReady() { if (currentNestedScrollingChild != null) { imeAnimController.insetBy(0); int[] location = tempIntArray2; currentNestedScrollingChild.getLocationInWindow(location); dropNextY = location[1] - startViewLocation[1]; } } private void reset() { dropNextY = 0; Arrays.fill(startViewLocation, 0); ViewUtils.suppressLayoutCompat(this, false); } @Override public void onNestedScrollAccepted(@NonNull final View child, @NonNull final View target, final int axes) { onNestedScrollAccepted(child, target, axes, TYPE_TOUCH); } @Override public void onNestedScroll(@NonNull final View target, final int dxConsumed, final int dyConsumed, final int dxUnconsumed, final int dyUnconsumed, final int type) { onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, tempIntArray2); } @Override public void onStopNestedScroll(@NonNull final View target) { onStopNestedScroll(target, TYPE_TOUCH); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingCoordinatorLayout.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.util.AttributeSet; import android.view.WindowInsets; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; public class InsetsNotifyingCoordinatorLayout extends CoordinatorLayout { public InsetsNotifyingCoordinatorLayout(@NonNull final Context context) { super(context); } public InsetsNotifyingCoordinatorLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public InsetsNotifyingCoordinatorLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public WindowInsets onApplyWindowInsets(WindowInsets insets) { int childCount = getChildCount(); for (int index = 0; index < childCount; index++) { getChildAt(index).dispatchApplyWindowInsets(insets); } return insets; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/InsetsNotifyingLinearLayout.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.util.AttributeSet; import android.view.WindowInsets; import android.widget.LinearLayout; import androidx.annotation.Nullable; public class InsetsNotifyingLinearLayout extends LinearLayout { public InsetsNotifyingLinearLayout(final Context context) { super(context); } public InsetsNotifyingLinearLayout(final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public InsetsNotifyingLinearLayout(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } public InsetsNotifyingLinearLayout(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override public WindowInsets onApplyWindowInsets(WindowInsets insets) { int childCount = getChildCount(); for (int index = 0; index < childCount; index++) { getChildAt(index).dispatchApplyWindowInsets(insets); } return insets; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/KeyNotifyingEmojiEditText.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.util.AttributeSet; import android.view.KeyEvent; import androidx.emoji.widget.EmojiEditText; public class KeyNotifyingEmojiEditText extends EmojiEditText { private OnKeyEventListener onKeyEventListener; public KeyNotifyingEmojiEditText(final Context context) { super(context); } public KeyNotifyingEmojiEditText(final Context context, final AttributeSet attrs) { super(context, attrs); } public KeyNotifyingEmojiEditText(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } public KeyNotifyingEmojiEditText(final Context context, final AttributeSet attrs, final int defStyleAttr, final int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override public boolean onKeyPreIme(final int keyCode, final KeyEvent event) { if (onKeyEventListener != null) { final boolean listenerResult = onKeyEventListener.onKeyPreIme(keyCode, event); if (listenerResult) return true; } return super.onKeyPreIme(keyCode, event); } public void setOnKeyEventListener(final OnKeyEventListener onKeyEventListener) { this.onKeyEventListener = onKeyEventListener; } public interface OnKeyEventListener { boolean onKeyPreIme(int keyCode, KeyEvent keyEvent); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/MouseDrawer.java ================================================ package awais.instagrabber.customviews; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.view.GravityCompat; import androidx.core.view.ViewCompat; import androidx.customview.view.AbsSavedState; import androidx.customview.widget.ViewDragHelper; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; import awais.instagrabber.BuildConfig; // exactly same as the LayoutDrawer with some edits @SuppressLint("RtlHardcoded") public class MouseDrawer extends ViewGroup { @IntDef({ViewDragHelper.STATE_IDLE, ViewDragHelper.STATE_DRAGGING, ViewDragHelper.STATE_SETTLING}) @Retention(RetentionPolicy.SOURCE) private @interface State {} @IntDef(value = {Gravity.NO_GRAVITY, Gravity.LEFT, Gravity.RIGHT, GravityCompat.START, GravityCompat.END}, flag = true) @Retention(RetentionPolicy.SOURCE) public @interface EdgeGravity {} //////////////////////////////////////////////////////////////////////////////////// private static final boolean CHILDREN_DISALLOW_INTERCEPT = true; //////////////////////////////////////////////////////////////////////////////////// private final ArrayList mNonDrawerViews = new ArrayList<>(); private final ViewDragHelper mLeftDragger, mRightDragger; private boolean mInLayout, mFirstLayout = true; private float mDrawerElevation, mInitialMotionX, mInitialMotionY; private int mDrawerState; private List mListeners; private Matrix mChildInvertedMatrix; private Rect mChildHitRect; public interface DrawerListener { void onDrawerSlide(final View drawerView, @EdgeGravity final int gravity, final float slideOffset); default void onDrawerOpened(final View drawerView, @EdgeGravity final int gravity) {} default void onDrawerClosed(final View drawerView, @EdgeGravity final int gravity) {} default void onDrawerStateChanged() {} } public MouseDrawer(@NonNull final Context context) { this(context, null); } public MouseDrawer(@NonNull final Context context, @Nullable final AttributeSet attrs) { this(context, attrs, 0); } public MouseDrawer(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyle) { super(context, attrs, defStyle); setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); final float density = getResources().getDisplayMetrics().density; this.mDrawerElevation = 10 * density; final float touchSlopSensitivity = 0.5f; // was 1.0f final float minFlingVelocity = 400 /* dips per second */ * density; final ViewDragCallback mLeftCallback = new ViewDragCallback(Gravity.LEFT); this.mLeftDragger = ViewDragHelper.create(this, touchSlopSensitivity, mLeftCallback); this.mLeftDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT); this.mLeftDragger.setMinVelocity(minFlingVelocity); final ViewDragCallback mRightCallback = new ViewDragCallback(Gravity.RIGHT); this.mRightDragger = ViewDragHelper.create(this, touchSlopSensitivity, mRightCallback); this.mRightDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_RIGHT); this.mRightDragger.setMinVelocity(minFlingVelocity); try { final Field edgeSizeField = ViewDragHelper.class.getDeclaredField("mEdgeSize"); if (!edgeSizeField.isAccessible()) edgeSizeField.setAccessible(true); final int widthPixels = getResources().getDisplayMetrics().widthPixels; // whole screen edgeSizeField.set(this.mLeftDragger, widthPixels / 2); edgeSizeField.set(this.mRightDragger, widthPixels / 2); } catch (final Exception e) { if (BuildConfig.DEBUG) Log.e("AWAISKING_APP", "", e); } mLeftCallback.setDragger(mLeftDragger); mRightCallback.setDragger(mRightDragger); setFocusableInTouchMode(true); //setMotionEventSplittingEnabled(false); } public void setDrawerElevation(final float elevation) { mDrawerElevation = elevation; for (int i = 0; i < getChildCount(); i++) { final View child = getChildAt(i); if (isDrawerView(child)) ViewCompat.setElevation(child, mDrawerElevation); } } public float getDrawerElevation() { return Build.VERSION.SDK_INT >= 21 ? mDrawerElevation : 0f; } public void addDrawerListener(@NonNull final DrawerListener listener) { if (mListeners == null) mListeners = new ArrayList<>(); mListeners.add(listener); } private boolean isInBoundsOfChild(final float x, final float y, final View child) { if (mChildHitRect == null) mChildHitRect = new Rect(); child.getHitRect(mChildHitRect); return mChildHitRect.contains((int) x, (int) y); } private boolean dispatchTransformedGenericPointerEvent(final MotionEvent event, @NonNull final View child) { final boolean handled; final Matrix childMatrix = child.getMatrix(); if (!childMatrix.isIdentity()) { final MotionEvent transformedEvent = getTransformedMotionEvent(event, child); handled = child.dispatchGenericMotionEvent(transformedEvent); transformedEvent.recycle(); } else { final float offsetX = getScrollX() - child.getLeft(); final float offsetY = getScrollY() - child.getTop(); event.offsetLocation(offsetX, offsetY); handled = child.dispatchGenericMotionEvent(event); event.offsetLocation(-offsetX, -offsetY); } return handled; } @NonNull private MotionEvent getTransformedMotionEvent(final MotionEvent event, @NonNull final View child) { final float offsetX = getScrollX() - child.getLeft(); final float offsetY = getScrollY() - child.getTop(); final MotionEvent transformedEvent = MotionEvent.obtain(event); transformedEvent.offsetLocation(offsetX, offsetY); final Matrix childMatrix = child.getMatrix(); if (!childMatrix.isIdentity()) { if (mChildInvertedMatrix == null) mChildInvertedMatrix = new Matrix(); childMatrix.invert(mChildInvertedMatrix); transformedEvent.transform(mChildInvertedMatrix); } return transformedEvent; } void updateDrawerState(@State final int activeState, final View activeDrawer) { final int leftState = mLeftDragger.getViewDragState(); final int rightState = mRightDragger.getViewDragState(); final int state; if (leftState == ViewDragHelper.STATE_DRAGGING || rightState == ViewDragHelper.STATE_DRAGGING) state = ViewDragHelper.STATE_DRAGGING; else if (leftState == ViewDragHelper.STATE_SETTLING || rightState == ViewDragHelper.STATE_SETTLING) state = ViewDragHelper.STATE_SETTLING; else state = ViewDragHelper.STATE_IDLE; if (activeDrawer != null && activeState == ViewDragHelper.STATE_IDLE) { final LayoutParams lp = (LayoutParams) activeDrawer.getLayoutParams(); if (lp.onScreen == 0) dispatchOnDrawerClosed(activeDrawer); else if (lp.onScreen == 1) dispatchOnDrawerOpened(activeDrawer); } if (state != mDrawerState) { mDrawerState = state; if (mListeners != null) { final int listenerCount = mListeners.size(); for (int i = listenerCount - 1; i >= 0; i--) mListeners.get(i).onDrawerStateChanged(); } } } void dispatchOnDrawerClosed(@NonNull final View drawerView) { final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); if ((lp.openState & LayoutParams.FLAG_IS_OPENED) == 1) { lp.openState = 0; if (mListeners != null) { final int listenerCount = mListeners.size(); for (int i = listenerCount - 1; i >= 0; i--) mListeners.get(i).onDrawerClosed(drawerView, lp.gravity); } } } void dispatchOnDrawerOpened(@NonNull final View drawerView) { final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); if ((lp.openState & LayoutParams.FLAG_IS_OPENED) == 0) { lp.openState = LayoutParams.FLAG_IS_OPENED; if (mListeners != null) { final int listenerCount = mListeners.size(); for (int i = listenerCount - 1; i >= 0; i--) mListeners.get(i).onDrawerOpened(drawerView, lp.gravity); } } } void setDrawerViewOffset(@NonNull final View drawerView, final float slideOffset) { final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); if (slideOffset != lp.onScreen) { lp.onScreen = slideOffset; if (mListeners != null) { final int listenerCount = mListeners.size(); for (int i = listenerCount - 1; i >= 0; i--) mListeners.get(i).onDrawerSlide(drawerView, lp.gravity, slideOffset); } } } float getDrawerViewOffset(@NonNull final View drawerView) { return ((LayoutParams) drawerView.getLayoutParams()).onScreen; } int getDrawerViewAbsoluteGravity(@NonNull final View drawerView) { final int gravity = ((LayoutParams) drawerView.getLayoutParams()).gravity; return GravityCompat.getAbsoluteGravity(gravity, ViewCompat.getLayoutDirection(this)); } boolean checkDrawerViewAbsoluteGravity(final View drawerView, final int checkFor) { final int absGravity = getDrawerViewAbsoluteGravity(drawerView); return (absGravity & checkFor) == checkFor; } void moveDrawerToOffset(final View drawerView, final float slideOffset) { final float oldOffset = getDrawerViewOffset(drawerView); final int width = drawerView.getWidth(); final int oldPos = (int) (width * oldOffset); final int newPos = (int) (width * slideOffset); final int dx = newPos - oldPos; drawerView.offsetLeftAndRight(checkDrawerViewAbsoluteGravity(drawerView, Gravity.LEFT) ? dx : -dx); setDrawerViewOffset(drawerView, slideOffset); } public View findOpenDrawer() { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); final LayoutParams childLp = (LayoutParams) child.getLayoutParams(); if ((childLp.openState & LayoutParams.FLAG_IS_OPENED) == 1) return child; } return null; } public View findDrawerWithGravity(final int gravity) { final int absHorizGravity = GravityCompat.getAbsoluteGravity(gravity, ViewCompat.getLayoutDirection(this)) & Gravity.HORIZONTAL_GRAVITY_MASK; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); final int childAbsGravity = getDrawerViewAbsoluteGravity(child); if ((childAbsGravity & Gravity.HORIZONTAL_GRAVITY_MASK) == absHorizGravity) return child; } return null; } @NonNull static String gravityToString(@EdgeGravity final int gravity) { if ((gravity & Gravity.LEFT) == Gravity.LEFT) return "LEFT"; if ((gravity & Gravity.RIGHT) == Gravity.RIGHT) return "RIGHT"; return Integer.toHexString(gravity); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); mFirstLayout = true; } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); mFirstLayout = true; } @SuppressLint("WrongConstant") @Override protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { final int widthSize = MeasureSpec.getSize(widthMeasureSpec); final int heightSize = MeasureSpec.getSize(heightMeasureSpec); setMeasuredDimension(widthSize, heightSize); boolean hasDrawerOnLeftEdge = false; boolean hasDrawerOnRightEdge = false; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (child.getVisibility() != GONE) { final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (isContentView(child)) { // Content views get measured at exactly the layout's size. final int contentWidthSpec = MeasureSpec.makeMeasureSpec(widthSize - lp.leftMargin - lp.rightMargin, MeasureSpec.EXACTLY); final int contentHeightSpec = MeasureSpec.makeMeasureSpec(heightSize - lp.topMargin - lp.bottomMargin, MeasureSpec.EXACTLY); child.measure(contentWidthSpec, contentHeightSpec); } else if (isDrawerView(child)) { if (Build.VERSION.SDK_INT >= 21 && ViewCompat.getElevation(child) != mDrawerElevation) ViewCompat.setElevation(child, mDrawerElevation); final int childGravity = getDrawerViewAbsoluteGravity(child) & Gravity.HORIZONTAL_GRAVITY_MASK; final boolean isLeftEdgeDrawer = (childGravity == Gravity.LEFT); if (isLeftEdgeDrawer && hasDrawerOnLeftEdge || !isLeftEdgeDrawer && hasDrawerOnRightEdge) throw new IllegalStateException("Child drawer has absolute gravity " + gravityToString(childGravity) + " but this MouseDrawer already has a drawer view along that edge"); if (isLeftEdgeDrawer) hasDrawerOnLeftEdge = true; else hasDrawerOnRightEdge = true; final int drawerWidthSpec = getChildMeasureSpec(widthMeasureSpec, lp.leftMargin + lp.rightMargin, lp.width); final int drawerHeightSpec = getChildMeasureSpec(heightMeasureSpec, lp.topMargin + lp.bottomMargin, lp.height); child.measure(drawerWidthSpec, drawerHeightSpec); } else throw new IllegalStateException("Child " + child + " at index " + i + " does not have a valid layout_gravity - must be Gravity.LEFT, Gravity.RIGHT or Gravity.NO_GRAVITY"); } } } @Override protected void onLayout(final boolean changed, final int left, final int top, final int right, final int bottom) { mInLayout = true; final int width = right - left; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (child.getVisibility() != GONE) { final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (isContentView(child)) { child.layout(lp.leftMargin, lp.topMargin, lp.leftMargin + child.getMeasuredWidth(), lp.topMargin + child.getMeasuredHeight()); } else { // Drawer, if it wasn't onMeasure would have thrown an exception. final int childWidth = child.getMeasuredWidth(); final int childHeight = child.getMeasuredHeight(); final int childLeft; final float newOffset; if (checkDrawerViewAbsoluteGravity(child, Gravity.LEFT)) { childLeft = -childWidth + (int) (childWidth * lp.onScreen); newOffset = (float) (childWidth + childLeft) / childWidth; } else { // Right; onMeasure checked for us. childLeft = width - (int) (childWidth * lp.onScreen); newOffset = (float) (width - childLeft) / childWidth; } final boolean changeOffset = newOffset != lp.onScreen; final int vgrav = lp.gravity & Gravity.VERTICAL_GRAVITY_MASK; switch (vgrav) { default: case Gravity.TOP: child.layout(childLeft, lp.topMargin, childLeft + childWidth, lp.topMargin + childHeight); break; case Gravity.BOTTOM: { final int height = bottom - top; child.layout(childLeft, height - lp.bottomMargin - child.getMeasuredHeight(), childLeft + childWidth, height - lp.bottomMargin); break; } case Gravity.CENTER_VERTICAL: { final int height = bottom - top; int childTop = (height - childHeight) / 2; if (childTop < lp.topMargin) childTop = lp.topMargin; else if (childTop + childHeight > height - lp.bottomMargin) childTop = height - lp.bottomMargin - childHeight; child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); break; } } if (changeOffset) setDrawerViewOffset(child, newOffset); final int newVisibility = lp.onScreen > 0 ? VISIBLE : INVISIBLE; if (child.getVisibility() != newVisibility) child.setVisibility(newVisibility); } } } mInLayout = false; mFirstLayout = false; } @Override public void requestLayout() { if (!mInLayout) super.requestLayout(); } @Override public void computeScroll() { final boolean leftDraggerSettling = mLeftDragger.continueSettling(true); final boolean rightDraggerSettling = mRightDragger.continueSettling(true); if (leftDraggerSettling || rightDraggerSettling) postInvalidateOnAnimation(); } private static boolean hasOpaqueBackground(@NonNull final View v) { final Drawable bg = v.getBackground(); if (bg != null) return bg.getOpacity() == PixelFormat.OPAQUE; return false; } @Override protected boolean drawChild(@NonNull final Canvas canvas, final View child, final long drawingTime) { final int height = getHeight(); final boolean drawingContent = isContentView(child); int clipLeft = 0, clipRight = getWidth(); final int restoreCount = canvas.save(); if (drawingContent) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View v = getChildAt(i); if (v != child && v.getVisibility() == VISIBLE && hasOpaqueBackground(v) && isDrawerView(v) && v.getHeight() >= height) { if (checkDrawerViewAbsoluteGravity(v, Gravity.LEFT)) { final int vright = v.getRight(); if (vright > clipLeft) clipLeft = vright; } else { final int vleft = v.getLeft(); if (vleft < clipRight) clipRight = vleft; } } } canvas.clipRect(clipLeft, 0, clipRight, getHeight()); } final boolean result = super.drawChild(canvas, child, drawingTime); canvas.restoreToCount(restoreCount); return result; } boolean isContentView(@NonNull final View child) { return ((LayoutParams) child.getLayoutParams()).gravity == Gravity.NO_GRAVITY; } boolean isDrawerView(@NonNull final View child) { final int gravity = ((LayoutParams) child.getLayoutParams()).gravity; final int absGravity = GravityCompat.getAbsoluteGravity(gravity, ViewCompat.getLayoutDirection(child)); return (absGravity & Gravity.LEFT) != 0 || (absGravity & Gravity.RIGHT) != 0; } @Override public boolean onInterceptTouchEvent(@NonNull final MotionEvent ev) { final int action = ev.getActionMasked(); // "|" used deliberately here; both methods should be invoked. final boolean interceptForDrag = mLeftDragger.shouldInterceptTouchEvent(ev) | mRightDragger.shouldInterceptTouchEvent(ev); switch (action) { case MotionEvent.ACTION_DOWN: mInitialMotionX = ev.getX(); mInitialMotionY = ev.getY(); break; case MotionEvent.ACTION_MOVE: mLeftDragger.checkTouchSlop(ViewDragHelper.DIRECTION_ALL); break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: closeDrawers(true); } return interceptForDrag || hasPeekingDrawer(); } @Override public boolean dispatchGenericMotionEvent(@NonNull final MotionEvent event) { if ((event.getSource() & InputDevice.SOURCE_CLASS_POINTER) == 0 || event.getAction() == MotionEvent.ACTION_HOVER_EXIT) return super.dispatchGenericMotionEvent(event); final int childrenCount = getChildCount(); if (childrenCount != 0) { final float x = event.getX(); final float y = event.getY(); // Walk through children from top to bottom. for (int i = childrenCount - 1; i >= 0; i--) { final View child = getChildAt(i); if (isInBoundsOfChild(x, y, child) && !isContentView(child) && dispatchTransformedGenericPointerEvent(event, child)) return true; } } return false; } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(final MotionEvent ev) { mLeftDragger.processTouchEvent(ev); mRightDragger.processTouchEvent(ev); final int action = ev.getActionMasked(); switch (action) { case MotionEvent.ACTION_DOWN: mInitialMotionX = ev.getX(); mInitialMotionY = ev.getY(); break; case MotionEvent.ACTION_UP: final float x = ev.getX(); final float y = ev.getY(); boolean peekingOnly = true; final View touchedView = mLeftDragger.findTopChildUnder((int) x, (int) y); if (touchedView != null && isContentView(touchedView)) { final float dx = x - mInitialMotionX; final float dy = y - mInitialMotionY; final int slop = mLeftDragger.getTouchSlop(); if (dx * dx + dy * dy < slop * slop) { // Taps close a dimmed open drawer but only if it isn't locked open. final View openDrawer = findOpenDrawer(); if (openDrawer != null) peekingOnly = false; } } closeDrawers(peekingOnly); break; case MotionEvent.ACTION_CANCEL: closeDrawers(true); break; } return true; } @Override public void requestDisallowInterceptTouchEvent(final boolean disallowIntercept) { if (CHILDREN_DISALLOW_INTERCEPT || (!mLeftDragger.isEdgeTouched(ViewDragHelper.EDGE_LEFT) && !mRightDragger.isEdgeTouched(ViewDragHelper.EDGE_RIGHT))) super.requestDisallowInterceptTouchEvent(disallowIntercept); if (disallowIntercept) closeDrawers(true); } public void closeDrawers() { closeDrawers(false); } void closeDrawers(final boolean peekingOnly) { boolean needsInvalidate = false; final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (isDrawerView(child) && (!peekingOnly || lp.isPeeking)) { final int childWidth = child.getWidth(); if (checkDrawerViewAbsoluteGravity(child, Gravity.LEFT)) needsInvalidate |= mLeftDragger.smoothSlideViewTo(child, -childWidth, child.getTop()); else needsInvalidate |= mRightDragger.smoothSlideViewTo(child, getWidth(), child.getTop()); lp.isPeeking = false; } } if (needsInvalidate) invalidate(); } public void openDrawer(@NonNull final View drawerView, final boolean animate) { if (isDrawerView(drawerView)) { final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); if (mFirstLayout) { lp.onScreen = 1.f; lp.openState = LayoutParams.FLAG_IS_OPENED; } else if (animate) { lp.openState |= LayoutParams.FLAG_IS_OPENING; if (checkDrawerViewAbsoluteGravity(drawerView, Gravity.LEFT)) mLeftDragger.smoothSlideViewTo(drawerView, 0, drawerView.getTop()); else mRightDragger.smoothSlideViewTo(drawerView, getWidth() - drawerView.getWidth(), drawerView.getTop()); } else { moveDrawerToOffset(drawerView, 1.f); updateDrawerState(ViewDragHelper.STATE_IDLE, drawerView); drawerView.setVisibility(VISIBLE); } invalidate(); return; } throw new IllegalArgumentException("View " + drawerView + " is not a sliding drawer"); } public void openDrawer(@NonNull final View drawerView) { openDrawer(drawerView, true); } // public void openDrawer(@EdgeGravity final int gravity, final boolean animate) { // final View drawerView = findDrawerWithGravity(gravity); // if (drawerView != null) openDrawer(drawerView, animate); // else throw new IllegalArgumentException("No drawer view found with gravity " + gravityToString(gravity)); // } // public void openDrawer(@EdgeGravity final int gravity) { // openDrawer(gravity, true); // } public void closeDrawer(@NonNull final View drawerView) { closeDrawer(drawerView, true); } public void closeDrawer(@NonNull final View drawerView, final boolean animate) { if (isDrawerView(drawerView)) { final LayoutParams lp = (LayoutParams) drawerView.getLayoutParams(); if (mFirstLayout) { lp.onScreen = 0.f; lp.openState = 0; } else if (animate) { lp.openState |= LayoutParams.FLAG_IS_CLOSING; if (checkDrawerViewAbsoluteGravity(drawerView, Gravity.LEFT)) mLeftDragger.smoothSlideViewTo(drawerView, -drawerView.getWidth(), drawerView.getTop()); else mRightDragger.smoothSlideViewTo(drawerView, getWidth(), drawerView.getTop()); } else { moveDrawerToOffset(drawerView, 0.f); updateDrawerState(ViewDragHelper.STATE_IDLE, drawerView); drawerView.setVisibility(INVISIBLE); } invalidate(); } else throw new IllegalArgumentException("View " + drawerView + " is not a sliding drawer"); } // public void closeDrawer(@EdgeGravity final int gravity) { // closeDrawer(gravity, true); // } // public void closeDrawer(@EdgeGravity final int gravity, final boolean animate) { // final View drawerView = findDrawerWithGravity(gravity); // if (drawerView != null) closeDrawer(drawerView, animate); // else throw new IllegalArgumentException("No drawer view found with gravity " + gravityToString(gravity)); // } public boolean isDrawerOpen(@NonNull final View drawer) { if (isDrawerView(drawer)) return (((LayoutParams) drawer.getLayoutParams()).openState & LayoutParams.FLAG_IS_OPENED) == 1; else throw new IllegalArgumentException("View " + drawer + " is not a drawer"); } // public boolean isDrawerOpen(@EdgeGravity final int drawerGravity) { // final View drawerView = findDrawerWithGravity(drawerGravity); // return drawerView != null && isDrawerOpen(drawerView); // } public boolean isDrawerVisible(@NonNull final View drawer) { if (isDrawerView(drawer)) return ((LayoutParams) drawer.getLayoutParams()).onScreen > 0; throw new IllegalArgumentException("View " + drawer + " is not a drawer"); } // public boolean isDrawerVisible(@EdgeGravity final int drawerGravity) { // final View drawerView = findDrawerWithGravity(drawerGravity); // return drawerView != null && isDrawerVisible(drawerView); // } private boolean hasPeekingDrawer() { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams(); if (lp.isPeeking) return true; } return false; } @Override protected ViewGroup.LayoutParams generateDefaultLayoutParams() { return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); } @Override protected ViewGroup.LayoutParams generateLayoutParams(final ViewGroup.LayoutParams params) { return params instanceof LayoutParams ? new LayoutParams((LayoutParams) params) : params instanceof ViewGroup.MarginLayoutParams ? new LayoutParams((MarginLayoutParams) params) : new LayoutParams(params); } @Override protected boolean checkLayoutParams(final ViewGroup.LayoutParams params) { return params instanceof LayoutParams && super.checkLayoutParams(params); } @Override public ViewGroup.LayoutParams generateLayoutParams(final AttributeSet attrs) { return new LayoutParams(getContext(), attrs); } @Override public void addFocusables(final ArrayList views, final int direction, final int focusableMode) { if (getDescendantFocusability() != FOCUS_BLOCK_DESCENDANTS) { final int childCount = getChildCount(); boolean isDrawerOpen = false; for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (!isDrawerView(child)) mNonDrawerViews.add(child); else if (isDrawerOpen(child)) { isDrawerOpen = true; child.addFocusables(views, direction, focusableMode); } } if (!isDrawerOpen) { final int nonDrawerViewsCount = mNonDrawerViews.size(); for (int i = 0; i < nonDrawerViewsCount; ++i) { final View child = mNonDrawerViews.get(i); if (child.getVisibility() == View.VISIBLE) child.addFocusables(views, direction, focusableMode); } } mNonDrawerViews.clear(); } } private boolean hasVisibleDrawer() { return findVisibleDrawer() != null; } @Nullable final View findVisibleDrawer() { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (isDrawerView(child) && isDrawerVisible(child)) return child; } return null; } @Override public boolean onKeyDown(final int keyCode, final KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK && hasVisibleDrawer()) { event.startTracking(); return true; } return super.onKeyDown(keyCode, event); } @Override public boolean onKeyUp(final int keyCode, final KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { final View visibleDrawer = findVisibleDrawer(); if (visibleDrawer != null && isDrawerView(visibleDrawer)) closeDrawers(); return visibleDrawer != null; } return super.onKeyUp(keyCode, event); } @Override protected void onRestoreInstanceState(final Parcelable state) { if (state instanceof SavedState) { final SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); if (ss.openDrawerGravity != Gravity.NO_GRAVITY) { final View toOpen = findDrawerWithGravity(ss.openDrawerGravity); if (toOpen != null) openDrawer(toOpen); } } else super.onRestoreInstanceState(state); } @Override protected Parcelable onSaveInstanceState() { final Parcelable superState = super.onSaveInstanceState(); assert superState != null; final SavedState ss = new SavedState(superState); final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); // Is the current child fully opened (that is, not closing)? final boolean isOpenedAndNotClosing = (lp.openState == LayoutParams.FLAG_IS_OPENED); // Is the current child opening? final boolean isClosedAndOpening = (lp.openState == LayoutParams.FLAG_IS_OPENING); if (isOpenedAndNotClosing || isClosedAndOpening) { // If one of the conditions above holds, save the child's gravity so that we open that child during state restore. ss.openDrawerGravity = lp.gravity; break; } } return ss; } @Override public void addView(final View child, final int index, final ViewGroup.LayoutParams params) { super.addView(child, index, params); final View openDrawer = findOpenDrawer(); if (openDrawer == null) isDrawerView(child); } protected static class SavedState extends AbsSavedState { public static final Creator CREATOR = new ClassLoaderCreator() { @NonNull @Override public SavedState createFromParcel(final Parcel in, final ClassLoader loader) { return new SavedState(in, loader); } @NonNull @Override public SavedState createFromParcel(final Parcel in) { return new SavedState(in, null); } @NonNull @Override public SavedState[] newArray(final int size) { return new SavedState[size]; } }; int openDrawerGravity = Gravity.NO_GRAVITY; public SavedState(@NonNull final Parcelable superState) { super(superState); } public SavedState(@NonNull final Parcel in, @Nullable final ClassLoader loader) { super(in, loader); openDrawerGravity = in.readInt(); } @Override public void writeToParcel(final Parcel dest, final int flags) { super.writeToParcel(dest, flags); dest.writeInt(openDrawerGravity); } } private class ViewDragCallback extends ViewDragHelper.Callback { private final int mAbsGravity; private ViewDragHelper mDragger; ViewDragCallback(final int gravity) { mAbsGravity = gravity; } public void setDragger(final ViewDragHelper dragger) { mDragger = dragger; } @Override public boolean tryCaptureView(@NonNull final View child, final int pointerId) { return isDrawerView(child) && checkDrawerViewAbsoluteGravity(child, mAbsGravity); } @Override public void onViewDragStateChanged(final int state) { updateDrawerState(state, mDragger.getCapturedView()); } @Override public void onViewPositionChanged(@NonNull final View changedView, final int left, final int top, final int dx, final int dy) { final float offset; final int childWidth = changedView.getWidth(); if (checkDrawerViewAbsoluteGravity(changedView, Gravity.LEFT)) offset = (float) (childWidth + left) / childWidth; else offset = (float) (getWidth() - left) / childWidth; setDrawerViewOffset(changedView, offset); changedView.setVisibility(offset == 0 ? INVISIBLE : VISIBLE); invalidate(); } @Override public void onViewCaptured(@NonNull final View capturedChild, final int activePointerId) { final LayoutParams lp = (LayoutParams) capturedChild.getLayoutParams(); lp.isPeeking = false; closeOtherDrawer(); } private void closeOtherDrawer() { final int otherGrav = mAbsGravity == Gravity.LEFT ? Gravity.RIGHT : Gravity.LEFT; final View toClose = findDrawerWithGravity(otherGrav); if (toClose != null) closeDrawer(toClose); } @Override public void onViewReleased(@NonNull final View releasedChild, final float xvel, final float yvel) { final float offset = getDrawerViewOffset(releasedChild); final int childWidth = releasedChild.getWidth(); final int left; if (checkDrawerViewAbsoluteGravity(releasedChild, Gravity.LEFT)) left = xvel > 0 || (xvel == 0 && offset > 0.5f) ? 0 : -childWidth; else { final int width = getWidth(); left = xvel < 0 || (xvel == 0 && offset > 0.5f) ? width - childWidth : width; } mDragger.settleCapturedViewAt(left, releasedChild.getTop()); invalidate(); } @Override public void onEdgeDragStarted(final int edgeFlags, final int pointerId) { final View toCapture; if ((edgeFlags & ViewDragHelper.EDGE_LEFT) == ViewDragHelper.EDGE_LEFT) toCapture = findDrawerWithGravity(Gravity.LEFT); else toCapture = findDrawerWithGravity(Gravity.RIGHT); if (toCapture != null && isDrawerView(toCapture)) mDragger.captureChildView(toCapture, pointerId); } @Override public int getViewHorizontalDragRange(@NonNull final View child) { return isDrawerView(child) ? child.getWidth() : 0; } @Override public int clampViewPositionHorizontal(@NonNull final View child, final int left, final int dx) { if (checkDrawerViewAbsoluteGravity(child, Gravity.LEFT)) return Math.max(-child.getWidth(), Math.min(left, 0)); final int width = getWidth(); return Math.max(width - child.getWidth(), Math.min(left, width)); } @Override public int clampViewPositionVertical(@NonNull final View child, final int top, final int dy) { return child.getTop(); } } public static class LayoutParams extends ViewGroup.MarginLayoutParams { private static final int FLAG_IS_CLOSING = 0x4; public static final int FLAG_IS_OPENED = 0x1; public static final int FLAG_IS_OPENING = 0x2; public int openState; @EdgeGravity public int gravity = Gravity.NO_GRAVITY; public boolean isPeeking; public float onScreen; public LayoutParams(@NonNull final Context c, @Nullable final AttributeSet attrs) { super(c, attrs); final TypedArray a = c.obtainStyledAttributes(attrs, new int[]{android.R.attr.layout_gravity}); try { this.gravity = a.getInt(0, Gravity.NO_GRAVITY); } finally { a.recycle(); } } public LayoutParams(final int width, final int height) { super(width, height); } public LayoutParams(@NonNull final LayoutParams source) { super(source); this.gravity = source.gravity; } public LayoutParams(@NonNull final ViewGroup.LayoutParams source) { super(source); } public LayoutParams(@NonNull final ViewGroup.MarginLayoutParams source) { super(source); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/PostsRecyclerView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.util.AttributeSet; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelStoreOwner; import androidx.recyclerview.widget.LinearSmoothScroller; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; import androidx.transition.ChangeBounds; import androidx.transition.Transition; import androidx.transition.TransitionManager; import androidx.work.Data; import androidx.work.WorkInfo; import androidx.work.WorkManager; import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.MediaViewModel; import awais.instagrabber.workers.DownloadWorker; public class PostsRecyclerView extends RecyclerView { private static final String TAG = "PostsRecyclerView"; private StaggeredGridLayoutManager layoutManager; private PostsLayoutPreferences layoutPreferences; private PostFetcher.PostFetchService postFetchService; private Transition transition; private ViewModelStoreOwner viewModelStoreOwner; private FeedAdapterV2 feedAdapter; private LifecycleOwner lifeCycleOwner; private MediaViewModel mediaViewModel; private boolean initCalled = false; private GridSpacingItemDecoration gridSpacingItemDecoration; private RecyclerLazyLoaderAtEdge lazyLoader; private FeedAdapterV2.FeedItemCallback feedItemCallback; private boolean shouldScrollToTop; private FeedAdapterV2.SelectionModeCallback selectionModeCallback; private final List fetchStatusChangeListeners = new ArrayList<>(); private final RecyclerView.SmoothScroller smoothScroller = new LinearSmoothScroller(getContext()) { @Override protected int getVerticalSnapPreference() { return LinearSmoothScroller.SNAP_TO_START; } }; public PostsRecyclerView(@NonNull final Context context) { super(context); } public PostsRecyclerView(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public PostsRecyclerView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } public PostsRecyclerView setViewModelStoreOwner(final ViewModelStoreOwner owner) { if (initCalled) { throw new IllegalArgumentException("init already called!"); } this.viewModelStoreOwner = owner; return this; } public PostsRecyclerView setLifeCycleOwner(final LifecycleOwner lifeCycleOwner) { if (initCalled) { throw new IllegalArgumentException("init already called!"); } this.lifeCycleOwner = lifeCycleOwner; return this; } public PostsRecyclerView setPostFetchService(final PostFetcher.PostFetchService postFetchService) { if (initCalled) { throw new IllegalArgumentException("init already called!"); } this.postFetchService = postFetchService; return this; } public PostsRecyclerView setFeedItemCallback(@NonNull final FeedAdapterV2.FeedItemCallback feedItemCallback) { this.feedItemCallback = feedItemCallback; return this; } public PostsRecyclerView setSelectionModeCallback(@NonNull final FeedAdapterV2.SelectionModeCallback selectionModeCallback) { this.selectionModeCallback = selectionModeCallback; return this; } public PostsRecyclerView setLayoutPreferences(final PostsLayoutPreferences layoutPreferences) { this.layoutPreferences = layoutPreferences; if (initCalled) { if (layoutPreferences == null) return this; feedAdapter.setLayoutPreferences(layoutPreferences); updateLayout(); } return this; } public void init() { initCalled = true; if (viewModelStoreOwner == null) { throw new IllegalArgumentException("ViewModelStoreOwner cannot be null"); } else if (lifeCycleOwner == null) { throw new IllegalArgumentException("LifecycleOwner cannot be null"); } else if (postFetchService == null) { throw new IllegalArgumentException("PostFetchService cannot be null"); } if (layoutPreferences == null) { layoutPreferences = PostsLayoutPreferences.builder().build(); // Utils.settingsHelper.putString(Constants.PREF_POSTS_LAYOUT, layoutPreferences.getJson()); } gridSpacingItemDecoration = new GridSpacingItemDecoration(Utils.convertDpToPx(2)); initTransition(); initAdapter(); initLayoutManager(); initSelf(); initDownloadWorkerListener(); } private void initTransition() { transition = new ChangeBounds(); transition.setDuration(300); } private void initLayoutManager() { layoutManager = new StaggeredGridLayoutManager(layoutPreferences.getColCount(), StaggeredGridLayoutManager.VERTICAL); setLayoutManager(layoutManager); } private void initAdapter() { feedAdapter = new FeedAdapterV2(layoutPreferences, feedItemCallback, selectionModeCallback); feedAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); setAdapter(feedAdapter); } private void initSelf() { try { mediaViewModel = new ViewModelProvider( viewModelStoreOwner, new MediaViewModel.ViewModelFactory(postFetchService) ).get(MediaViewModel.class); } catch (Exception e) { Log.e(TAG, "initSelf: ", e); } if (mediaViewModel == null) return; final LiveData> mediaListLiveData = mediaViewModel.getList(); mediaListLiveData.observe(lifeCycleOwner, list -> feedAdapter.submitList(list, () -> { dispatchFetchStatus(); postDelayed(this::fetchMoreIfPossible, 1000); if (!shouldScrollToTop) return; shouldScrollToTop = false; post(() -> smoothScrollToPosition(0)); })); if (layoutPreferences.getHasGap()) { addItemDecoration(gridSpacingItemDecoration); } setHasFixedSize(true); setNestedScrollingEnabled(true); setItemAnimator(null); lazyLoader = new RecyclerLazyLoaderAtEdge(layoutManager, (page) -> { if (mediaViewModel.hasMore()) { mediaViewModel.fetch(); dispatchFetchStatus(); } }); addOnScrollListener(lazyLoader); if (mediaListLiveData.getValue() == null || mediaListLiveData.getValue().isEmpty()) { mediaViewModel.fetch(); dispatchFetchStatus(); } } private void fetchMoreIfPossible() { if (!mediaViewModel.hasMore()) return; if (feedAdapter.getItemCount() == 0) return; final LayoutManager layoutManager = getLayoutManager(); if (!(layoutManager instanceof StaggeredGridLayoutManager)) return; final int[] itemPositions = ((StaggeredGridLayoutManager) layoutManager).findLastCompletelyVisibleItemPositions(null); final boolean allNoPosition = Arrays.stream(itemPositions).allMatch(position -> position == RecyclerView.NO_POSITION); if (allNoPosition) return; final boolean match = Arrays.stream(itemPositions).anyMatch(position -> position == feedAdapter.getItemCount() - 1); if (!match) return; mediaViewModel.fetch(); dispatchFetchStatus(); } private void initDownloadWorkerListener() { WorkManager.getInstance(getContext()) .getWorkInfosByTagLiveData("download") .observe(lifeCycleOwner, workInfoList -> { for (final WorkInfo workInfo : workInfoList) { if (workInfo == null) continue; final Data progress = workInfo.getProgress(); final float progressPercent = progress.getFloat(DownloadWorker.PROGRESS, 0); if (progressPercent != 100) continue; final String url = progress.getString(DownloadWorker.URL); final List feedModels = mediaViewModel.getList().getValue(); if (feedModels == null) continue; for (int i = 0; i < feedModels.size(); i++) { final Media feedModel = feedModels.get(i); final List displayUrls = getDisplayUrl(feedModel); if (displayUrls.contains(url)) { feedAdapter.notifyItemChanged(i); break; } } } }); } private List getDisplayUrl(final Media feedModel) { List urls = Collections.emptyList(); if (feedModel == null || feedModel.getType() == null) return urls; switch (feedModel.getType()) { case MEDIA_TYPE_IMAGE: case MEDIA_TYPE_VIDEO: urls = Collections.singletonList(ResponseBodyUtils.getImageUrl(feedModel)); break; case MEDIA_TYPE_SLIDER: final List sliderItems = feedModel.getCarouselMedia(); if (sliderItems != null) { final ImmutableList.Builder builder = ImmutableList.builder(); for (final Media child : sliderItems) { builder.add(ResponseBodyUtils.getImageUrl(child)); } urls = builder.build(); } break; default: } return urls; } private void updateLayout() { post(() -> { TransitionManager.beginDelayedTransition(this, transition); feedAdapter.notifyDataSetChanged(); final int itemDecorationCount = getItemDecorationCount(); if (!layoutPreferences.getHasGap()) { if (itemDecorationCount == 1) { removeItemDecoration(gridSpacingItemDecoration); } } else { if (itemDecorationCount == 0) { addItemDecoration(gridSpacingItemDecoration); } } if (layoutPreferences.getType() == PostsLayoutPreferences.PostsLayoutType.LINEAR) { if (layoutManager.getSpanCount() != 1) { layoutManager.setSpanCount(1); setAdapter(null); setAdapter(feedAdapter); } } else { boolean shouldRedraw = layoutManager.getSpanCount() == 1; layoutManager.setSpanCount(layoutPreferences.getColCount()); if (shouldRedraw) { setAdapter(null); setAdapter(feedAdapter); } } }); } public void refresh() { shouldScrollToTop = true; if (lazyLoader != null) { lazyLoader.resetState(); } if (mediaViewModel != null) { mediaViewModel.refresh(); } dispatchFetchStatus(); } public boolean isFetching() { return mediaViewModel != null && mediaViewModel.isFetching(); } public PostsRecyclerView addFetchStatusChangeListener(final FetchStatusChangeListener fetchStatusChangeListener) { if (fetchStatusChangeListener == null) return this; fetchStatusChangeListeners.add(fetchStatusChangeListener); return this; } public void removeFetchStatusListener(final FetchStatusChangeListener fetchStatusChangeListener) { if (fetchStatusChangeListener == null) return; fetchStatusChangeListeners.remove(fetchStatusChangeListener); } private void dispatchFetchStatus() { for (final FetchStatusChangeListener listener : fetchStatusChangeListeners) { listener.onFetchStatusChange(isFetching()); } } public PostsLayoutPreferences getLayoutPreferences() { return layoutPreferences; } public void endSelection() { feedAdapter.endSelection(); } public interface FetchStatusChangeListener { void onFetchStatusChange(boolean fetching); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); lifeCycleOwner = null; initCalled = false; } @Override public void smoothScrollToPosition(final int position) { smoothScroller.setTargetPosition(position); layoutManager.startSmoothScroll(smoothScroller); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/PrimaryActionModeCallback.java ================================================ package awais.instagrabber.customviews; import android.view.ActionMode; import android.view.Menu; import android.view.MenuItem; public class PrimaryActionModeCallback implements ActionMode.Callback { private ActionMode mode; private int menuRes; private final Callbacks callbacks; public PrimaryActionModeCallback(final int menuRes, final Callbacks callbacks) { this.menuRes = menuRes; this.callbacks = callbacks; } @Override public boolean onCreateActionMode(final ActionMode mode, final Menu menu) { this.mode = mode; mode.getMenuInflater().inflate(menuRes, menu); if (callbacks != null) { callbacks.onCreate(mode, menu); } return true; } @Override public boolean onPrepareActionMode(final ActionMode mode, final Menu menu) { return false; } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { if (callbacks != null) { return callbacks.onActionItemClicked(mode, item); } return false; } @Override public void onDestroyActionMode(final ActionMode mode) { if (callbacks != null) { callbacks.onDestroy(mode); } this.mode = null; } public abstract static class CallbacksHelper implements Callbacks { public void onCreate(final ActionMode mode, final Menu menu) { } @Override public void onDestroy(final ActionMode mode) { } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { return false; } } public interface Callbacks { void onCreate(final ActionMode mode, final Menu menu); void onDestroy(final ActionMode mode); boolean onActionItemClicked(final ActionMode mode, final MenuItem item); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/ProfilePicView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Color; import android.util.AttributeSet; import android.view.ViewGroup; import androidx.annotation.DimenRes; import androidx.annotation.NonNull; import com.facebook.drawee.generic.GenericDraweeHierarchy; import com.facebook.drawee.generic.RoundingParams; import java.util.HashMap; import java.util.Map; import awais.instagrabber.R; public final class ProfilePicView extends CircularImageView { private static final String TAG = "ProfilePicView"; private Size size; private int dimensionPixelSize; public ProfilePicView(Context context, GenericDraweeHierarchy hierarchy) { super(context); setHierarchy(hierarchy); size = Size.REGULAR; updateLayout(); } public ProfilePicView(final Context context) { super(context); size = Size.REGULAR; updateLayout(); } public ProfilePicView(final Context context, final AttributeSet attrs) { super(context, attrs); parseAttrs(context, attrs); updateLayout(); } public ProfilePicView(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); parseAttrs(context, attrs); updateLayout(); } private void parseAttrs(final Context context, final AttributeSet attrs) { final TypedArray a = context.getTheme().obtainStyledAttributes( attrs, R.styleable.ProfilePicView, 0, 0); try { final int sizeValue = a.getInt(R.styleable.ProfilePicView_size, Size.REGULAR.getValue()); size = Size.valueOf(sizeValue); } finally { a.recycle(); } } private void updateLayout() { @DimenRes final int dimenRes; switch (size) { case SMALL: dimenRes = R.dimen.profile_pic_size_small; break; case SMALLER: dimenRes = R.dimen.profile_pic_size_smaller; break; case TINY: dimenRes = R.dimen.profile_pic_size_tiny; break; case LARGE: dimenRes = R.dimen.profile_pic_size_large; break; default: case REGULAR: dimenRes = R.dimen.profile_pic_size_regular; break; } ViewGroup.LayoutParams layoutParams = getLayoutParams(); if (layoutParams == null) { layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); } dimensionPixelSize = getResources().getDimensionPixelSize(dimenRes); layoutParams.width = dimensionPixelSize; layoutParams.height = dimensionPixelSize; // invalidate(); // requestLayout(); } public void setSize(final Size size) { this.size = size; updateLayout(); } public void setStoriesBorder() { // private final int borderSize = 8; final int color = Color.GREEN; RoundingParams roundingParams = getHierarchy().getRoundingParams(); if (roundingParams == null) { roundingParams = RoundingParams.asCircle().setRoundingMethod(RoundingParams.RoundingMethod.BITMAP_ONLY); } roundingParams.setBorder(color, 5.0f); getHierarchy().setRoundingParams(roundingParams); } public enum Size { TINY(0), SMALL(1), REGULAR(2), LARGE(3), SMALLER(4); private final int value; private static final Map map = new HashMap<>(); static { for (Size size : Size.values()) { map.put(size.value, size); } } Size(final int value) { this.value = value; } @NonNull public static Size valueOf(final int value) { final Size size = map.get(value); return size != null ? size : Size.REGULAR; } public int getValue() { return value; } } @Override protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); setMeasuredDimension(dimensionPixelSize, dimensionPixelSize); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.text.InputFilter; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.emoji.widget.EmojiTextViewHelper; import java.util.ArrayList; import java.util.List; import io.github.armcha.autolink.AutoLinkItem; import io.github.armcha.autolink.AutoLinkTextView; import io.github.armcha.autolink.MODE_EMAIL; import io.github.armcha.autolink.MODE_HASHTAG; import io.github.armcha.autolink.MODE_MENTION; import io.github.armcha.autolink.MODE_URL; import io.github.armcha.autolink.Mode; public class RamboTextViewV2 extends AutoLinkTextView { private final List onMentionClickListeners = new ArrayList<>(); private final List onHashtagClickListeners = new ArrayList<>(); private final List onURLClickListeners = new ArrayList<>(); private final List onEmailClickListeners = new ArrayList<>(); private EmojiTextViewHelper emojiTextViewHelper; public RamboTextViewV2(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); init(); } private void init() { getEmojiTextViewHelper().updateTransformationMethod(); addAutoLinkMode(MODE_HASHTAG.INSTANCE, MODE_MENTION.INSTANCE, MODE_EMAIL.INSTANCE, MODE_URL.INSTANCE); onAutoLinkClick(autoLinkItem -> { final Mode mode = autoLinkItem.getMode(); if (mode.equals(MODE_MENTION.INSTANCE)) { for (final OnMentionClickListener onMentionClickListener : onMentionClickListeners) { onMentionClickListener.onMentionClick(autoLinkItem); } return; } if (mode.equals(MODE_HASHTAG.INSTANCE)) { for (final OnHashtagClickListener onHashtagClickListener : onHashtagClickListeners) { onHashtagClickListener.onHashtagClick(autoLinkItem); } return; } if (mode.equals(MODE_URL.INSTANCE)) { for (final OnURLClickListener onURLClickListener : onURLClickListeners) { onURLClickListener.onURLClick(autoLinkItem); } return; } if (mode.equals(MODE_EMAIL.INSTANCE)) { for (final OnEmailClickListener onEmailClickListener : onEmailClickListeners) { onEmailClickListener.onEmailClick(autoLinkItem); } } }); onAutoLinkLongClick(autoLinkItem -> {}); } @Override public void setFilters(InputFilter[] filters) { super.setFilters(getEmojiTextViewHelper().getFilters(filters)); } @Override public void setAllCaps(boolean allCaps) { super.setAllCaps(allCaps); getEmojiTextViewHelper().setAllCaps(allCaps); } private EmojiTextViewHelper getEmojiTextViewHelper() { if (emojiTextViewHelper == null) { emojiTextViewHelper = new EmojiTextViewHelper(this); } return emojiTextViewHelper; } public void addOnMentionClickListener(final OnMentionClickListener onMentionClickListener) { if (onMentionClickListener == null) { return; } onMentionClickListeners.add(onMentionClickListener); } public void removeOnMentionClickListener(final OnMentionClickListener onMentionClickListener) { if (onMentionClickListener == null) { return; } onMentionClickListeners.remove(onMentionClickListener); } public void clearOnMentionClickListeners() { onMentionClickListeners.clear(); } public void addOnHashtagListener(final OnHashtagClickListener onHashtagClickListener) { if (onHashtagClickListener == null) { return; } onHashtagClickListeners.add(onHashtagClickListener); } public void removeOnHashtagListener(final OnHashtagClickListener onHashtagClickListener) { if (onHashtagClickListener == null) { return; } onHashtagClickListeners.remove(onHashtagClickListener); } public void clearOnHashtagClickListeners() { onHashtagClickListeners.clear(); } public void addOnURLClickListener(final OnURLClickListener onURLClickListener) { if (onURLClickListener == null) { return; } onURLClickListeners.add(onURLClickListener); } public void removeOnURLClickListener(final OnURLClickListener onURLClickListener) { if (onURLClickListener == null) { return; } onURLClickListeners.remove(onURLClickListener); } public void clearOnURLClickListeners() { onURLClickListeners.clear(); } public void addOnEmailClickListener(final OnEmailClickListener onEmailClickListener) { if (onEmailClickListener == null) { return; } onEmailClickListeners.add(onEmailClickListener); } public void removeOnEmailClickListener(final OnEmailClickListener onEmailClickListener) { if (onEmailClickListener == null) { return; } onEmailClickListeners.remove(onEmailClickListener); } public void clearOnEmailClickListeners() { onEmailClickListeners.clear(); } public void clearAllAutoLinkListeners() { clearOnMentionClickListeners(); clearOnHashtagClickListeners(); clearOnURLClickListeners(); clearOnEmailClickListeners(); } public interface OnMentionClickListener { void onMentionClick(final AutoLinkItem autoLinkItem); } public interface OnHashtagClickListener { void onHashtagClick(final AutoLinkItem autoLinkItem); } public interface OnURLClickListener { void onURLClick(final AutoLinkItem autoLinkItem); } public interface OnEmailClickListener { void onEmailClick(final AutoLinkItem autoLinkItem); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/ReactionEmojiTextView.java ================================================ package awais.instagrabber.customviews; import android.annotation.SuppressLint; import android.content.Context; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.emoji.widget.EmojiAppCompatTextView; import java.util.List; import java.util.stream.Collectors; public class ReactionEmojiTextView extends EmojiAppCompatTextView { private static final String TAG = ReactionEmojiTextView.class.getSimpleName(); private final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); private String count = ""; private SpannableString ellipsisSpannable; private String distinctEmojis; public ReactionEmojiTextView(final Context context) { super(context); init(); } public ReactionEmojiTextView(final Context context, final AttributeSet attrs) { super(context, attrs); init(); } public ReactionEmojiTextView(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { ellipsisSpannable = new SpannableString(count); } @SuppressLint("SetTextI18n") public void setEmojis(@NonNull final List emojis) { count = String.valueOf(emojis.size()); distinctEmojis = emojis.stream() .distinct() .collect(Collectors.joining()); ellipsisSpannable = new SpannableString(count); setText(distinctEmojis + (emojis.size() > 1 ? count : "")); } @Override protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); final CharSequence text = getText(); if (text == null) return; final int measuredWidth = getMeasuredWidth(); float availableTextWidth = measuredWidth - getCompoundPaddingLeft() - getCompoundPaddingRight(); CharSequence ellipsizedText = TextUtils.ellipsize(text, getPaint(), availableTextWidth, getEllipsize()); if (!ellipsizedText.toString().equals(text.toString())) { // If the ellipsizedText is different than the original text, this means that it didn't fit and got indeed ellipsized. // Calculate the new availableTextWidth by taking into consideration the size of the custom ellipsis, too. availableTextWidth = (availableTextWidth - getPaint().measureText(count)); ellipsizedText = TextUtils.ellipsize(text, getPaint(), availableTextWidth, getEllipsize()); final int defaultEllipsisStart = ellipsizedText.toString().indexOf(getDefaultEllipsis()); final int defaultEllipsisEnd = defaultEllipsisStart + 1; spannableStringBuilder.clear(); // Update the text with the ellipsized version and replace the default ellipsis with the custom one. final SpannableStringBuilder replace = spannableStringBuilder.append(ellipsizedText) .replace(defaultEllipsisStart, defaultEllipsisEnd, ellipsisSpannable); setText(replace); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } } private char getDefaultEllipsis() { return '…'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/RecordButton.java ================================================ package awais.instagrabber.customviews; import android.annotation.SuppressLint; import android.content.Context; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import com.google.android.material.button.MaterialButton; import awais.instagrabber.animations.ScaleAnimation; /** * Created by Devlomi on 13/12/2017. */ public class RecordButton extends MaterialButton implements View.OnTouchListener, View.OnClickListener, View.OnLongClickListener { private ScaleAnimation scaleAnimation; private RecordView recordView; private boolean listenForRecord = true; private OnRecordClickListener onRecordClickListener; private OnRecordLongClickListener onRecordLongClickListener; public RecordButton(Context context) { super(context); init(context, null); } public RecordButton(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public RecordButton(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } @SuppressLint("ClickableViewAccessibility") private void init(Context context, AttributeSet attrs) { scaleAnimation = new ScaleAnimation(this); this.setOnTouchListener(this); this.setOnClickListener(this); this.setOnLongClickListener(this); } public void setRecordView(RecordView recordView) { this.recordView = recordView; } @Override public boolean onTouch(View v, MotionEvent event) { if (isListenForRecord()) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: recordView.onActionDown((RecordButton) v, event); break; case MotionEvent.ACTION_MOVE: recordView.onActionMove((RecordButton) v, event, false); break; case MotionEvent.ACTION_UP: recordView.onActionUp((RecordButton) v); break; } } return isListenForRecord(); } protected void startScale() { scaleAnimation.start(); } public void stopScale() { scaleAnimation.stop(); } public void setListenForRecord(boolean listenForRecord) { this.listenForRecord = listenForRecord; } public boolean isListenForRecord() { return listenForRecord; } public void setOnRecordClickListener(OnRecordClickListener onRecordClickListener) { this.onRecordClickListener = onRecordClickListener; } public void setOnRecordLongClickListener(OnRecordLongClickListener onRecordLongClickListener) { this.onRecordLongClickListener = onRecordLongClickListener; } @Override public void onClick(View v) { if (onRecordClickListener != null) { onRecordClickListener.onClick(v); } } @Override public boolean onLongClick(final View v) { if (onRecordLongClickListener != null) { return onRecordLongClickListener.onLongClick(v); } return false; } public interface OnRecordClickListener { void onClick(View v); } public interface OnRecordLongClickListener { boolean onLongClick(View v); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/RecordView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.content.res.AssetFileDescriptor; import android.content.res.TypedArray; import android.graphics.drawable.Drawable; import android.media.MediaPlayer; import android.os.SystemClock; import android.util.AttributeSet; import android.util.Log; import android.view.LayoutInflater; import android.view.MotionEvent; import android.widget.RelativeLayout; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.graphics.drawable.DrawableCompat; import java.io.IOException; import awais.instagrabber.R; import awais.instagrabber.customviews.helpers.RecordViewAnimationHelper; import awais.instagrabber.databinding.RecordViewLayoutBinding; import awais.instagrabber.utils.Utils; /** * Created by Devlomi on 24/08/2017. */ public class RecordView extends RelativeLayout { private static final String TAG = RecordView.class.getSimpleName(); public static final int DEFAULT_CANCEL_BOUNDS = 8; //8dp // private ImageView smallBlinkingMic; // private ImageView basketImg; // private Chronometer counterTime; // private TextView slideToCancel; // private LinearLayout slideToCancelLayout; private float initialX; private float basketInitialY; private float difX = 0; private float cancelBounds = DEFAULT_CANCEL_BOUNDS; private long startTime; private final Context context; private OnRecordListener onRecordListener; private boolean isSwiped; private boolean isLessThanMinAllowed = false; private boolean isSoundEnabled = true; private int RECORD_START = R.raw.record_start; private int RECORD_FINISHED = R.raw.record_finished; private int RECORD_ERROR = R.raw.record_error; private RecordViewAnimationHelper recordViewAnimationHelper; private RecordViewLayoutBinding binding; private int minMillis = 1000; public RecordView(Context context) { super(context); this.context = context; init(context, null, -1, -1); } public RecordView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); this.context = context; init(context, attrs, -1, -1); } public RecordView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); this.context = context; init(context, attrs, defStyleAttr, -1); } private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { binding = RecordViewLayoutBinding.inflate(LayoutInflater.from(context), this, false); addView(binding.getRoot()); hideViews(true); if (attrs != null && defStyleAttr == -1 && defStyleRes == -1) { TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.RecordView, defStyleAttr, defStyleRes); int slideArrowResource = typedArray.getResourceId(R.styleable.RecordView_slide_to_cancel_arrow, -1); String slideToCancelText = typedArray.getString(R.styleable.RecordView_slide_to_cancel_text); int slideToCancelTextColor = typedArray.getResourceId(R.styleable.RecordView_slide_to_cancel_text_color, -1); int slideMarginRight = (int) typedArray.getDimension(R.styleable.RecordView_slide_to_cancel_margin_right, 30); int counterTimeColor = typedArray.getResourceId(R.styleable.RecordView_counter_time_color, -1); int arrowColor = typedArray.getResourceId(R.styleable.RecordView_slide_to_cancel_arrow_color, -1); int cancelBounds = typedArray.getDimensionPixelSize(R.styleable.RecordView_slide_to_cancel_bounds, -1); if (cancelBounds != -1) { setCancelBounds(cancelBounds, false);//don't convert it to pixels since it's already in pixels } if (slideToCancelText != null) { setSlideToCancelText(slideToCancelText); } if (slideToCancelTextColor != -1) { setSlideToCancelTextColor(getResources().getColor(slideToCancelTextColor)); } if (slideArrowResource != -1) { setSlideArrowDrawable(slideArrowResource); } if (arrowColor != -1) { setSlideToCancelArrowColor(getResources().getColor(arrowColor)); } if (counterTimeColor != -1) { setCounterTimeColor(getResources().getColor(counterTimeColor)); } setMarginRight(slideMarginRight, true); typedArray.recycle(); } recordViewAnimationHelper = new RecordViewAnimationHelper(context, binding.basketImg, binding.glowingMic); } private void hideViews(boolean hideSmallMic) { binding.slideToCancel.setVisibility(GONE); binding.basketImg.setVisibility(GONE); binding.counterTv.setVisibility(GONE); if (hideSmallMic) { binding.glowingMic.setVisibility(GONE); } } private void showViews() { binding.slideToCancel.setVisibility(VISIBLE); binding.glowingMic.setVisibility(VISIBLE); binding.counterTv.setVisibility(VISIBLE); } private boolean isLessThanMin(long time) { return time <= minMillis; } private void playSound(int soundRes) { if (!isSoundEnabled) return; if (soundRes == 0) return; try { final MediaPlayer player = new MediaPlayer(); AssetFileDescriptor afd = context.getResources().openRawResourceFd(soundRes); if (afd == null) return; player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); afd.close(); player.prepare(); player.start(); player.setOnCompletionListener(MediaPlayer::release); player.setLooping(false); } catch (IOException e) { Log.e(TAG, "playSound", e); } } protected void onActionDown(RecordButton recordBtn, MotionEvent motionEvent) { if (onRecordListener != null) { onRecordListener.onStart(); } recordViewAnimationHelper.setStartRecorded(true); recordViewAnimationHelper.resetBasketAnimation(); recordViewAnimationHelper.resetSmallMic(); recordBtn.startScale(); // slideToCancelLayout.startShimmerAnimation(); initialX = recordBtn.getX(); basketInitialY = binding.basketImg.getY() + 90; // playSound(RECORD_START); showViews(); recordViewAnimationHelper.animateSmallMicAlpha(); binding.counterTv.setBase(SystemClock.elapsedRealtime()); startTime = System.currentTimeMillis(); binding.counterTv.start(); isSwiped = false; } protected void onActionMove(RecordButton recordBtn, MotionEvent motionEvent, final boolean forceCancel) { long time = System.currentTimeMillis() - startTime; if (isSwiped) return; //Swipe To Cancel if (forceCancel || (binding.slideToCancel.getX() != 0 && binding.slideToCancel.getX() <= binding.counterTv.getRight() + cancelBounds)) { //if the time was less than one second then do not start basket animation if (isLessThanMin(time)) { hideViews(true); recordViewAnimationHelper.clearAlphaAnimation(false); if (onRecordListener != null) { onRecordListener.onLessThanMin(); } recordViewAnimationHelper.onAnimationEnd(); } else { hideViews(false); recordViewAnimationHelper.animateBasket(basketInitialY); } recordViewAnimationHelper.moveRecordButtonAndSlideToCancelBack(recordBtn, binding.slideToCancel, initialX, difX); binding.counterTv.stop(); // slideToCancelLayout.stopShimmerAnimation(); isSwiped = true; recordViewAnimationHelper.setStartRecorded(false); if (onRecordListener != null) { onRecordListener.onCancel(); } return; } //if statement is to Prevent Swiping out of bounds if (!(motionEvent.getRawX() < initialX)) return; recordBtn.animate() .x(motionEvent.getRawX()) .setDuration(0) .start(); if (difX == 0) { difX = (initialX - binding.slideToCancel.getX()); } binding.slideToCancel.animate() .x(motionEvent.getRawX() - difX) .setDuration(0) .start(); } protected void onActionUp(RecordButton recordBtn) { final long elapsedTime = System.currentTimeMillis() - startTime; if (!isLessThanMinAllowed && isLessThanMin(elapsedTime) && !isSwiped) { if (onRecordListener != null) { onRecordListener.onLessThanMin(); } recordViewAnimationHelper.setStartRecorded(false); // playSound(RECORD_ERROR); } else { if (onRecordListener != null && !isSwiped) { onRecordListener.onFinish(elapsedTime); } recordViewAnimationHelper.setStartRecorded(false); if (!isSwiped) { // playSound(RECORD_FINISHED); } } //if user has swiped then do not hide SmallMic since it will be hidden after swipe Animation hideViews(!isSwiped); if (!isSwiped) { recordViewAnimationHelper.clearAlphaAnimation(true); } recordViewAnimationHelper.moveRecordButtonAndSlideToCancelBack(recordBtn, binding.slideToCancel, initialX, difX); binding.counterTv.stop(); // slideToCancelLayout.stopShimmerAnimation(); } private void setMarginRight(int marginRight, boolean convertToDp) { ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) binding.slideToCancel.getLayoutParams(); if (convertToDp) { layoutParams.rightMargin = Utils.convertDpToPx(marginRight); } else { layoutParams.rightMargin = marginRight; } binding.slideToCancel.setLayoutParams(layoutParams); } private void setSlideArrowDrawable(@DrawableRes final int slideArrowResource) { Drawable slideArrow = AppCompatResources.getDrawable(getContext(), slideArrowResource); // Log.d(TAG, "setSlideArrowDrawable: slideArrow: " + slideArrow); if (slideArrow == null) return; slideArrow.setBounds(0, 0, slideArrow.getIntrinsicWidth(), slideArrow.getIntrinsicHeight()); binding.slideToCancel.setCompoundDrawablesRelative(slideArrow, null, null, null); } public void setOnRecordListener(OnRecordListener onRecordListener) { this.onRecordListener = onRecordListener; } public void setOnBasketAnimationEndListener(OnBasketAnimationEnd onBasketAnimationEndListener) { recordViewAnimationHelper.setOnBasketAnimationEndListener(onBasketAnimationEndListener); } public void setSoundEnabled(boolean isEnabled) { isSoundEnabled = isEnabled; } public void setLessThanMinAllowed(boolean isAllowed) { isLessThanMinAllowed = isAllowed; } public void setSlideToCancelText(String text) { binding.slideToCancel.setText(text); } public void setSlideToCancelTextColor(int color) { binding.slideToCancel.setTextColor(color); } public void setSmallMicColor(int color) { binding.glowingMic.setColorFilter(color); } public void setSmallMicIcon(int icon) { binding.glowingMic.setImageResource(icon); } public void setSlideMarginRight(int marginRight) { setMarginRight(marginRight, true); } public void setCustomSounds(int startSound, int finishedSound, int errorSound) { //0 means do not play sound RECORD_START = startSound; RECORD_FINISHED = finishedSound; RECORD_ERROR = errorSound; } public float getCancelBounds() { return cancelBounds; } public void setCancelBounds(float cancelBounds) { setCancelBounds(cancelBounds, true); } //set Chronometer color public void setCounterTimeColor(@ColorInt int color) { binding.counterTv.setTextColor(color); } public void setSlideToCancelArrowColor(@ColorInt int color) { Drawable drawable = binding.slideToCancel.getCompoundDrawablesRelative()[0]; drawable = DrawableCompat.wrap(drawable); DrawableCompat.setTint(drawable.mutate(), color); binding.slideToCancel.setCompoundDrawablesRelative(drawable, null, null, null); } private void setCancelBounds(float cancelBounds, boolean convertDpToPixel) { this.cancelBounds = convertDpToPixel ? Utils.convertDpToPx(cancelBounds) : cancelBounds; } public void setMinMillis(final int minMillis) { this.minMillis = minMillis; } public void cancelRecording(final RecordButton recordBtn) { onActionMove(recordBtn, null, true); } public interface OnBasketAnimationEnd { void onAnimationEnd(); } public interface OnRecordListener { void onStart(); void onCancel(); void onFinish(long recordTime); void onLessThanMin(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/SharedElementTransitionDialogFragment.java ================================================ package awais.instagrabber.customviews; import android.animation.Animator; import android.graphics.Rect; import android.os.Handler; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.animation.AccelerateDecelerateInterpolator; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; import androidx.transition.ChangeBounds; import androidx.transition.ChangeTransform; import androidx.transition.Transition; import androidx.transition.TransitionListenerAdapter; import androidx.transition.TransitionManager; import androidx.transition.TransitionSet; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import awais.instagrabber.utils.Utils; public abstract class SharedElementTransitionDialogFragment extends DialogFragment { // private static final String TAG = "SETDialogFragment"; private static final int DURATION = 200; private final Map startViews = new HashMap<>(); private final Map destViews = new HashMap<>(); private final Map viewBoundsMap = new HashMap<>(); private final List additionalAnimators = new ArrayList<>(); private final Handler initialBoundsHandler = new Handler(); private boolean startCalled = false; private boolean startInitiated = false; private int boundsCalculatedCount = 0; protected int getAnimationDuration() { return DURATION; } public void addSharedElement(@NonNull final View startView, @NonNull final View destView) { final int key = destView.hashCode(); startViews.put(key, startView); destViews.put(key, destView); setupInitialBounds(startView, destView); // final View view = getView(); // if (view == null) return; // view.post(() -> {}); } public void startPostponedEnterTransition() { startCalled = true; if (startInitiated) return; if (boundsCalculatedCount < startViews.size()) return; startInitiated = true; final Set keySet = startViews.keySet(); final View view = getView(); if (!(view instanceof ViewGroup)) return; final TransitionSet transitionSet = new TransitionSet() .setDuration(DURATION) .setInterpolator(new AccelerateDecelerateInterpolator()) .addTransition(new ChangeBounds()) .addTransition(new ChangeTransform()) .addListener(new TransitionListenerAdapter() { @Override public void onTransitionStart(@NonNull final Transition transition) { for (Animator animator : additionalAnimators) { animator.start(); } } @Override public void onTransitionEnd(@NonNull final Transition transition) { for (final Integer key : keySet) { final View startView = startViews.get(key); final View destView = destViews.get(key); final ViewBounds viewBounds = viewBoundsMap.get(key); if (startView == null || destView == null || viewBounds == null) return; onEndSharedElementAnimation(startView, destView, viewBounds); } } }); view.post(() -> { TransitionManager.beginDelayedTransition((ViewGroup) view, transitionSet); for (final Integer key : keySet) { final View startView = startViews.get(key); final View destView = destViews.get(key); final ViewBounds viewBounds = viewBoundsMap.get(key); if (startView == null || destView == null || viewBounds == null) return; onBeforeSharedElementAnimation(startView, destView, viewBounds); setDestBounds(key); } }); } private void setDestBounds(final int key) { final View startView = startViews.get(key); if (startView == null) return; final View destView = destViews.get(key); if (destView == null) return; final ViewBounds viewBounds = viewBoundsMap.get(key); if (viewBounds == null) return; destView.setX((int) viewBounds.getDestX()); destView.setY((int) viewBounds.getDestY()); destView.setTranslationX(0); destView.setTranslationY(0); final ViewGroup.LayoutParams layoutParams = destView.getLayoutParams(); layoutParams.height = viewBounds.getDestHeight(); layoutParams.width = viewBounds.getDestWidth(); destView.requestLayout(); } protected void onBeforeSharedElementAnimation(@NonNull final View startView, @NonNull final View destView, @NonNull final ViewBounds viewBounds) {} protected void onEndSharedElementAnimation(@NonNull final View startView, @NonNull final View destView, @NonNull final ViewBounds viewBounds) {} private void setupInitialBounds(@NonNull final View startView, @NonNull final View destView) { final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() { private boolean firstPassDone; @Override public boolean onPreDraw() { destView.getViewTreeObserver().removeOnPreDrawListener(this); if (!firstPassDone) { getViewBounds(startView, destView, this); firstPassDone = true; return false; } final int[] location = new int[2]; startView.getLocationOnScreen(location); final int initX = location[0]; final int initY = location[1]; destView.setX(initX); destView.setY(initY - Utils.getStatusBarHeight(getContext())); boundsCalculatedCount++; if (startCalled) { startPostponedEnterTransition(); } return true; } }; destView.getViewTreeObserver().addOnPreDrawListener(preDrawListener); } private void getViewBounds(@NonNull final View startView, @NonNull final View destView, @NonNull final ViewTreeObserver.OnPreDrawListener preDrawListener) { final ViewBounds viewBounds = new ViewBounds(); viewBounds.setDestWidth(destView.getWidth()); viewBounds.setDestHeight(destView.getHeight()); viewBounds.setDestX(destView.getX()); viewBounds.setDestY(destView.getY()); final Rect destBounds = new Rect(); destView.getDrawingRect(destBounds); viewBounds.setDestBounds(destBounds); final ViewGroup.LayoutParams layoutParams = destView.getLayoutParams(); final Rect startBounds = new Rect(); startView.getDrawingRect(startBounds); viewBounds.setStartBounds(startBounds); final int key = destView.hashCode(); viewBoundsMap.put(key, viewBounds); layoutParams.height = startView.getHeight(); layoutParams.width = startView.getWidth(); destView.getViewTreeObserver().addOnPreDrawListener(preDrawListener); destView.requestLayout(); } // private void animateBounds(@NonNull final View startView, // @NonNull final View destView, // @NonNull final ViewBounds viewBounds) { // final ValueAnimator heightAnimator = ObjectAnimator.ofInt(startView.getHeight(), viewBounds.getDestHeight()); // final ValueAnimator widthAnimator = ObjectAnimator.ofInt(startView.getWidth(), viewBounds.getDestWidth()); // heightAnimator.setDuration(DURATION); // widthAnimator.setDuration(DURATION); // additionalAnimators.add(heightAnimator); // additionalAnimators.add(widthAnimator); // heightAnimator.addUpdateListener(animation -> { // ViewGroup.LayoutParams params = destView.getLayoutParams(); // params.height = (int) animation.getAnimatedValue(); // destView.requestLayout(); // }); // widthAnimator.addUpdateListener(animation -> { // ViewGroup.LayoutParams params = destView.getLayoutParams(); // params.width = (int) animation.getAnimatedValue(); // destView.requestLayout(); // }); // onBeforeSharedElementAnimation(startView, destView, viewBounds); // final float destX = viewBounds.getDestX(); // final float destY = viewBounds.getDestY(); // final AnimatorSet animatorSet = new AnimatorSet(); // animatorSet.addListener(new AnimatorListenerAdapter() { // @Override // public void onAnimationEnd(final Animator animation) { // animationEnded(startView, destView, viewBounds); // } // }); // // destView.animate() // .x(destX) // .y(destY) // .setDuration(DURATION) // .withStartAction(() -> { // if (!additionalAnimatorsStarted && additionalAnimators.size() > 0) { // additionalAnimatorsStarted = true; // animatorSet.playTogether(additionalAnimators); // animatorSet.start(); // } // }) // .withEndAction(() -> animationEnded(startView, destView, viewBounds)) // .start(); // } // private int endCount = 0; // private void animationEnded(final View startView, final View destView, final ViewBounds viewBounds) { // ++endCount; // if (endCount != startViews.size() + 1) return; // onEndSharedElementAnimation(startView, destView, viewBounds); // } protected void addAnimator(@NonNull final Animator animator) { additionalAnimators.add(animator); } protected static class ViewBounds { private float destY; private float destX; private int destHeight; private int destWidth; private Rect startBounds; private Rect destBounds; public ViewBounds() {} public float getDestY() { return destY; } public void setDestY(final float destY) { this.destY = destY; } public float getDestX() { return destX; } public void setDestX(final float destX) { this.destX = destX; } public int getDestHeight() { return destHeight; } public void setDestHeight(final int destHeight) { this.destHeight = destHeight; } public int getDestWidth() { return destWidth; } public void setDestWidth(final int destWidth) { this.destWidth = destWidth; } public Rect getStartBounds() { return startBounds; } public void setStartBounds(final Rect startBounds) { this.startBounds = startBounds; } public Rect getDestBounds() { return destBounds; } public void setDestBounds(final Rect destBounds) { this.destBounds = destBounds; } } @Override public void onDestroyView() { super.onDestroyView(); startViews.clear(); destViews.clear(); viewBoundsMap.clear(); additionalAnimators.clear(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/SquareImageView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.util.AttributeSet; import androidx.appcompat.widget.AppCompatImageView; public class SquareImageView extends AppCompatImageView { public SquareImageView(final Context context) { super(context); } public SquareImageView(final Context context, final AttributeSet attrs) { super(context, attrs); } public SquareImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //noinspection SuspiciousNameCombination super.onMeasure(widthMeasureSpec, widthMeasureSpec); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/TextViewDrawableSize.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.emoji.widget.EmojiAppCompatTextView; import awais.instagrabber.R; /** * https://stackoverflow.com/a/31916731 */ public class TextViewDrawableSize extends EmojiAppCompatTextView { private int mDrawableWidth; private int mDrawableHeight; private boolean calledFromInit = false; public TextViewDrawableSize(final Context context) { this(context, null); } public TextViewDrawableSize(final Context context, final AttributeSet attrs) { this(context, attrs, 0); } public TextViewDrawableSize(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr); } private void init(@NonNull final Context context, final AttributeSet attrs, final int defStyleAttr) { final TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.TextViewDrawableSize, defStyleAttr, 0); try { mDrawableWidth = array.getDimensionPixelSize(R.styleable.TextViewDrawableSize_compoundDrawableWidth, -1); mDrawableHeight = array.getDimensionPixelSize(R.styleable.TextViewDrawableSize_compoundDrawableHeight, -1); } finally { array.recycle(); } if (mDrawableWidth > 0 || mDrawableHeight > 0) { initCompoundDrawableSize(); } } private void initCompoundDrawableSize() { final Drawable[] drawables = getCompoundDrawablesRelative(); for (Drawable drawable : drawables) { if (drawable == null) { continue; } final Rect realBounds = drawable.getBounds(); float scaleFactor = drawable.getIntrinsicHeight() / (float) drawable.getIntrinsicWidth(); float drawableWidth = drawable.getIntrinsicWidth(); float drawableHeight = drawable.getIntrinsicHeight(); if (mDrawableWidth > 0) { // save scale factor of image if (drawableWidth > mDrawableWidth) { drawableWidth = mDrawableWidth; drawableHeight = drawableWidth * scaleFactor; } } if (mDrawableHeight > 0) { // save scale factor of image if (drawableHeight > mDrawableHeight) { drawableHeight = mDrawableHeight; drawableWidth = drawableHeight / scaleFactor; } } realBounds.right = realBounds.left + Math.round(drawableWidth); realBounds.bottom = realBounds.top + Math.round(drawableHeight); drawable.setBounds(realBounds); } setCompoundDrawablesRelative(drawables[0], drawables[1], drawables[2], drawables[3]); } public void setCompoundDrawablesRelativeWithSize(@Nullable final Drawable start, @Nullable final Drawable top, @Nullable final Drawable end, @Nullable final Drawable bottom) { setCompoundDrawablesRelative(start, top, end, bottom); initCompoundDrawableSize(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/Tooltip.java ================================================ package awais.instagrabber.customviews; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.content.Context; import android.util.TypedValue; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.view.ViewPropertyAnimator; import androidx.annotation.NonNull; import androidx.appcompat.widget.AppCompatTextView; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.ViewUtils; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; public class Tooltip extends AppCompatTextView { private View anchor; private ViewPropertyAnimator animator; private boolean showing; private final AppExecutors appExecutors = AppExecutors.INSTANCE; private final Runnable dismissRunnable = () -> { animator = animate().alpha(0).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { setVisibility(View.GONE); } }).setDuration(300); animator.start(); }; public Tooltip(@NonNull Context context, @NonNull ViewGroup parentView, int backgroundColor, int textColor) { super(context); setBackgroundDrawable(ViewUtils.createRoundRectDrawable(Utils.convertDpToPx(3), backgroundColor)); setTextColor(textColor); setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14); setPadding(Utils.convertDpToPx(8), Utils.convertDpToPx(7), Utils.convertDpToPx(8), Utils.convertDpToPx(7)); setGravity(Gravity.CENTER_VERTICAL); parentView.addView(this, ViewUtils.createFrame(WRAP_CONTENT, WRAP_CONTENT, Gravity.START | Gravity.TOP, 5, 0, 5, 3)); setVisibility(GONE); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); updateTooltipPosition(); } private void updateTooltipPosition() { if (anchor == null) { return; } int top = 0; int left = 0; View containerView = (View) getParent(); View view = anchor; while (view != containerView) { top += view.getTop(); left += view.getLeft(); view = (View) view.getParent(); } int x = left + anchor.getWidth() / 2 - getMeasuredWidth() / 2; if (x < 0) { x = 0; } else if (x + getMeasuredWidth() > containerView.getMeasuredWidth()) { x = containerView.getMeasuredWidth() - getMeasuredWidth() - Utils.convertDpToPx(16); } setTranslationX(x); int y = top - getMeasuredHeight(); setTranslationY(y); } public void show(View anchor) { if (anchor == null) { return; } this.anchor = anchor; updateTooltipPosition(); showing = true; appExecutors.getMainThread().cancel(dismissRunnable); appExecutors.getMainThread().execute(dismissRunnable, 2000); if (animator != null) { animator.setListener(null); animator.cancel(); animator = null; } if (getVisibility() != VISIBLE) { setAlpha(0f); setVisibility(VISIBLE); animator = animate().setDuration(300).alpha(1f).setListener(null); animator.start(); } } public void hide() { if (showing) { if (animator != null) { animator.setListener(null); animator.cancel(); animator = null; } appExecutors.getMainThread().cancel(dismissRunnable); dismissRunnable.run(); } showing = false; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/UsernameTextView.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.graphics.drawable.Drawable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.util.AttributeSet; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.AppCompatTextView; import awais.instagrabber.R; import awais.instagrabber.utils.Utils; public class UsernameTextView extends AppCompatTextView { private static final String TAG = UsernameTextView.class.getSimpleName(); private final int drawableSize = Utils.convertDpToPx(24); private boolean verified; private VerticalImageSpan verifiedSpan; public UsernameTextView(@NonNull final Context context) { this(context, null); } public UsernameTextView(@NonNull final Context context, @Nullable final AttributeSet attrs) { this(context, attrs, 0); } public UsernameTextView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { try { final Drawable verifiedDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.verified); final Drawable drawable = verifiedDrawable.mutate(); drawable.setBounds(0, 0, drawableSize, drawableSize); verifiedSpan = new VerticalImageSpan(drawable); } catch (Exception e) { Log.e(TAG, "init: ", e); } } public void setUsername(final CharSequence username) { setUsername(username, false); } public void setUsername(final CharSequence username, final boolean verified) { this.verified = verified; final SpannableStringBuilder sb = new SpannableStringBuilder(username); if (verified) { try { if (verifiedSpan != null) { sb.append(" "); sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } catch (Exception e) { Log.e(TAG, "bind: ", e); } } super.setText(sb); } public boolean isVerified() { return verified; } public void setVerified(final boolean verified) { setUsername(getText(), verified); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/VerticalDragHelper.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; import android.view.ViewParent; import androidx.annotation.NonNull; public class VerticalDragHelper { // private static final String TAG = "VerticalDragHelper"; private static final double SWIPE_THRESHOLD_VELOCITY = 80; private final View view; private GestureDetector gestureDetector; private Context context; private double flingVelocity; private OnVerticalDragListener onVerticalDragListener; private final GestureDetector.OnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onSingleTapConfirmed(final MotionEvent e) { view.performClick(); return true; } @Override public boolean onFling(final MotionEvent e1, final MotionEvent e2, final float velocityX, final float velocityY) { double yDir = e1.getY() - e2.getY(); // Log.d(TAG, "onFling: yDir: " + yDir); if (yDir < -SWIPE_THRESHOLD_VELOCITY || yDir > SWIPE_THRESHOLD_VELOCITY) { flingVelocity = yDir; } return super.onFling(e1, e2, velocityX, velocityY); } }; private float prevRawY; private boolean isDragging; private float prevRawX; private float dX; private float prevDY; public VerticalDragHelper(@NonNull final View view) { this.view = view; final Context context = view.getContext(); if (context == null) return; this.context = context; init(); } public void setOnVerticalDragListener(@NonNull final OnVerticalDragListener onVerticalDragListener) { this.onVerticalDragListener = onVerticalDragListener; } protected void init() { gestureDetector = new GestureDetector(context, gestureListener); } public boolean onDragTouch(final MotionEvent event) { if (onVerticalDragListener == null) { return false; } if (gestureDetector.onTouchEvent(event)) { return true; } switch (event.getAction()) { case MotionEvent.ACTION_DOWN: return true; case MotionEvent.ACTION_MOVE: boolean handled = false; final float rawY = event.getRawY(); final float dY = rawY - prevRawY; if (!isDragging) { final float rawX = event.getRawX(); if (prevRawX != 0) { dX = rawX - prevRawX; } prevRawX = rawX; if (prevRawY != 0) { final float dYAbs = Math.abs(dY - prevDY); if (!isDragging && dYAbs < 50) { final float abs = Math.abs(dY) - Math.abs(dX); if (abs > 0) { isDragging = true; } } } } if (isDragging) { final ViewParent parent = view.getParent(); parent.requestDisallowInterceptTouchEvent(true); onVerticalDragListener.onDrag(dY); handled = true; } prevDY = dY; prevRawY = rawY; return handled; case MotionEvent.ACTION_UP: // Log.d(TAG, "onDragTouch: reset prevRawY"); prevRawY = 0; if (flingVelocity != 0) { onVerticalDragListener.onFling(flingVelocity); flingVelocity = 0; isDragging = false; return true; } if (isDragging) { onVerticalDragListener.onDragEnd(); isDragging = false; return true; } return false; default: return false; } } public boolean isDragging() { return isDragging; } public boolean onGestureTouchEvent(final MotionEvent event) { return gestureDetector.onTouchEvent(event); } public interface OnVerticalDragListener { void onDrag(final float dY); void onDragEnd(); void onFling(final double flingVelocity); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/VerticalImageSpan.java ================================================ package awais.instagrabber.customviews; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.text.style.ImageSpan; import androidx.annotation.NonNull; public class VerticalImageSpan extends ImageSpan { public VerticalImageSpan(final Drawable drawable) { super(drawable); } /** * update the text line height */ @Override public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fontMetricsInt) { Drawable drawable = getDrawable(); Rect rect = drawable.getBounds(); if (fontMetricsInt != null) { Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt(); int fontHeight = fmPaint.descent - fmPaint.ascent; int drHeight = rect.bottom - rect.top; int centerY = fmPaint.ascent + fontHeight / 2; fontMetricsInt.ascent = centerY - drHeight / 2; fontMetricsInt.top = fontMetricsInt.ascent; fontMetricsInt.bottom = centerY + drHeight / 2; fontMetricsInt.descent = fontMetricsInt.bottom; } return rect.right; } /** * see detail message in android.text.TextLine * * @param canvas the canvas, can be null if not rendering * @param text the text to be draw * @param start the text start position * @param end the text end position * @param x the edge of the replacement closest to the leading margin * @param top the top of the line * @param y the baseline * @param bottom the bottom of the line * @param paint the work paint */ @Override public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { Drawable drawable = getDrawable(); canvas.save(); Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt(); int fontHeight = fmPaint.descent - fmPaint.ascent; int centerY = y + fmPaint.descent - fontHeight / 2; int transY = centerY - (drawable.getBounds().bottom - drawable.getBounds().top) / 2; canvas.translate(x, transY); drawable.draw(canvas); canvas.restore(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/VideoPlayerCallbackAdapter.java ================================================ package awais.instagrabber.customviews; import com.google.android.exoplayer2.ui.StyledPlayerView; public class VideoPlayerCallbackAdapter implements VideoPlayerViewHelper.VideoPlayerCallback { @Override public void onThumbnailLoaded() {} @Override public void onThumbnailClick() {} @Override public void onPlayerViewLoaded() {} @Override public void onPlay() {} @Override public void onPause() {} @Override public void onRelease() {} @Override public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) {} @Override public boolean isInFullScreen() { return false; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/VideoPlayerViewHelper.java ================================================ package awais.instagrabber.customviews; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.Resources; import android.graphics.drawable.Animatable; import android.net.Uri; import android.os.Looper; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatImageButton; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.backends.pipeline.PipelineDraweeControllerBuilder; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.imagepipeline.image.ImageInfo; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioListener; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.StyledPlayerControlView; import com.google.android.exoplayer2.ui.StyledPlayerView; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import awais.instagrabber.R; import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; import awais.instagrabber.utils.Utils; public class VideoPlayerViewHelper implements Player.EventListener { private static final String TAG = VideoPlayerViewHelper.class.getSimpleName(); private final Context context; private final LayoutVideoPlayerWithThumbnailBinding binding; private final float initialVolume; private final float thumbnailAspectRatio; private final String thumbnailUrl; private final boolean loadPlayerOnClick; private final VideoPlayerCallback videoPlayerCallback; private final String videoUrl; private final DefaultDataSourceFactory dataSourceFactory; private SimpleExoPlayer player; private AppCompatImageButton mute; private final AudioListener audioListener = new AudioListener() { @Override public void onVolumeChanged(final float volume) { updateMuteIcon(volume); } }; private final View.OnClickListener muteOnClickListener = v -> toggleMute(); private Object layoutManager; public VideoPlayerViewHelper(@NonNull final Context context, @NonNull final LayoutVideoPlayerWithThumbnailBinding binding, @NonNull final String videoUrl, final float initialVolume, final float thumbnailAspectRatio, final String thumbnailUrl, final boolean loadPlayerOnClick, final VideoPlayerCallback videoPlayerCallback) { this.context = context; this.binding = binding; this.initialVolume = initialVolume; this.thumbnailAspectRatio = thumbnailAspectRatio; this.thumbnailUrl = thumbnailUrl; this.loadPlayerOnClick = loadPlayerOnClick; this.videoPlayerCallback = videoPlayerCallback; this.videoUrl = videoUrl; this.dataSourceFactory = new DefaultDataSourceFactory(binding.getRoot().getContext(), "instagram"); bind(); } private void bind() { binding.thumbnailParent.setOnClickListener(v -> { if (videoPlayerCallback != null) { videoPlayerCallback.onThumbnailClick(); } if (loadPlayerOnClick) { loadPlayer(); } }); setThumbnail(); } private void setThumbnail() { binding.thumbnail.setAspectRatio(thumbnailAspectRatio); ImageRequest thumbnailRequest = null; if (thumbnailUrl != null) { thumbnailRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(thumbnailUrl)).build(); } final PipelineDraweeControllerBuilder builder = Fresco .newDraweeControllerBuilder() .setControllerListener(new BaseControllerListener() { @Override public void onFailure(final String id, final Throwable throwable) { if (videoPlayerCallback != null) { videoPlayerCallback.onThumbnailLoaded(); } } @Override public void onFinalImageSet(final String id, final ImageInfo imageInfo, final Animatable animatable) { if (videoPlayerCallback != null) { videoPlayerCallback.onThumbnailLoaded(); } } }); if (thumbnailRequest != null) { builder.setImageRequest(thumbnailRequest); } binding.thumbnail.setController(builder.build()); } private void loadPlayer() { if (videoUrl == null) return; if (binding.getRoot().getDisplayedChild() == 0) { binding.getRoot().showNext(); } if (videoPlayerCallback != null) { videoPlayerCallback.onPlayerViewLoaded(); } player = (SimpleExoPlayer) binding.playerView.getPlayer(); if (player != null) { player.release(); } final ViewGroup.LayoutParams playerViewLayoutParams = binding.playerView.getLayoutParams(); if (playerViewLayoutParams.height > Utils.displayMetrics.heightPixels * 0.8) { playerViewLayoutParams.height = (int) (Utils.displayMetrics.heightPixels * 0.8); } player = new SimpleExoPlayer.Builder(context) .setLooper(Looper.getMainLooper()) .build(); player.addListener(this); player.addAudioListener(audioListener); player.setVolume(initialVolume); player.setPlayWhenReady(true); player.setRepeatMode(Player.REPEAT_MODE_ALL); final ProgressiveMediaSource.Factory sourceFactory = new ProgressiveMediaSource.Factory(dataSourceFactory); final MediaItem mediaItem = MediaItem.fromUri(videoUrl); final ProgressiveMediaSource mediaSource = sourceFactory.createMediaSource(mediaItem); player.setMediaSource(mediaSource); player.prepare(); binding.playerView.setPlayer(player); binding.playerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); binding.playerView.setShowNextButton(false); binding.playerView.setShowPreviousButton(false); binding.playerView.setControllerOnFullScreenModeChangedListener(isFullScreen -> { if (videoPlayerCallback == null) return; videoPlayerCallback.onFullScreenModeChanged(isFullScreen, binding.playerView); }); setupControllerView(); } private void setupControllerView() { try { final StyledPlayerControlView controllerView = getStyledPlayerControlView(); if (controllerView == null) return; layoutManager = setControlViewLayoutManager(controllerView); if (videoPlayerCallback != null && videoPlayerCallback.isInFullScreen()) { setControllerViewToFullScreenMode(controllerView); } final ViewGroup exoBasicControls = controllerView.findViewById(R.id.exo_basic_controls); if (exoBasicControls == null) return; mute = new AppCompatImageButton(context); final Resources resources = context.getResources(); if (resources == null) return; final int width = resources.getDimensionPixelSize(R.dimen.exo_small_icon_width); final int height = resources.getDimensionPixelSize(R.dimen.exo_small_icon_height); final int margin = resources.getDimensionPixelSize(R.dimen.exo_small_icon_horizontal_margin); final int paddingHorizontal = resources.getDimensionPixelSize(R.dimen.exo_small_icon_padding_horizontal); final int paddingVertical = resources.getDimensionPixelSize(R.dimen.exo_small_icon_padding_vertical); final ViewGroup.MarginLayoutParams layoutParams = new ViewGroup.MarginLayoutParams(width, height); layoutParams.setMargins(margin, 0, margin, 0); mute.setLayoutParams(layoutParams); mute.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical); mute.setScaleType(ImageView.ScaleType.FIT_XY); mute.setBackgroundResource(Utils.getAttrResId(context, android.R.attr.selectableItemBackground)); mute.setImageTintList(ColorStateList.valueOf(resources.getColor(R.color.white))); updateMuteIcon(player.getVolume()); exoBasicControls.addView(mute, 0); mute.setOnClickListener(muteOnClickListener); } catch (Exception e) { Log.e(TAG, "loadPlayer: ", e); } } @Nullable private Object setControlViewLayoutManager(@NonNull final StyledPlayerControlView controllerView) throws NoSuchFieldException, IllegalAccessException { final Field controlViewLayoutManagerField = controllerView.getClass().getDeclaredField("controlViewLayoutManager"); controlViewLayoutManagerField.setAccessible(true); return controlViewLayoutManagerField.get(controllerView); } private void setControllerViewToFullScreenMode(@NonNull final StyledPlayerControlView controllerView) throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { // Exoplayer doesn't expose the fullscreen state, so using reflection final Field fullScreenButtonField = controllerView.getClass().getDeclaredField("fullScreenButton"); fullScreenButtonField.setAccessible(true); final ImageView fullScreenButton = (ImageView) fullScreenButtonField.get(controllerView); final Field isFullScreen = controllerView.getClass().getDeclaredField("isFullScreen"); isFullScreen.setAccessible(true); isFullScreen.set(controllerView, true); final Method updateFullScreenButtonForState = controllerView .getClass() .getDeclaredMethod("updateFullScreenButtonForState", ImageView.class, boolean.class); updateFullScreenButtonForState.setAccessible(true); updateFullScreenButtonForState.invoke(controllerView, fullScreenButton, true); } @Nullable private StyledPlayerControlView getStyledPlayerControlView() throws NoSuchFieldException, IllegalAccessException { final Field controller = binding.playerView.getClass().getDeclaredField("controller"); controller.setAccessible(true); return (StyledPlayerControlView) controller.get(binding.playerView); } @Override public void onTracksChanged(@NonNull TrackGroupArray trackGroups, @NonNull TrackSelectionArray trackSelections) { if (trackGroups.isEmpty()) { setHasAudio(false); return; } boolean hasAudio = false; for (int i = 0; i < trackGroups.length; i++) { for (int g = 0; g < trackGroups.get(i).length; g++) { final String sampleMimeType = trackGroups.get(i).getFormat(g).sampleMimeType; if (sampleMimeType != null && sampleMimeType.contains("audio")) { hasAudio = true; break; } } } setHasAudio(hasAudio); } private void setHasAudio(final boolean hasAudio) { if (mute == null) return; mute.setEnabled(hasAudio); mute.setAlpha(hasAudio ? 1f : 0.5f); updateMuteIcon(hasAudio ? 1f : 0f); } private void updateMuteIcon(final float volume) { if (mute == null) return; if (volume == 0) { mute.setImageResource(R.drawable.ic_volume_off_24); return; } mute.setImageResource(R.drawable.ic_volume_up_24); } @Override public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason) { if (videoPlayerCallback == null) return; if (playWhenReady) { videoPlayerCallback.onPlay(); return; } videoPlayerCallback.onPause(); } @Override public void onPlayerError(@NonNull final ExoPlaybackException error) { Log.e(TAG, "onPlayerError", error); } private void toggleMute() { if (player == null) return; if (layoutManager != null) { try { final Method resetHideCallbacks = layoutManager.getClass().getDeclaredMethod("resetHideCallbacks"); resetHideCallbacks.invoke(layoutManager); } catch (Exception e) { Log.e(TAG, "toggleMute: ", e); } } final float vol = player.getVolume() == 0f ? 1f : 0f; player.setVolume(vol); } public void releasePlayer() { if (videoPlayerCallback != null) { videoPlayerCallback.onRelease(); } if (player != null) { player.release(); player = null; } } public void pause() { if (player != null) { player.pause(); } } public interface VideoPlayerCallback { void onThumbnailLoaded(); void onThumbnailClick(); void onPlayerViewLoaded(); void onPlay(); void onPause(); void onRelease(); void onFullScreenModeChanged(boolean isFullScreen, final StyledPlayerView playerView); boolean isInFullScreen(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/AbstractAnimatedZoomableController.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.graphics.Matrix; import android.graphics.PointF; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; /** * Abstract class for ZoomableController that adds animation capabilities to * DefaultZoomableController. */ public abstract class AbstractAnimatedZoomableController extends DefaultZoomableController { private boolean mIsAnimating; private final float[] mStartValues = new float[9]; private final float[] mStopValues = new float[9]; private final float[] mCurrentValues = new float[9]; private final Matrix mNewTransform = new Matrix(); private final Matrix mWorkingTransform = new Matrix(); public AbstractAnimatedZoomableController(TransformGestureDetector transformGestureDetector) { super(transformGestureDetector); } @Override public void reset() { FLog.v(getLogTag(), "reset"); stopAnimation(); mWorkingTransform.reset(); mNewTransform.reset(); super.reset(); } /** * Returns true if the zoomable transform is identity matrix, and the controller is idle. */ @Override public boolean isIdentity() { return !isAnimating() && super.isIdentity(); } /** * Zooms to the desired scale and positions the image so that the given image point corresponds to * the given view point. * *

If this method is called while an animation or gesture is already in progress, the current * animation or gesture will be stopped first. * * @param scale desired scale, will be limited to {min, max} scale factor * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) * @param viewPoint 2D point in view's absolute coordinate system */ @Override public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) { zoomToPoint(scale, imagePoint, viewPoint, LIMIT_ALL, 0, null); } /** * Zooms to the desired scale and positions the image so that the given image point corresponds to * the given view point. * *

If this method is called while an animation or gesture is already in progress, the current * animation or gesture will be stopped first. * * @param scale desired scale, will be limited to {min, max} scale factor * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) * @param viewPoint 2D point in view's absolute coordinate system * @param limitFlags whether to limit translation and/or scale. * @param durationMs length of animation of the zoom, or 0 if no animation desired * @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0 */ public void zoomToPoint( float scale, PointF imagePoint, PointF viewPoint, @LimitFlag int limitFlags, long durationMs, @Nullable Runnable onAnimationComplete) { FLog.v(getLogTag(), "zoomToPoint: duration %d ms", durationMs); calculateZoomToPointTransform(mNewTransform, scale, imagePoint, viewPoint, limitFlags); setTransform(mNewTransform, durationMs, onAnimationComplete); } /** * Sets a new zoomable transformation and animates to it if desired. * *

If this method is called while an animation or gesture is already in progress, the current * animation or gesture will be stopped first. * * @param newTransform new transform to make active * @param durationMs duration of the animation, or 0 to not animate * @param onAnimationComplete code to run when the animation completes. Ignored if durationMs=0 */ public void setTransform( Matrix newTransform, long durationMs, @Nullable Runnable onAnimationComplete) { FLog.v(getLogTag(), "setTransform: duration %d ms", durationMs); if (durationMs <= 0) { setTransformImmediate(newTransform); } else { setTransformAnimated(newTransform, durationMs, onAnimationComplete); } } private void setTransformImmediate(final Matrix newTransform) { FLog.v(getLogTag(), "setTransformImmediate"); stopAnimation(); mWorkingTransform.set(newTransform); super.setTransform(newTransform); getDetector().restartGesture(); } protected boolean isAnimating() { return mIsAnimating; } protected void setAnimating(boolean isAnimating) { mIsAnimating = isAnimating; } protected float[] getStartValues() { return mStartValues; } protected float[] getStopValues() { return mStopValues; } protected Matrix getWorkingTransform() { return mWorkingTransform; } @Override public void onGestureBegin(TransformGestureDetector detector) { FLog.v(getLogTag(), "onGestureBegin"); stopAnimation(); super.onGestureBegin(detector); } @Override public void onGestureUpdate(TransformGestureDetector detector) { FLog.v(getLogTag(), "onGestureUpdate %s", isAnimating() ? "(ignored)" : ""); if (isAnimating()) { return; } super.onGestureUpdate(detector); } protected void calculateInterpolation(Matrix outMatrix, float fraction) { for (int i = 0; i < 9; i++) { mCurrentValues[i] = (1 - fraction) * mStartValues[i] + fraction * mStopValues[i]; } outMatrix.setValues(mCurrentValues); } public abstract void setTransformAnimated( final Matrix newTransform, long durationMs, @Nullable final Runnable onAnimationComplete); protected abstract void stopAnimation(); protected abstract Class getLogTag(); } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/AnimatedZoomableController.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.graphics.Matrix; import android.view.animation.DecelerateInterpolator; import androidx.annotation.Nullable; import com.facebook.common.internal.Preconditions; import com.facebook.common.logging.FLog; /** * ZoomableController that adds animation capabilities to DefaultZoomableController using standard * Android animation classes */ public class AnimatedZoomableController extends AbstractAnimatedZoomableController { private static final Class TAG = AnimatedZoomableController.class; private final ValueAnimator mValueAnimator; public static AnimatedZoomableController newInstance() { return new AnimatedZoomableController(TransformGestureDetector.newInstance()); } @SuppressLint("NewApi") public AnimatedZoomableController(TransformGestureDetector transformGestureDetector) { super(transformGestureDetector); mValueAnimator = ValueAnimator.ofFloat(0, 1); mValueAnimator.setInterpolator(new DecelerateInterpolator()); } @SuppressLint("NewApi") @Override public void setTransformAnimated( final Matrix newTransform, long durationMs, @Nullable final Runnable onAnimationComplete) { FLog.v(getLogTag(), "setTransformAnimated: duration %d ms", durationMs); stopAnimation(); Preconditions.checkArgument(durationMs > 0); Preconditions.checkState(!isAnimating()); setAnimating(true); mValueAnimator.setDuration(durationMs); getTransform().getValues(getStartValues()); newTransform.getValues(getStopValues()); mValueAnimator.addUpdateListener( new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { calculateInterpolation(getWorkingTransform(), (float) valueAnimator.getAnimatedValue()); AnimatedZoomableController.super.setTransform(getWorkingTransform()); } }); mValueAnimator.addListener( new AnimatorListenerAdapter() { @Override public void onAnimationCancel(Animator animation) { FLog.v(getLogTag(), "setTransformAnimated: animation cancelled"); onAnimationStopped(); } @Override public void onAnimationEnd(Animator animation) { FLog.v(getLogTag(), "setTransformAnimated: animation finished"); onAnimationStopped(); } private void onAnimationStopped() { if (onAnimationComplete != null) { onAnimationComplete.run(); } setAnimating(false); getDetector().restartGesture(); } }); mValueAnimator.start(); } @SuppressLint("NewApi") @Override public void stopAnimation() { if (!isAnimating()) { return; } FLog.v(getLogTag(), "stopAnimation"); mValueAnimator.cancel(); mValueAnimator.removeAllUpdateListeners(); mValueAnimator.removeAllListeners(); } @Override protected Class getLogTag() { return TAG; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/DefaultZoomableController.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.graphics.Matrix; import android.graphics.PointF; import android.graphics.RectF; import android.view.MotionEvent; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * Zoomable controller that calculates transformation based on touch events. */ public class DefaultZoomableController implements ZoomableController, TransformGestureDetector.Listener { /** * Interface for handling call backs when the image bounds are set. */ public interface ImageBoundsListener { void onImageBoundsSet(RectF imageBounds); } @IntDef( flag = true, value = {LIMIT_NONE, LIMIT_TRANSLATION_X, LIMIT_TRANSLATION_Y, LIMIT_SCALE, LIMIT_ALL}) @Retention(RetentionPolicy.SOURCE) public @interface LimitFlag {} public static final int LIMIT_NONE = 0; public static final int LIMIT_TRANSLATION_X = 1; public static final int LIMIT_TRANSLATION_Y = 2; public static final int LIMIT_SCALE = 4; public static final int LIMIT_ALL = LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y | LIMIT_SCALE; private static final float EPS = 1e-3f; private static final Class TAG = DefaultZoomableController.class; private static final RectF IDENTITY_RECT = new RectF(0, 0, 1, 1); private TransformGestureDetector mGestureDetector; private @Nullable ImageBoundsListener mImageBoundsListener; private @Nullable Listener mListener = null; private boolean mIsEnabled = false; private boolean mIsRotationEnabled = false; private boolean mIsScaleEnabled = true; private boolean mIsTranslationEnabled = true; private boolean mIsGestureZoomEnabled = true; private float mMinScaleFactor = 1.0f; private float mMaxScaleFactor = 2.0f; // View bounds, in view-absolute coordinates private final RectF mViewBounds = new RectF(); // Non-transformed image bounds, in view-absolute coordinates private final RectF mImageBounds = new RectF(); // Transformed image bounds, in view-absolute coordinates private final RectF mTransformedImageBounds = new RectF(); private final Matrix mPreviousTransform = new Matrix(); private final Matrix mActiveTransform = new Matrix(); private final Matrix mActiveTransformInverse = new Matrix(); private final float[] mTempValues = new float[9]; private final RectF mTempRect = new RectF(); private boolean mWasTransformCorrected; public static DefaultZoomableController newInstance() { return new DefaultZoomableController(TransformGestureDetector.newInstance()); } public DefaultZoomableController(TransformGestureDetector gestureDetector) { mGestureDetector = gestureDetector; mGestureDetector.setListener(this); } /** * Rests the controller. */ public void reset() { FLog.v(TAG, "reset"); mGestureDetector.reset(); mPreviousTransform.reset(); mActiveTransform.reset(); onTransformChanged(); } /** * Sets the zoomable listener. */ @Override public void setListener(Listener listener) { mListener = listener; } /** * Sets whether the controller is enabled or not. */ @Override public void setEnabled(boolean enabled) { mIsEnabled = enabled; if (!enabled) { reset(); } } /** * Gets whether the controller is enabled or not. */ @Override public boolean isEnabled() { return mIsEnabled; } /** * Sets whether the rotation gesture is enabled or not. */ public void setRotationEnabled(boolean enabled) { mIsRotationEnabled = enabled; } /** * Gets whether the rotation gesture is enabled or not. */ public boolean isRotationEnabled() { return mIsRotationEnabled; } /** * Sets whether the scale gesture is enabled or not. */ public void setScaleEnabled(boolean enabled) { mIsScaleEnabled = enabled; } /** * Gets whether the scale gesture is enabled or not. */ public boolean isScaleEnabled() { return mIsScaleEnabled; } /** * Sets whether the translation gesture is enabled or not. */ public void setTranslationEnabled(boolean enabled) { mIsTranslationEnabled = enabled; } /** * Gets whether the translations gesture is enabled or not. */ public boolean isTranslationEnabled() { return mIsTranslationEnabled; } /** * Sets the minimum scale factor allowed. * *

Hierarchy's scaling (if any) is not taken into account. */ public void setMinScaleFactor(float minScaleFactor) { mMinScaleFactor = minScaleFactor; } /** * Gets the minimum scale factor allowed. */ public float getMinScaleFactor() { return mMinScaleFactor; } /** * Sets the maximum scale factor allowed. * *

Hierarchy's scaling (if any) is not taken into account. */ public void setMaxScaleFactor(float maxScaleFactor) { mMaxScaleFactor = maxScaleFactor; } /** * Gets the maximum scale factor allowed. */ public float getMaxScaleFactor() { return mMaxScaleFactor; } /** * Sets whether gesture zooms are enabled or not. */ public void setGestureZoomEnabled(boolean isGestureZoomEnabled) { mIsGestureZoomEnabled = isGestureZoomEnabled; } /** * Gets whether gesture zooms are enabled or not. */ public boolean isGestureZoomEnabled() { return mIsGestureZoomEnabled; } /** * Gets the current scale factor. */ @Override public float getScaleFactor() { return getMatrixScaleFactor(mActiveTransform); } /** * Sets the image bounds, in view-absolute coordinates. */ @Override public void setImageBounds(RectF imageBounds) { if (!imageBounds.equals(mImageBounds)) { mImageBounds.set(imageBounds); onTransformChanged(); if (mImageBoundsListener != null) { mImageBoundsListener.onImageBoundsSet(mImageBounds); } } } /** * Gets the non-transformed image bounds, in view-absolute coordinates. */ public RectF getImageBounds() { return mImageBounds; } /** * Gets the transformed image bounds, in view-absolute coordinates */ private RectF getTransformedImageBounds() { return mTransformedImageBounds; } /** * Sets the view bounds. */ @Override public void setViewBounds(RectF viewBounds) { mViewBounds.set(viewBounds); } /** * Gets the view bounds. */ public RectF getViewBounds() { return mViewBounds; } /** * Sets the image bounds listener. */ public void setImageBoundsListener(@Nullable ImageBoundsListener imageBoundsListener) { mImageBoundsListener = imageBoundsListener; } /** * Gets the image bounds listener. */ public @Nullable ImageBoundsListener getImageBoundsListener() { return mImageBoundsListener; } /** * Returns true if the zoomable transform is identity matrix. */ @Override public boolean isIdentity() { return isMatrixIdentity(mActiveTransform, 1e-3f); } /** * Returns true if the transform was corrected during the last update. * *

We should rename this method to `wasTransformedWithoutCorrection` and just return the * internal flag directly. However, this requires interface change and negation of meaning. */ @Override public boolean wasTransformCorrected() { return mWasTransformCorrected; } /** * Gets the matrix that transforms image-absolute coordinates to view-absolute coordinates. The * zoomable transformation is taken into account. * *

Internal matrix is exposed for performance reasons and is not to be modified by the callers. */ @Override public Matrix getTransform() { return mActiveTransform; } /** * Gets the matrix that transforms image-relative coordinates to view-absolute coordinates. The * zoomable transformation is taken into account. */ public void getImageRelativeToViewAbsoluteTransform(Matrix outMatrix) { outMatrix.setRectToRect(IDENTITY_RECT, mTransformedImageBounds, Matrix.ScaleToFit.FILL); } /** * Maps point from view-absolute to image-relative coordinates. This takes into account the * zoomable transformation. */ public PointF mapViewToImage(PointF viewPoint) { float[] points = mTempValues; points[0] = viewPoint.x; points[1] = viewPoint.y; mActiveTransform.invert(mActiveTransformInverse); mActiveTransformInverse.mapPoints(points, 0, points, 0, 1); mapAbsoluteToRelative(points, points, 1); return new PointF(points[0], points[1]); } /** * Maps point from image-relative to view-absolute coordinates. This takes into account the * zoomable transformation. */ public PointF mapImageToView(PointF imagePoint) { float[] points = mTempValues; points[0] = imagePoint.x; points[1] = imagePoint.y; mapRelativeToAbsolute(points, points, 1); mActiveTransform.mapPoints(points, 0, points, 0, 1); return new PointF(points[0], points[1]); } /** * Maps array of 2D points from view-absolute to image-relative coordinates. This does NOT take * into account the zoomable transformation. Points are represented by a float array of [x0, y0, * x1, y1, ...]. * * @param destPoints destination array (may be the same as source array) * @param srcPoints source array * @param numPoints number of points to map */ private void mapAbsoluteToRelative(float[] destPoints, float[] srcPoints, int numPoints) { for (int i = 0; i < numPoints; i++) { destPoints[i * 2 + 0] = (srcPoints[i * 2 + 0] - mImageBounds.left) / mImageBounds.width(); destPoints[i * 2 + 1] = (srcPoints[i * 2 + 1] - mImageBounds.top) / mImageBounds.height(); } } /** * Maps array of 2D points from image-relative to view-absolute coordinates. This does NOT take * into account the zoomable transformation. Points are represented by float array of [x0, y0, x1, * y1, ...]. * * @param destPoints destination array (may be the same as source array) * @param srcPoints source array * @param numPoints number of points to map */ private void mapRelativeToAbsolute(float[] destPoints, float[] srcPoints, int numPoints) { for (int i = 0; i < numPoints; i++) { destPoints[i * 2 + 0] = srcPoints[i * 2 + 0] * mImageBounds.width() + mImageBounds.left; destPoints[i * 2 + 1] = srcPoints[i * 2 + 1] * mImageBounds.height() + mImageBounds.top; } } /** * Zooms to the desired scale and positions the image so that the given image point corresponds to * the given view point. * * @param scale desired scale, will be limited to {min, max} scale factor * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) * @param viewPoint 2D point in view's absolute coordinate system */ public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) { FLog.v(TAG, "zoomToPoint"); calculateZoomToPointTransform(mActiveTransform, scale, imagePoint, viewPoint, LIMIT_ALL); onTransformChanged(); } /** * Calculates the zoom transformation that would zoom to the desired scale and position the image * so that the given image point corresponds to the given view point. * * @param outTransform the matrix to store the result to * @param scale desired scale, will be limited to {min, max} scale factor * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) * @param viewPoint 2D point in view's absolute coordinate system * @param limitFlags whether to limit translation and/or scale. * @return whether or not the transform has been corrected due to limitation */ protected boolean calculateZoomToPointTransform( Matrix outTransform, float scale, PointF imagePoint, PointF viewPoint, @LimitFlag int limitFlags) { float[] viewAbsolute = mTempValues; viewAbsolute[0] = imagePoint.x; viewAbsolute[1] = imagePoint.y; mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1); float distanceX = viewPoint.x - viewAbsolute[0]; float distanceY = viewPoint.y - viewAbsolute[1]; boolean transformCorrected = false; outTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]); transformCorrected |= limitScale(outTransform, viewAbsolute[0], viewAbsolute[1], limitFlags); outTransform.postTranslate(distanceX, distanceY); transformCorrected |= limitTranslation(outTransform, limitFlags); return transformCorrected; } /** * Sets a new zoom transformation. */ public void setTransform(Matrix newTransform) { FLog.v(TAG, "setTransform"); mActiveTransform.set(newTransform); onTransformChanged(); } /** * Gets the gesture detector. */ protected TransformGestureDetector getDetector() { return mGestureDetector; } /** * Notifies controller of the received touch event. */ @Override public boolean onTouchEvent(MotionEvent event) { FLog.v(TAG, "onTouchEvent: action: ", event.getAction()); if (mIsEnabled && mIsGestureZoomEnabled) { return mGestureDetector.onTouchEvent(event); } return false; } /* TransformGestureDetector.Listener methods */ @Override public void onGestureBegin(TransformGestureDetector detector) { FLog.v(TAG, "onGestureBegin"); mPreviousTransform.set(mActiveTransform); onTransformBegin(); // We only received a touch down event so far, and so we don't know yet in which direction a // future move event will follow. Therefore, if we can't scroll in all directions, we have to // assume the worst case where the user tries to scroll out of edge, which would cause // transformation to be corrected. mWasTransformCorrected = !canScrollInAllDirection(); } @Override public void onGestureUpdate(TransformGestureDetector detector) { FLog.v(TAG, "onGestureUpdate"); boolean transformCorrected = calculateGestureTransform(mActiveTransform, LIMIT_ALL); onTransformChanged(); if (transformCorrected) { mGestureDetector.restartGesture(); } // A transformation happened, but was it without correction? mWasTransformCorrected = transformCorrected; } @Override public void onGestureEnd(TransformGestureDetector detector) { FLog.v(TAG, "onGestureEnd"); onTransformEnd(); } /** * Calculates the zoom transformation based on the current gesture. * * @param outTransform the matrix to store the result to * @param limitTypes whether to limit translation and/or scale. * @return whether or not the transform has been corrected due to limitation */ protected boolean calculateGestureTransform(Matrix outTransform, @LimitFlag int limitTypes) { TransformGestureDetector detector = mGestureDetector; boolean transformCorrected = false; outTransform.set(mPreviousTransform); if (mIsRotationEnabled) { float angle = detector.getRotation() * (float) (180 / Math.PI); outTransform.postRotate(angle, detector.getPivotX(), detector.getPivotY()); } if (mIsScaleEnabled) { float scale = detector.getScale(); outTransform.postScale(scale, scale, detector.getPivotX(), detector.getPivotY()); } transformCorrected |= limitScale(outTransform, detector.getPivotX(), detector.getPivotY(), limitTypes); if (mIsTranslationEnabled) { outTransform.postTranslate(detector.getTranslationX(), detector.getTranslationY()); } transformCorrected |= limitTranslation(outTransform, limitTypes); return transformCorrected; } private void onTransformBegin() { if (mListener != null && isEnabled()) { mListener.onTransformBegin(mActiveTransform); } } private void onTransformChanged() { mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds); if (mListener != null && isEnabled()) { mListener.onTransformChanged(mActiveTransform); } } private void onTransformEnd() { if (mListener != null && isEnabled()) { mListener.onTransformEnd(mActiveTransform); } } /** * Keeps the scaling factor within the specified limits. * * @param pivotX x coordinate of the pivot point * @param pivotY y coordinate of the pivot point * @param limitTypes whether to limit scale. * @return whether limiting has been applied or not */ private boolean limitScale( Matrix transform, float pivotX, float pivotY, @LimitFlag int limitTypes) { if (!shouldLimit(limitTypes, LIMIT_SCALE)) { return false; } float currentScale = getMatrixScaleFactor(transform); float targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor); if (targetScale != currentScale) { float scale = targetScale / currentScale; transform.postScale(scale, scale, pivotX, pivotY); return true; } return false; } /** * Limits the translation so that there are no empty spaces on the sides if possible. * *

The image is attempted to be centered within the view bounds if the transformed image is * smaller. There will be no empty spaces within the view bounds if the transformed image is * bigger. This applies to each dimension (horizontal and vertical) independently. * * @param limitTypes whether to limit translation along the specific axis. * @return whether limiting has been applied or not */ private boolean limitTranslation(Matrix transform, @LimitFlag int limitTypes) { if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y)) { return false; } RectF b = mTempRect; b.set(mImageBounds); transform.mapRect(b); final boolean shouldLimitX = shouldLimit(limitTypes, LIMIT_TRANSLATION_X); float offsetLeft = shouldLimitX ? getOffset(b.left, b.right, mViewBounds.left, mViewBounds.right, mImageBounds.centerX()) : 0; float offsetTop = shouldLimit(limitTypes, LIMIT_TRANSLATION_Y) ? getOffset(b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, mImageBounds.centerY()) : 0; if (mListener != null) { mListener.onTranslationLimited(offsetLeft, offsetTop); } if (offsetLeft != 0 || offsetTop != 0) { transform.postTranslate(offsetLeft, offsetTop); return true; } return false; } /** * Checks whether the specified limit flag is present in the limits provided. * *

If the flag contains multiple flags together using a bitwise OR, this only checks that at * least one of the flags is included. * * @param limits the limits to apply * @param flag the limit flag(s) to check for * @return true if the flag (or one of the flags) is included in the limits */ private static boolean shouldLimit(@LimitFlag int limits, @LimitFlag int flag) { return (limits & flag) != LIMIT_NONE; } /** * Returns the offset necessary to make sure that: - the image is centered within the limit if the * image is smaller than the limit - there is no empty space on left/right if the image is bigger * than the limit */ private float getOffset( float imageStart, float imageEnd, float limitStart, float limitEnd, float limitCenter) { float imageWidth = imageEnd - imageStart, limitWidth = limitEnd - limitStart; float limitInnerWidth = Math.min(limitCenter - limitStart, limitEnd - limitCenter) * 2; // center if smaller than limitInnerWidth if (imageWidth < limitInnerWidth) { return limitCenter - (imageEnd + imageStart) / 2; } // to the edge if in between and limitCenter is not (limitLeft + limitRight) / 2 if (imageWidth < limitWidth) { if (limitCenter < (limitStart + limitEnd) / 2) { return limitStart - imageStart; } else { return limitEnd - imageEnd; } } // to the edge if larger than limitWidth and empty space visible if (imageStart > limitStart) { return limitStart - imageStart; } if (imageEnd < limitEnd) { return limitEnd - imageEnd; } return 0; } /** * Limits the value to the given min and max range. */ private float limit(float value, float min, float max) { return Math.min(Math.max(min, value), max); } /** * Gets the scale factor for the given matrix. This method assumes the equal scaling factor for X * and Y axis. */ private float getMatrixScaleFactor(Matrix transform) { transform.getValues(mTempValues); return mTempValues[Matrix.MSCALE_X]; } /** * Same as {@code Matrix.isIdentity()}, but with tolerance {@code eps}. */ private boolean isMatrixIdentity(Matrix transform, float eps) { // Checks whether the given matrix is close enough to the identity matrix: // 1 0 0 // 0 1 0 // 0 0 1 // Or equivalently to the zero matrix, after subtracting 1.0f from the diagonal elements: // 0 0 0 // 0 0 0 // 0 0 0 transform.getValues(mTempValues); mTempValues[0] -= 1.0f; // m00 mTempValues[4] -= 1.0f; // m11 mTempValues[8] -= 1.0f; // m22 for (int i = 0; i < 9; i++) { if (Math.abs(mTempValues[i]) > eps) { return false; } } return true; } /** * Returns whether the scroll can happen in all directions. I.e. the image is not on any edge. */ private boolean canScrollInAllDirection() { return mTransformedImageBounds.left < mViewBounds.left - EPS && mTransformedImageBounds.top < mViewBounds.top - EPS && mTransformedImageBounds.right > mViewBounds.right + EPS && mTransformedImageBounds.bottom > mViewBounds.bottom + EPS; } @Override public int computeHorizontalScrollRange() { return (int) mTransformedImageBounds.width(); } @Override public int computeHorizontalScrollOffset() { return (int) (mViewBounds.left - mTransformedImageBounds.left); } @Override public int computeHorizontalScrollExtent() { return (int) mViewBounds.width(); } @Override public int computeVerticalScrollRange() { return (int) mTransformedImageBounds.height(); } @Override public int computeVerticalScrollOffset() { return (int) (mViewBounds.top - mTransformedImageBounds.top); } @Override public int computeVerticalScrollExtent() { return (int) mViewBounds.height(); } public Listener getListener() { return mListener; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/DoubleTapGestureListener.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.graphics.PointF; import android.view.GestureDetector; import android.view.MotionEvent; /** * Tap gesture listener for double tap to zoom / unzoom and double-tap-and-drag to zoom. * * @see ZoomableDraweeView#setTapListener(GestureDetector.SimpleOnGestureListener) */ public class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener { private static final int DURATION_MS = 300; private static final int DOUBLE_TAP_SCROLL_THRESHOLD = 20; private final ZoomableDraweeView mDraweeView; private final PointF mDoubleTapViewPoint = new PointF(); private final PointF mDoubleTapImagePoint = new PointF(); private float mDoubleTapScale = 1; private boolean mDoubleTapScroll = false; public DoubleTapGestureListener(ZoomableDraweeView zoomableDraweeView) { mDraweeView = zoomableDraweeView; } @Override public boolean onDoubleTapEvent(MotionEvent e) { AbstractAnimatedZoomableController zc = (AbstractAnimatedZoomableController) mDraweeView.getZoomableController(); PointF vp = new PointF(e.getX(), e.getY()); PointF ip = zc.mapViewToImage(vp); switch (e.getActionMasked()) { case MotionEvent.ACTION_DOWN: mDoubleTapViewPoint.set(vp); mDoubleTapImagePoint.set(ip); mDoubleTapScale = zc.getScaleFactor(); break; case MotionEvent.ACTION_MOVE: mDoubleTapScroll = mDoubleTapScroll || shouldStartDoubleTapScroll(vp); if (mDoubleTapScroll) { float scale = calcScale(vp); zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint); } break; case MotionEvent.ACTION_UP: if (mDoubleTapScroll) { float scale = calcScale(vp); zc.zoomToPoint(scale, mDoubleTapImagePoint, mDoubleTapViewPoint); } else { final float maxScale = zc.getMaxScaleFactor(); final float minScale = zc.getMinScaleFactor(); if (zc.getScaleFactor() < (maxScale + minScale) / 2) { zc.zoomToPoint( maxScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null); } else { zc.zoomToPoint( minScale, ip, vp, DefaultZoomableController.LIMIT_ALL, DURATION_MS, null); } } mDoubleTapScroll = false; break; } return true; } private boolean shouldStartDoubleTapScroll(PointF viewPoint) { double dist = Math.hypot(viewPoint.x - mDoubleTapViewPoint.x, viewPoint.y - mDoubleTapViewPoint.y); return dist > DOUBLE_TAP_SCROLL_THRESHOLD; } private float calcScale(PointF currentViewPoint) { float dy = (currentViewPoint.y - mDoubleTapViewPoint.y); float t = 1 + Math.abs(dy) * 0.001f; return (dy < 0) ? mDoubleTapScale / t : mDoubleTapScale * t; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/DraggableZoomableDraweeView.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.content.Context; import android.util.AttributeSet; import androidx.annotation.NonNull; import com.facebook.drawee.generic.GenericDraweeHierarchy; import awais.instagrabber.customviews.VerticalDragHelper; import awais.instagrabber.customviews.VerticalDragHelper.OnVerticalDragListener; public class DraggableZoomableDraweeView extends ZoomableDraweeView { private static final String TAG = "DraggableZoomableDV"; private VerticalDragHelper verticalDragHelper; public DraggableZoomableDraweeView(final Context context, final GenericDraweeHierarchy hierarchy) { super(context, hierarchy); verticalDragHelper = new VerticalDragHelper(this); } public DraggableZoomableDraweeView(final Context context) { super(context); verticalDragHelper = new VerticalDragHelper(this); } public DraggableZoomableDraweeView(final Context context, final AttributeSet attrs) { super(context, attrs); verticalDragHelper = new VerticalDragHelper(this); } public DraggableZoomableDraweeView(final Context context, final AttributeSet attrs, final int defStyle) { super(context, attrs, defStyle); verticalDragHelper = new VerticalDragHelper(this); } public void setOnVerticalDragListener(@NonNull final OnVerticalDragListener onVerticalDragListener) { verticalDragHelper.setOnVerticalDragListener(onVerticalDragListener); } private int lastPointerCount; private int lastNewPointerCount; private boolean wasTransformCorrected; // @Override // protected void onTransformEnd(final Matrix transform) { // super.onTransformEnd(transform); // final AnimatedZoomableController zoomableController = (AnimatedZoomableController) getZoomableController(); // final TransformGestureDetector detector = zoomableController.getDetector(); // lastNewPointerCount = detector.getNewPointerCount(); // lastPointerCount = detector.getPointerCount(); // } // // @Override // protected void onTranslationLimited(final float offsetLeft, final float offsetTop) { // super.onTranslationLimited(offsetLeft, offsetTop); // wasTransformCorrected = offsetTop != 0; // } // @SuppressLint("ClickableViewAccessibility") // @Override // public boolean onTouchEvent(final MotionEvent event) { // boolean superResult = false; // superResult = super.onTouchEvent(event); // if (verticalDragHelper.isDragging()) { // final boolean onDragTouch = verticalDragHelper.onDragTouch(event); // if (onDragTouch) { // return true; // } // } // if (!verticalDragHelper.isDragging()) { // superResult = super.onTouchEvent(event); // if (wasTransformCorrected // && (lastPointerCount == 1 || lastPointerCount == 0) // && (lastNewPointerCount == 1 || lastNewPointerCount == 0)) { // final boolean onDragTouch = verticalDragHelper.onDragTouch(event); // if (onDragTouch) { // return true; // } // } // } // final boolean gestureListenerResult = verticalDragHelper.onGestureTouchEvent(event); // if (gestureListenerResult) { // return true; // } // return superResult; // } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/GestureListenerWrapper.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.view.GestureDetector; import android.view.MotionEvent; /** * Wrapper for SimpleOnGestureListener as GestureDetector does not allow changing its listener. */ public class GestureListenerWrapper extends GestureDetector.SimpleOnGestureListener { private GestureDetector.SimpleOnGestureListener mDelegate; public GestureListenerWrapper() { mDelegate = new GestureDetector.SimpleOnGestureListener(); } public void setListener(GestureDetector.SimpleOnGestureListener listener) { mDelegate = listener; } @Override public void onLongPress(MotionEvent e) { mDelegate.onLongPress(e); } @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return mDelegate.onScroll(e1, e2, distanceX, distanceY); } @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { return mDelegate.onFling(e1, e2, velocityX, velocityY); } @Override public void onShowPress(MotionEvent e) { mDelegate.onShowPress(e); } @Override public boolean onDown(MotionEvent e) { return mDelegate.onDown(e); } @Override public boolean onDoubleTap(MotionEvent e) { return mDelegate.onDoubleTap(e); } @Override public boolean onDoubleTapEvent(MotionEvent e) { return mDelegate.onDoubleTapEvent(e); } @Override public boolean onSingleTapConfirmed(MotionEvent e) { return mDelegate.onSingleTapConfirmed(e); } @Override public boolean onSingleTapUp(MotionEvent e) { return mDelegate.onSingleTapUp(e); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/MultiGestureListener.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.view.GestureDetector; import android.view.MotionEvent; import java.util.ArrayList; import java.util.List; /** * Gesture listener that allows multiple child listeners to be added and notified about gesture * events. * *

NOTE: The order of the listeners is important. Listeners can consume gesture events. For * example, if one of the child listeners consumes {@link #onLongPress(MotionEvent)} (the listener * returned true), subsequent listeners will not be notified about the event any more since it has * been consumed. */ public class MultiGestureListener extends GestureDetector.SimpleOnGestureListener { private final List mListeners = new ArrayList<>(); /** * Adds a listener to the multi gesture listener. * *

NOTE: The order of the listeners is important since gesture events can be consumed. * * @param listener the listener to be added */ public synchronized void addListener(GestureDetector.SimpleOnGestureListener listener) { mListeners.add(listener); } /** * Removes the given listener so that it will not be notified about future events. * *

NOTE: The order of the listeners is important since gesture events can be consumed. * * @param listener the listener to remove */ public synchronized void removeListener(GestureDetector.SimpleOnGestureListener listener) { mListeners.remove(listener); } @Override public synchronized boolean onSingleTapUp(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onSingleTapUp(e)) { return true; } } return false; } @Override public synchronized void onLongPress(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { mListeners.get(i).onLongPress(e); } } @Override public synchronized boolean onScroll( MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onScroll(e1, e2, distanceX, distanceY)) { return true; } } return false; } @Override public synchronized boolean onFling( MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onFling(e1, e2, velocityX, velocityY)) { return true; } } return false; } @Override public synchronized void onShowPress(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { mListeners.get(i).onShowPress(e); } } @Override public synchronized boolean onDown(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onDown(e)) { return true; } } return false; } @Override public synchronized boolean onDoubleTap(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onDoubleTap(e)) { return true; } } return false; } @Override public synchronized boolean onDoubleTapEvent(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onDoubleTapEvent(e)) { return true; } } return false; } @Override public synchronized boolean onSingleTapConfirmed(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onSingleTapConfirmed(e)) { return true; } } return false; } @Override public synchronized boolean onContextClick(MotionEvent e) { final int size = mListeners.size(); for (int i = 0; i < size; i++) { if (mListeners.get(i).onContextClick(e)) { return true; } } return false; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/MultiPointerGestureDetector.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.view.MotionEvent; /** * Component that detects and tracks multiple pointers based on touch events. * *

Each time a pointer gets pressed or released, the current gesture (if any) will end, and a new * one will be started (if there are still pressed pointers left). It is guaranteed that the number * of pointers within the single gesture will remain the same during the whole gesture. */ public class MultiPointerGestureDetector { /** * The listener for receiving notifications when gestures occur. */ public interface Listener { /** * A callback called right before the gesture is about to start. */ public void onGestureBegin(MultiPointerGestureDetector detector); /** * A callback called each time the gesture gets updated. */ public void onGestureUpdate(MultiPointerGestureDetector detector); /** * A callback called right after the gesture has finished. */ public void onGestureEnd(MultiPointerGestureDetector detector); } private static final int MAX_POINTERS = 2; private boolean mGestureInProgress; private int mPointerCount; private int mNewPointerCount; private final int mId[] = new int[MAX_POINTERS]; private final float mStartX[] = new float[MAX_POINTERS]; private final float mStartY[] = new float[MAX_POINTERS]; private final float mCurrentX[] = new float[MAX_POINTERS]; private final float mCurrentY[] = new float[MAX_POINTERS]; private Listener mListener = null; public MultiPointerGestureDetector() { reset(); } /** * Factory method that creates a new instance of MultiPointerGestureDetector */ public static MultiPointerGestureDetector newInstance() { return new MultiPointerGestureDetector(); } /** * Sets the listener. * * @param listener listener to set */ public void setListener(Listener listener) { mListener = listener; } /** * Resets the component to the initial state. */ public void reset() { mGestureInProgress = false; mPointerCount = 0; for (int i = 0; i < MAX_POINTERS; i++) { mId[i] = MotionEvent.INVALID_POINTER_ID; } } /** * This method can be overridden in order to perform threshold check or something similar. * * @return whether or not to start a new gesture */ protected boolean shouldStartGesture() { return true; } /** * Starts a new gesture and calls the listener just before starting it. */ private void startGesture() { if (!mGestureInProgress) { if (mListener != null) { mListener.onGestureBegin(this); } mGestureInProgress = true; } } /** * Stops the current gesture and calls the listener right after stopping it. */ private void stopGesture() { if (mGestureInProgress) { mGestureInProgress = false; if (mListener != null) { mListener.onGestureEnd(this); } } } /** * Gets the index of the i-th pressed pointer. Normally, the index will be equal to i, except in * the case when the pointer is released. * * @return index of the specified pointer or -1 if not found (i.e. not enough pointers are down) */ private int getPressedPointerIndex(MotionEvent event, int i) { final int count = event.getPointerCount(); final int action = event.getActionMasked(); final int index = event.getActionIndex(); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { if (i >= index) { i++; } } return (i < count) ? i : -1; } /** * Gets the number of pressed pointers (fingers down). */ private static int getPressedPointerCount(MotionEvent event) { int count = event.getPointerCount(); int action = event.getActionMasked(); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { count--; } return count; } private void updatePointersOnTap(MotionEvent event) { mPointerCount = 0; for (int i = 0; i < MAX_POINTERS; i++) { int index = getPressedPointerIndex(event, i); if (index == -1) { mId[i] = MotionEvent.INVALID_POINTER_ID; } else { mId[i] = event.getPointerId(index); mCurrentX[i] = mStartX[i] = event.getX(index); mCurrentY[i] = mStartY[i] = event.getY(index); mPointerCount++; } } } private void updatePointersOnMove(MotionEvent event) { for (int i = 0; i < MAX_POINTERS; i++) { int index = event.findPointerIndex(mId[i]); if (index != -1) { mCurrentX[i] = event.getX(index); mCurrentY[i] = event.getY(index); } } } /** * Handles the given motion event. * * @param event event to handle * @return whether or not the event was handled */ public boolean onTouchEvent(final MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_MOVE: { // update pointers updatePointersOnMove(event); // start a new gesture if not already started if (!mGestureInProgress && mPointerCount > 0 && shouldStartGesture()) { startGesture(); } // notify listener if (mGestureInProgress && mListener != null) { mListener.onGestureUpdate(this); } break; } case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_POINTER_DOWN: case MotionEvent.ACTION_POINTER_UP: case MotionEvent.ACTION_UP: { // restart gesture whenever the number of pointers changes mNewPointerCount = getPressedPointerCount(event); stopGesture(); updatePointersOnTap(event); if (mPointerCount > 0 && shouldStartGesture()) { startGesture(); } break; } case MotionEvent.ACTION_CANCEL: { mNewPointerCount = 0; stopGesture(); reset(); break; } } return true; } /** * Restarts the current gesture (if any). */ public void restartGesture() { if (!mGestureInProgress) { return; } stopGesture(); for (int i = 0; i < MAX_POINTERS; i++) { mStartX[i] = mCurrentX[i]; mStartY[i] = mCurrentY[i]; } startGesture(); } /** * Gets whether there is a gesture in progress */ public boolean isGestureInProgress() { return mGestureInProgress; } /** * Gets the number of pointers after the current gesture */ public int getNewPointerCount() { return mNewPointerCount; } /** * Gets the number of pointers in the current gesture */ public int getPointerCount() { return mPointerCount; } /** * Gets the start X coordinates for the all pointers Mutable array is exposed for performance * reasons and is not to be modified by the callers. */ public float[] getStartX() { return mStartX; } /** * Gets the start Y coordinates for the all pointers Mutable array is exposed for performance * reasons and is not to be modified by the callers. */ public float[] getStartY() { return mStartY; } /** * Gets the current X coordinates for the all pointers Mutable array is exposed for performance * reasons and is not to be modified by the callers. */ public float[] getCurrentX() { return mCurrentX; } /** * Gets the current Y coordinates for the all pointers Mutable array is exposed for performance * reasons and is not to be modified by the callers. */ public float[] getCurrentY() { return mCurrentY; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/MultiZoomableControllerListener.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.graphics.Matrix; import java.util.ArrayList; import java.util.List; /** * An implementation of {@link ZoomableController.Listener} that allows multiple child listeners to * be added and notified about {@link ZoomableController} events. */ public class MultiZoomableControllerListener implements ZoomableController.Listener { private final List mListeners = new ArrayList<>(); @Override public synchronized void onTransformBegin(Matrix transform) { for (ZoomableController.Listener listener : mListeners) { listener.onTransformBegin(transform); } } @Override public synchronized void onTransformChanged(Matrix transform) { for (ZoomableController.Listener listener : mListeners) { listener.onTransformChanged(transform); } } @Override public synchronized void onTransformEnd(Matrix transform) { for (ZoomableController.Listener listener : mListeners) { listener.onTransformEnd(transform); } } @Override public void onTranslationLimited(final float offsetLeft, final float offsetTop) { for (ZoomableController.Listener listener : mListeners) { listener.onTranslationLimited(offsetLeft, offsetTop); } } public synchronized void addListener(ZoomableController.Listener listener) { mListeners.add(listener); } public synchronized void removeListener(ZoomableController.Listener listener) { mListeners.remove(listener); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/TransformGestureDetector.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.view.MotionEvent; /** * Component that detects translation, scale and rotation based on touch events. * *

This class notifies its listeners whenever a gesture begins, updates or ends. The instance of * this detector is passed to the listeners, so it can be queried for pivot, translation, scale or * rotation. */ public class TransformGestureDetector implements MultiPointerGestureDetector.Listener { /** * The listener for receiving notifications when gestures occur. */ public interface Listener { /** * A callback called right before the gesture is about to start. */ public void onGestureBegin(TransformGestureDetector detector); /** * A callback called each time the gesture gets updated. */ public void onGestureUpdate(TransformGestureDetector detector); /** * A callback called right after the gesture has finished. */ public void onGestureEnd(TransformGestureDetector detector); } private final MultiPointerGestureDetector mDetector; private Listener mListener = null; public TransformGestureDetector(MultiPointerGestureDetector multiPointerGestureDetector) { mDetector = multiPointerGestureDetector; mDetector.setListener(this); } /** * Factory method that creates a new instance of TransformGestureDetector */ public static TransformGestureDetector newInstance() { return new TransformGestureDetector(MultiPointerGestureDetector.newInstance()); } /** * Sets the listener. * * @param listener listener to set */ public void setListener(Listener listener) { mListener = listener; } /** * Resets the component to the initial state. */ public void reset() { mDetector.reset(); } /** * Handles the given motion event. * * @param event event to handle * @return whether or not the event was handled */ public boolean onTouchEvent(final MotionEvent event) { return mDetector.onTouchEvent(event); } @Override public void onGestureBegin(MultiPointerGestureDetector detector) { if (mListener != null) { mListener.onGestureBegin(this); } } @Override public void onGestureUpdate(MultiPointerGestureDetector detector) { if (mListener != null) { mListener.onGestureUpdate(this); } } @Override public void onGestureEnd(MultiPointerGestureDetector detector) { if (mListener != null) { mListener.onGestureEnd(this); } } private float calcAverage(float[] arr, int len) { float sum = 0; for (int i = 0; i < len; i++) { sum += arr[i]; } return (len > 0) ? sum / len : 0; } /** * Restarts the current gesture (if any). */ public void restartGesture() { mDetector.restartGesture(); } /** * Gets whether there is a gesture in progress */ public boolean isGestureInProgress() { return mDetector.isGestureInProgress(); } /** * Gets the number of pointers after the current gesture */ public int getNewPointerCount() { return mDetector.getNewPointerCount(); } /** * Gets the number of pointers in the current gesture */ public int getPointerCount() { return mDetector.getPointerCount(); } /** * Gets the X coordinate of the pivot point */ public float getPivotX() { return calcAverage(mDetector.getStartX(), mDetector.getPointerCount()); } /** * Gets the Y coordinate of the pivot point */ public float getPivotY() { return calcAverage(mDetector.getStartY(), mDetector.getPointerCount()); } /** * Gets the X component of the translation */ public float getTranslationX() { return calcAverage(mDetector.getCurrentX(), mDetector.getPointerCount()) - calcAverage(mDetector.getStartX(), mDetector.getPointerCount()); } /** * Gets the Y component of the translation */ public float getTranslationY() { return calcAverage(mDetector.getCurrentY(), mDetector.getPointerCount()) - calcAverage(mDetector.getStartY(), mDetector.getPointerCount()); } /** * Gets the scale */ public float getScale() { if (mDetector.getPointerCount() < 2) { return 1; } else { float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0]; float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0]; float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0]; float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0]; float startDist = (float) Math.hypot(startDeltaX, startDeltaY); float currentDist = (float) Math.hypot(currentDeltaX, currentDeltaY); return currentDist / startDist; } } /** * Gets the rotation in radians */ public float getRotation() { if (mDetector.getPointerCount() < 2) { return 0; } else { float startDeltaX = mDetector.getStartX()[1] - mDetector.getStartX()[0]; float startDeltaY = mDetector.getStartY()[1] - mDetector.getStartY()[0]; float currentDeltaX = mDetector.getCurrentX()[1] - mDetector.getCurrentX()[0]; float currentDeltaY = mDetector.getCurrentY()[1] - mDetector.getCurrentY()[0]; float startAngle = (float) Math.atan2(startDeltaY, startDeltaX); float currentAngle = (float) Math.atan2(currentDeltaY, currentDeltaX); return currentAngle - startAngle; } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableController.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.graphics.Matrix; import android.graphics.RectF; import android.view.MotionEvent; /** * Interface for implementing a controller that works with {@link ZoomableDraweeView} to control the * zoom. */ public interface ZoomableController { /** * Listener interface. */ interface Listener { /** * Notifies the view that the transform began. * * @param transform the current transform matrix */ void onTransformBegin(Matrix transform); /** * Notifies the view that the transform changed. * * @param transform the new matrix */ void onTransformChanged(Matrix transform); /** * Notifies the view that the transform ended. * * @param transform the current transform matrix */ void onTransformEnd(Matrix transform); void onTranslationLimited(float offsetLeft, float offsetTop); } /** * Enables the controller. The controller is enabled when the image has been loaded. * * @param enabled whether to enable the controller */ void setEnabled(boolean enabled); /** * Gets whether the controller is enabled. This should return the last value passed to {@link * #setEnabled}. * * @return whether the controller is enabled. */ boolean isEnabled(); /** * Sets the listener for the controller to call back when the matrix changes. * * @param listener the listener */ void setListener(Listener listener); /** * Gets the current scale factor. A convenience method for calculating the scale from the * transform. * * @return the current scale factor */ float getScaleFactor(); /** * Returns true if the zoomable transform is identity matrix, and the controller is idle. */ boolean isIdentity(); /** * Returns true if the transform was corrected during the last update. * *

This mainly happens when a gesture would cause the image to get out of limits and the * transform gets corrected in order to prevent that. */ boolean wasTransformCorrected(); /** * See {@link androidx.core.view.ScrollingView}. */ int computeHorizontalScrollRange(); int computeHorizontalScrollOffset(); int computeHorizontalScrollExtent(); int computeVerticalScrollRange(); int computeVerticalScrollOffset(); int computeVerticalScrollExtent(); /** * Gets the current transform. * * @return the transform */ Matrix getTransform(); /** * Sets the bounds of the image post transform prior to application of the zoomable * transformation. * * @param imageBounds the bounds of the image */ void setImageBounds(RectF imageBounds); /** * Sets the bounds of the view. * * @param viewBounds the bounds of the view */ void setViewBounds(RectF viewBounds); /** * Allows the controller to handle a touch event. * * @param event the touch event * @return whether the controller handled the event */ boolean onTouchEvent(MotionEvent event); } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/drawee/ZoomableDraweeView.java ================================================ /* * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ package awais.instagrabber.customviews.drawee; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.RectF; import android.graphics.drawable.Animatable; import android.util.AttributeSet; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.ViewParent; import androidx.annotation.Nullable; import androidx.core.view.ScrollingView; import com.facebook.common.internal.Preconditions; import com.facebook.common.logging.FLog; import com.facebook.drawee.controller.AbstractDraweeController; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.drawee.controller.ControllerListener; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchy; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.generic.GenericDraweeHierarchyInflater; import com.facebook.drawee.interfaces.DraweeController; import com.facebook.drawee.view.DraweeView; /** * DraweeView that has zoomable capabilities. * *

Once the image loads, pinch-to-zoom and translation gestures are enabled. */ public class ZoomableDraweeView extends DraweeView implements ScrollingView { private static final Class TAG = ZoomableDraweeView.class; private static final float HUGE_IMAGE_SCALE_FACTOR_THRESHOLD = 1.1f; private final RectF mImageBounds = new RectF(); private final RectF mViewBounds = new RectF(); private DraweeController mHugeImageController; private ZoomableController mZoomableController; private GestureDetector mTapGestureDetector; private boolean mAllowTouchInterceptionWhileZoomed = false; private boolean mIsDialtoneEnabled = false; private boolean mZoomingEnabled = true; private final ControllerListener mControllerListener = new BaseControllerListener() { @Override public void onFinalImageSet( String id, @Nullable Object imageInfo, @Nullable Animatable animatable) { ZoomableDraweeView.this.onFinalImageSet(); } @Override public void onRelease(String id) { ZoomableDraweeView.this.onRelease(); } }; private final ZoomableController.Listener mZoomableListener = new ZoomableController.Listener() { @Override public void onTransformBegin(Matrix transform) { ZoomableDraweeView.this.onTransformBegin(transform); } @Override public void onTransformChanged(Matrix transform) { ZoomableDraweeView.this.onTransformChanged(transform); } @Override public void onTransformEnd(Matrix transform) { ZoomableDraweeView.this.onTransformEnd(transform); } @Override public void onTranslationLimited(final float offsetLeft, final float offsetTop) { ZoomableDraweeView.this.onTranslationLimited(offsetLeft, offsetTop); } }; private final GestureListenerWrapper mTapListenerWrapper = new GestureListenerWrapper(); public ZoomableDraweeView(Context context, GenericDraweeHierarchy hierarchy) { super(context); setHierarchy(hierarchy); init(); } public ZoomableDraweeView(Context context) { super(context); inflateHierarchy(context, null); init(); } public ZoomableDraweeView(Context context, AttributeSet attrs) { super(context, attrs); inflateHierarchy(context, attrs); init(); } public ZoomableDraweeView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); inflateHierarchy(context, attrs); init(); } protected void inflateHierarchy(Context context, @Nullable AttributeSet attrs) { Resources resources = context.getResources(); GenericDraweeHierarchyBuilder builder = new GenericDraweeHierarchyBuilder(resources) .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER); GenericDraweeHierarchyInflater.updateBuilder(builder, context, attrs); setAspectRatio(builder.getDesiredAspectRatio()); setHierarchy(builder.build()); } private void init() { mZoomableController = createZoomableController(); mZoomableController.setListener(mZoomableListener); mTapGestureDetector = new GestureDetector(getContext(), mTapListenerWrapper); } public void setIsDialtoneEnabled(boolean isDialtoneEnabled) { mIsDialtoneEnabled = isDialtoneEnabled; } /** * Gets the original image bounds, in view-absolute coordinates. * *

The original image bounds are those reported by the hierarchy. The hierarchy itself may * apply scaling on its own (e.g. due to scale type) so the reported bounds are not necessarily * the same as the actual bitmap dimensions. In other words, the original image bounds correspond * to the image bounds within this view when no zoomable transformation is applied, but including * the potential scaling of the hierarchy. Having the actual bitmap dimensions abstracted away * from this view greatly simplifies implementation because the actual bitmap may change (e.g. * when a high-res image arrives and replaces the previously set low-res image). With proper * hierarchy scaling (e.g. FIT_CENTER), this underlying change will not affect this view nor the * zoomable transformation in any way. */ protected void getImageBounds(RectF outBounds) { getHierarchy().getActualImageBounds(outBounds); } /** * Gets the bounds used to limit the translation, in view-absolute coordinates. * *

These bounds are passed to the zoomable controller in order to limit the translation. The * image is attempted to be centered within the limit bounds if the transformed image is smaller. * There will be no empty spaces within the limit bounds if the transformed image is bigger. This * applies to each dimension (horizontal and vertical) independently. * *

Unless overridden by a subclass, these bounds are same as the view bounds. */ protected void getLimitBounds(RectF outBounds) { outBounds.set(0, 0, getWidth(), getHeight()); } /** * Sets a custom zoomable controller, instead of using the default one. */ public void setZoomableController(ZoomableController zoomableController) { Preconditions.checkNotNull(zoomableController); mZoomableController.setListener(null); mZoomableController = zoomableController; mZoomableController.setListener(mZoomableListener); } /** * Gets the zoomable controller. * *

Zoomable controller can be used to zoom to point, or to map point from view to image * coordinates for instance. */ public ZoomableController getZoomableController() { return mZoomableController; } /** * Check whether the parent view can intercept touch events while zoomed. This can be used, for * example, to swipe between images in a view pager while zoomed. * * @return true if touch events can be intercepted */ public boolean allowsTouchInterceptionWhileZoomed() { return mAllowTouchInterceptionWhileZoomed; } /** * If this is set to true, parent views can intercept touch events while the view is zoomed. For * example, this can be used to swipe between images in a view pager while zoomed. * * @param allowTouchInterceptionWhileZoomed true if the parent needs to intercept touches */ public void setAllowTouchInterceptionWhileZoomed(boolean allowTouchInterceptionWhileZoomed) { mAllowTouchInterceptionWhileZoomed = allowTouchInterceptionWhileZoomed; } /** * Sets the tap listener. */ public void setTapListener(GestureDetector.SimpleOnGestureListener tapListener) { mTapListenerWrapper.setListener(tapListener); } /** * Sets whether long-press tap detection is enabled. Unfortunately, long-press conflicts with * onDoubleTapEvent. */ public void setIsLongpressEnabled(boolean enabled) { mTapGestureDetector.setIsLongpressEnabled(enabled); } public void setZoomingEnabled(boolean zoomingEnabled) { mZoomingEnabled = zoomingEnabled; mZoomableController.setEnabled(zoomingEnabled); } /** * Sets the image controller. */ @Override public void setController(@Nullable DraweeController controller) { setControllers(controller, null); } /** * Sets the controllers for the normal and huge image. * *

The huge image controller is used after the image gets scaled above a certain threshold. * *

IMPORTANT: in order to avoid a flicker when switching to the huge image, the huge image * controller should have the normal-image-uri set as its low-res-uri. * * @param controller controller to be initially used * @param hugeImageController controller to be used after the client starts zooming-in */ public void setControllers( @Nullable DraweeController controller, @Nullable DraweeController hugeImageController) { setControllersInternal(null, null); mZoomableController.setEnabled(false); setControllersInternal(controller, hugeImageController); } private void setControllersInternal( @Nullable DraweeController controller, @Nullable DraweeController hugeImageController) { removeControllerListener(getController()); addControllerListener(controller); mHugeImageController = hugeImageController; super.setController(controller); } private void maybeSetHugeImageController() { if (mHugeImageController != null && mZoomableController.getScaleFactor() > HUGE_IMAGE_SCALE_FACTOR_THRESHOLD) { setControllersInternal(mHugeImageController, null); } } private void removeControllerListener(DraweeController controller) { if (controller instanceof AbstractDraweeController) { ((AbstractDraweeController) controller).removeControllerListener(mControllerListener); } } private void addControllerListener(DraweeController controller) { if (controller instanceof AbstractDraweeController) { ((AbstractDraweeController) controller).addControllerListener(mControllerListener); } } @Override protected void onDraw(Canvas canvas) { int saveCount = canvas.save(); canvas.concat(mZoomableController.getTransform()); try { super.onDraw(canvas); } catch (Exception e) { DraweeController controller = getController(); if (controller != null && controller instanceof AbstractDraweeController) { Object callerContext = ((AbstractDraweeController) controller).getCallerContext(); if (callerContext != null) { throw new RuntimeException( String.format("Exception in onDraw, callerContext=%s", callerContext.toString()), e); } } throw e; } canvas.restoreToCount(saveCount); } @Override public boolean onTouchEvent(MotionEvent event) { int a = event.getActionMasked(); FLog.v(getLogTag(), "onTouchEvent: %d, view %x, received", a, this.hashCode()); if (!mIsDialtoneEnabled && mTapGestureDetector.onTouchEvent(event)) { FLog.v(getLogTag(), "onTouchEvent: %d, view %x, handled by tap gesture detector", a, this.hashCode()); return true; } if (!mIsDialtoneEnabled && mZoomableController.onTouchEvent(event)) { FLog.v( getLogTag(), "onTouchEvent: %d, view %x, handled by zoomable controller", a, this.hashCode()); if (!mAllowTouchInterceptionWhileZoomed && !mZoomableController.isIdentity()) { final ViewParent parent = getParent(); parent.requestDisallowInterceptTouchEvent(true); } return true; } if (super.onTouchEvent(event)) { FLog.v(getLogTag(), "onTouchEvent: %d, view %x, handled by the super", a, this.hashCode()); return true; } // None of our components reported that they handled the touch event. Upon returning false // from this method, our parent won't send us any more events for this gesture. Unfortunately, // some components may have started a delayed action, such as a long-press timer, and since we // won't receive an ACTION_UP that would cancel that timer, a false event may be triggered. // To prevent that we explicitly send one last cancel event when returning false. MotionEvent cancelEvent = MotionEvent.obtain(event); cancelEvent.setAction(MotionEvent.ACTION_CANCEL); mTapGestureDetector.onTouchEvent(cancelEvent); mZoomableController.onTouchEvent(cancelEvent); cancelEvent.recycle(); return false; } @Override public int computeHorizontalScrollRange() { return mZoomableController.computeHorizontalScrollRange(); } @Override public int computeHorizontalScrollOffset() { return mZoomableController.computeHorizontalScrollOffset(); } @Override public int computeHorizontalScrollExtent() { return mZoomableController.computeHorizontalScrollExtent(); } @Override public int computeVerticalScrollRange() { return mZoomableController.computeVerticalScrollRange(); } @Override public int computeVerticalScrollOffset() { return mZoomableController.computeVerticalScrollOffset(); } @Override public int computeVerticalScrollExtent() { return mZoomableController.computeVerticalScrollExtent(); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { FLog.v(getLogTag(), "onLayout: view %x", this.hashCode()); super.onLayout(changed, left, top, right, bottom); updateZoomableControllerBounds(); } private void onFinalImageSet() { FLog.v(getLogTag(), "onFinalImageSet: view %x", this.hashCode()); if (!mZoomableController.isEnabled() && mZoomingEnabled) { mZoomableController.setEnabled(true); updateZoomableControllerBounds(); } } private void onRelease() { FLog.v(getLogTag(), "onRelease: view %x", this.hashCode()); mZoomableController.setEnabled(false); } protected void onTransformBegin(final Matrix transform) {} protected void onTransformChanged(Matrix transform) { FLog.v(getLogTag(), "onTransformChanged: view %x, transform: %s", this.hashCode(), transform); maybeSetHugeImageController(); invalidate(); } protected void onTransformEnd(final Matrix transform) {} protected void onTranslationLimited(final float offsetLeft, final float offsetTop) {} protected void updateZoomableControllerBounds() { getImageBounds(mImageBounds); getLimitBounds(mViewBounds); // Log.d(TAG.getSimpleName(), "updateZoomableControllerBounds: mImageBounds: " + mImageBounds); mZoomableController.setImageBounds(mImageBounds); mZoomableController.setViewBounds(mViewBounds); FLog.v(getLogTag(), "updateZoomableControllerBounds: view %x, view bounds: %s, image bounds: %s", this.hashCode(), mViewBounds, mImageBounds); } protected Class getLogTag() { return TAG; } protected ZoomableController createZoomableController() { return AnimatedZoomableController.newInstance(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/Emoji.java ================================================ package awais.instagrabber.customviews.emoji; import androidx.annotation.NonNull; import java.util.List; import java.util.Objects; public class Emoji { private final String unicode; private final String name; private final List variants; private GoogleCompatEmojiDrawable drawable; public Emoji(final String unicode, final String name, final List variants) { this.unicode = unicode; this.name = name; this.variants = variants; } public String getUnicode() { return unicode; } public void addVariant(final Emoji emoji) { variants.add(emoji); } public String getName() { return name; } public List getVariants() { return variants; } public GoogleCompatEmojiDrawable getDrawable() { if (drawable == null && unicode != null) { drawable = new GoogleCompatEmojiDrawable(unicode); } return drawable; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final Emoji emoji = (Emoji) o; return Objects.equals(unicode, emoji.unicode); } @Override public int hashCode() { return Objects.hash(unicode); } @NonNull @Override public String toString() { return "Emoji{" + "unicode='" + unicode + '\'' + ", name='" + name + '\'' + ", variants=" + variants + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiBottomSheetDialog.java ================================================ package awais.instagrabber.customviews.emoji; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import awais.instagrabber.R; import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.emoji.EmojiParser; public class EmojiBottomSheetDialog extends BottomSheetDialogFragment { public static final String TAG = EmojiBottomSheetDialog.class.getSimpleName(); private RecyclerView grid; private EmojiPicker.OnEmojiClickListener callback; @NonNull public static EmojiBottomSheetDialog newInstance() { // Bundle args = new Bundle(); // fragment.setArguments(args); return new EmojiBottomSheetDialog(); } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setStyle(DialogFragment.STYLE_NORMAL, R.style.ThemeOverlay_Rounded_BottomSheetDialog); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { final Context context = getContext(); if (context == null) return null; grid = new RecyclerView(context); return grid; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { init(); } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); if (bottomSheetInternal == null) return; bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; bottomSheetInternal.requestLayout(); } @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); final Fragment parentFragment = getParentFragment(); if (parentFragment instanceof EmojiPicker.OnEmojiClickListener) { callback = (EmojiPicker.OnEmojiClickListener) parentFragment; } } @Override public void onDestroyView() { grid = null; super.onDestroyView(); } private void init() { final Context context = getContext(); if (context == null) return; final GridLayoutManager gridLayoutManager = new GridLayoutManager(context, 9); grid.setLayoutManager(gridLayoutManager); grid.setHasFixedSize(true); grid.setClipToPadding(false); grid.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(8))); final EmojiParser emojiParser = EmojiParser.Companion.getInstance(context); final EmojiGridAdapter adapter = new EmojiGridAdapter(emojiParser, null, (view, emoji) -> { if (callback != null) { callback.onClick(view, emoji); } dismiss(); }, null); grid.setAdapter(adapter); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategory.java ================================================ package awais.instagrabber.customviews.emoji; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import java.util.Map; import java.util.Objects; import awais.instagrabber.R; public class EmojiCategory { private final EmojiCategoryType type; private final Map emojis; @DrawableRes private int drawableRes; public EmojiCategory(final EmojiCategoryType type, final Map emojis) { this.type = type; this.emojis = emojis; } public EmojiCategoryType getType() { return type; } public Map getEmojis() { return emojis; } public int getDrawableRes() { if (drawableRes == 0) { switch (type) { case SMILEYS_AND_EMOTION: drawableRes = R.drawable.ic_round_emoji_emotions_24; break; case ANIMALS_AND_NATURE: drawableRes = R.drawable.ic_round_emoji_nature_24; break; case FOOD_AND_DRINK: drawableRes = R.drawable.ic_round_emoji_food_beverage_24; break; case TRAVEL_AND_PLACES: drawableRes = R.drawable.ic_round_emoji_transportation_24; break; case ACTIVITIES: drawableRes = R.drawable.ic_round_emoji_events_24; break; case OBJECTS: drawableRes = R.drawable.ic_round_emoji_objects_24; break; case SYMBOLS: drawableRes = R.drawable.ic_round_emoji_symbols_24; break; case FLAGS: drawableRes = R.drawable.ic_round_emoji_flags_24; break; case OTHERS: drawableRes = R.drawable.ic_round_unknown_24; break; } } return drawableRes; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final EmojiCategory that = (EmojiCategory) o; return type == that.type; } @Override public int hashCode() { return Objects.hash(type); } @NonNull @Override public String toString() { return "EmojiCategory{" + "type=" + type + ", emojis=" + emojis + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryPageViewHolder.java ================================================ package awais.instagrabber.customviews.emoji; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; import awais.instagrabber.utils.emoji.EmojiParser; public class EmojiCategoryPageViewHolder extends RecyclerView.ViewHolder { // private static final String TAG = EmojiCategoryPageViewHolder.class.getSimpleName(); private final View rootView; private final OnEmojiClickListener onEmojiClickListener; private final EmojiParser emojiParser = EmojiParser.Companion.getInstance(itemView.getContext()); public EmojiCategoryPageViewHolder(@NonNull final View rootView, @NonNull final RecyclerView itemView, final OnEmojiClickListener onEmojiClickListener) { super(itemView); this.rootView = rootView; this.onEmojiClickListener = onEmojiClickListener; } public void bind(final EmojiCategory emojiCategory) { final RecyclerView emojiGrid = (RecyclerView) itemView; final EmojiGridAdapter adapter = new EmojiGridAdapter( emojiParser, emojiCategory.getType(), onEmojiClickListener, (position, view, parent) -> { final EmojiVariantPopup emojiVariantPopup = new EmojiVariantPopup(rootView, ((view1, emoji) -> { if (onEmojiClickListener != null) { onEmojiClickListener.onClick(view1, emoji); } final EmojiGridAdapter emojiGridAdapter = (EmojiGridAdapter) emojiGrid.getAdapter(); if (emojiGridAdapter == null) return; emojiGridAdapter.notifyItemChanged(position); })); emojiVariantPopup.show(view, parent); return true; } ); emojiGrid.setAdapter(adapter); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiCategoryType.java ================================================ package awais.instagrabber.customviews.emoji; import java.util.HashMap; import java.util.Map; public enum EmojiCategoryType { SMILEYS_AND_EMOTION("Smileys & Emotion"), // PEOPLE_AND_BODY("People & Body"), ANIMALS_AND_NATURE("Animals & Nature"), FOOD_AND_DRINK("Food & Drink"), TRAVEL_AND_PLACES("Travel & Places"), ACTIVITIES("Activities"), OBJECTS("Objects"), SYMBOLS("Symbols"), FLAGS("Flags"), OTHERS("Others"); private final String name; private static final Map map = new HashMap<>(); static { for (EmojiCategoryType type : EmojiCategoryType.values()) { map.put(type.name, type); } } EmojiCategoryType(final String name) { this.name = name; } public static EmojiCategoryType valueOfName(final String name) { final EmojiCategoryType emojiCategoryType = map.get(name); if (emojiCategoryType == null) { return EmojiCategoryType.OTHERS; } return emojiCategoryType; } public String getName() { return name; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiGridAdapter.java ================================================ package awais.instagrabber.customviews.emoji; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.AdapterListUpdateCallback; import androidx.recyclerview.widget.AsyncDifferConfig; import androidx.recyclerview.widget.AsyncListDiffer; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import com.google.common.collect.ImmutableList; import java.util.Collections; import java.util.Map; import java.util.Optional; import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; import awais.instagrabber.databinding.ItemEmojiGridBinding; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.emoji.EmojiParser; public class EmojiGridAdapter extends RecyclerView.Adapter { private static final String TAG = EmojiGridAdapter.class.getSimpleName(); private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final Emoji oldItem, @NonNull final Emoji newItem) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame(@NonNull final Emoji oldItem, @NonNull final Emoji newItem) { return oldItem.equals(newItem); } }; private final AsyncListDiffer differ; private final OnEmojiLongClickListener onEmojiLongClickListener; private final OnEmojiClickListener onEmojiClickListener; private final EmojiVariantManager emojiVariantManager; private final AppExecutors appExecutors; public EmojiGridAdapter(@NonNull final EmojiParser emojiParser, final EmojiCategoryType emojiCategoryType, final OnEmojiClickListener onEmojiClickListener, final OnEmojiLongClickListener onEmojiLongClickListener) { this.onEmojiClickListener = onEmojiClickListener; this.onEmojiLongClickListener = onEmojiLongClickListener; differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), new AsyncDifferConfig.Builder<>(diffCallback).build()); final Map categoryMap = emojiParser.getCategoryMap(); emojiVariantManager = EmojiVariantManager.getInstance(); appExecutors = AppExecutors.INSTANCE; setHasStableIds(true); if (emojiCategoryType == null) { // show all if type is null differ.submitList(ImmutableList.copyOf(emojiParser.getAllEmojis().values())); return; } final EmojiCategory emojiCategory = categoryMap.get(emojiCategoryType); if (emojiCategory == null) { differ.submitList(Collections.emptyList()); return; } differ.submitList(ImmutableList.copyOf(emojiCategory.getEmojis().values())); } @NonNull @Override public EmojiViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); final ItemEmojiGridBinding binding = ItemEmojiGridBinding.inflate(layoutInflater, parent, false); return new EmojiViewHolder(binding, onEmojiClickListener, onEmojiLongClickListener); } @Override public void onBindViewHolder(@NonNull final EmojiViewHolder holder, final int position) { final Emoji emoji = differ.getCurrentList().get(position); final String variant = emojiVariantManager.getVariant(emoji.getUnicode()); if (variant != null) { appExecutors.getTasksThread().execute(() -> { final Optional first = emoji.getVariants() .stream() .filter(e -> e.getUnicode().equals(variant)) .findFirst(); if (!first.isPresent()) return; appExecutors.getMainThread().execute(() -> holder.bind(position, first.get(), emoji)); }); return; } holder.bind(position, emoji, emoji); } @Override public long getItemId(final int position) { return differ.getCurrentList().get(position).hashCode(); } @Override public int getItemViewType(final int position) { return 0; } @Override public int getItemCount() { return differ.getCurrentList().size(); } public static class EmojiViewHolder extends RecyclerView.ViewHolder { // private final AppExecutors appExecutors = AppExecutors.getInstance(); private final ItemEmojiGridBinding binding; private final OnEmojiClickListener onEmojiClickListener; private final OnEmojiLongClickListener onEmojiLongClickListener; public EmojiViewHolder(@NonNull final ItemEmojiGridBinding binding, final OnEmojiClickListener onEmojiClickListener, final OnEmojiLongClickListener onEmojiLongClickListener) { super(binding.getRoot()); this.binding = binding; this.onEmojiClickListener = onEmojiClickListener; this.onEmojiLongClickListener = onEmojiLongClickListener; } public void bind(final int position, final Emoji emoji, final Emoji parent) { binding.image.setImageDrawable(null); binding.indicator.setVisibility(View.GONE); itemView.setOnLongClickListener(null); // itemView.post(() -> { binding.image.setImageDrawable(emoji.getDrawable()); final boolean hasVariants = !parent.getVariants().isEmpty(); binding.indicator.setVisibility(hasVariants ? View.VISIBLE : View.GONE); if (onEmojiClickListener != null) { itemView.setOnClickListener(v -> onEmojiClickListener.onClick(v, emoji)); } if (hasVariants && onEmojiLongClickListener != null) { itemView.setOnLongClickListener(v -> onEmojiLongClickListener.onLongClick(position, v, parent)); } // }); } } public interface OnEmojiLongClickListener { boolean onLongClick(int position, View view, Emoji emoji); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPicker.java ================================================ package awais.instagrabber.customviews.emoji; import android.content.Context; import android.util.AttributeSet; import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatImageView; import androidx.core.content.ContextCompat; import androidx.core.widget.ImageViewCompat; import androidx.viewpager2.widget.ViewPager2; import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayoutMediator; import java.util.Collection; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.utils.emoji.EmojiParser; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; public class EmojiPicker extends LinearLayout { // private static final String TAG = EmojiPicker.class.getSimpleName(); public EmojiPicker(final Context context) { super(context); setup(); } public EmojiPicker(final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); setup(); } public EmojiPicker(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); setup(); } private void setup() { setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); setOrientation(VERTICAL); } public void init(@NonNull final View rootView, final OnEmojiClickListener onEmojiClickListener, final OnBackspaceClickListener onBackspaceClickListener) { final TabLayout tabLayout = new TabLayout(getContext()); final LayoutParams tabLayoutLayoutParam = new LayoutParams(MATCH_PARENT, WRAP_CONTENT); tabLayout.setLayoutParams(tabLayoutLayoutParam); tabLayout.setSelectedTabIndicatorGravity(TabLayout.INDICATOR_GRAVITY_TOP); // tabLayout.setSelectedTabIndicatorColor(Utils.getThemeAccentColor(getContext())); tabLayout.setSelectedTabIndicatorColor(getResources().getColor(R.color.blue_500)); final ViewPager2 viewPager2 = new ViewPager2(getContext()); final LayoutParams viewPagerLayoutParam = new LayoutParams(MATCH_PARENT, 0); viewPagerLayoutParam.weight = 1; viewPager2.setLayoutParams(viewPagerLayoutParam); viewPager2.setAdapter(new EmojiPickerPageAdapter(rootView, onEmojiClickListener)); viewPager2.setOffscreenPageLimit(1); final Context context = getContext(); if (context == null) return; final EmojiParser emojiParser = EmojiParser.Companion.getInstance(context); final List categories = emojiParser.getEmojiCategories(); new TabLayoutMediator(tabLayout, viewPager2, (tab, position) -> { tab.view.setPadding(0, 0, 0, 0); final EmojiCategory emojiCategory = categories.get(position); if (emojiCategory == null) return; final Collection emojis = emojiCategory.getEmojis().values(); if (emojis.isEmpty()) return; final AppCompatImageView imageView = getImageView(); imageView.setImageResource(emojiCategory.getDrawableRes()); tab.setCustomView(imageView); }).attach(); final TabLayout.Tab tab = tabLayout.newTab(); tab.view.setPadding(0, 0, 0, 0); final AppCompatImageView imageView = getImageView(); imageView.setImageResource(R.drawable.ic_round_backspace_24); final TypedValue outValue = new TypedValue(); getContext().getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true); imageView.setBackgroundResource(outValue.resourceId); imageView.setOnClickListener(v -> { if (onBackspaceClickListener == null) return; onBackspaceClickListener.onClick(); }); tab.setCustomView(imageView); tab.view.setEnabled(false); tabLayout.addTab(tab); addView(viewPager2); addView(tabLayout); } @NonNull private AppCompatImageView getImageView() { final AppCompatImageView imageView = new AppCompatImageView(getContext()); imageView.setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); imageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); ImageViewCompat.setImageTintList(imageView, ContextCompat.getColorStateList(getContext(), R.color.emoji_picker_tab_color)); return imageView; } public interface OnEmojiClickListener { void onClick(View view, Emoji emoji); } public interface OnBackspaceClickListener { void onClick(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiPickerPageAdapter.java ================================================ package awais.instagrabber.customviews.emoji; import android.content.Context; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.AdapterListUpdateCallback; import androidx.recyclerview.widget.AsyncDifferConfig; import androidx.recyclerview.widget.AsyncListDiffer; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.emoji.EmojiParser; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; public class EmojiPickerPageAdapter extends RecyclerView.Adapter { private static final DiffUtil.ItemCallback diffCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull final EmojiCategory oldItem, @NonNull final EmojiCategory newItem) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame(@NonNull final EmojiCategory oldItem, @NonNull final EmojiCategory newItem) { return oldItem.equals(newItem); } }; private final View rootView; private final OnEmojiClickListener onEmojiClickListener; private final AsyncListDiffer differ; public EmojiPickerPageAdapter(@NonNull final View rootView, final OnEmojiClickListener onEmojiClickListener) { this.rootView = rootView; this.onEmojiClickListener = onEmojiClickListener; differ = new AsyncListDiffer<>(new AdapterListUpdateCallback(this), new AsyncDifferConfig.Builder<>(diffCallback).build()); final EmojiParser emojiParser = EmojiParser.Companion.getInstance(rootView.getContext()); differ.submitList(emojiParser.getEmojiCategories()); setHasStableIds(true); } @NonNull @Override public EmojiCategoryPageViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final Context context = parent.getContext(); final RecyclerView emojiGrid = new RecyclerView(context); emojiGrid.setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); emojiGrid.setLayoutManager(new GridLayoutManager(context, 9)); emojiGrid.setHasFixedSize(true); emojiGrid.setClipToPadding(false); emojiGrid.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(8))); return new EmojiCategoryPageViewHolder(rootView, emojiGrid, onEmojiClickListener); } @Override public void onBindViewHolder(@NonNull final EmojiCategoryPageViewHolder holder, final int position) { final EmojiCategory emojiCategory = differ.getCurrentList().get(position); holder.bind(emojiCategory); } @Override public long getItemId(final int position) { return differ.getCurrentList().get(position).hashCode(); } @Override public int getItemCount() { return differ.getCurrentList().size(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantManager.java ================================================ package awais.instagrabber.customviews.emoji; import android.util.Log; import androidx.annotation.Nullable; import org.json.JSONException; import org.json.JSONObject; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import static awais.instagrabber.utils.Constants.PREF_EMOJI_VARIANTS; public class EmojiVariantManager { private static final String TAG = EmojiVariantManager.class.getSimpleName(); private static final Object LOCK = new Object(); private final AppExecutors appExecutors = AppExecutors.INSTANCE; private final Map selectedVariantMap = new HashMap<>(); private static EmojiVariantManager instance; public static EmojiVariantManager getInstance() { if (instance == null) { synchronized (LOCK) { if (instance == null) { instance = new EmojiVariantManager(); } } } return instance; } private EmojiVariantManager() { final String variantsJson = Utils.settingsHelper.getString(PREF_EMOJI_VARIANTS); if (TextUtils.isEmpty(variantsJson)) return; try { final JSONObject variantsJSONObject = new JSONObject(variantsJson); final Iterator keys = variantsJSONObject.keys(); keys.forEachRemaining(s -> selectedVariantMap.put(s, variantsJSONObject.optString(s))); } catch (JSONException e) { Log.e(TAG, "EmojiVariantManager: ", e); } } @Nullable public String getVariant(final String parentUnicode) { return selectedVariantMap.get(parentUnicode); } public void setVariant(final String parent, final String variant) { if (parent == null || variant == null) return; selectedVariantMap.put(parent, variant); appExecutors.getTasksThread().execute(() -> { final JSONObject jsonObject = new JSONObject(selectedVariantMap); final String json = jsonObject.toString(); Utils.settingsHelper.putString(PREF_EMOJI_VARIANTS, json); }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/EmojiVariantPopup.java ================================================ package awais.instagrabber.customviews.emoji; /* * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import android.content.Context; import android.graphics.Bitmap; import android.graphics.Point; import android.graphics.drawable.BitmapDrawable; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.PopupWindow; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.List; import awais.instagrabber.customviews.emoji.EmojiPicker.OnEmojiClickListener; import awais.instagrabber.databinding.ItemEmojiGridBinding; import awais.instagrabber.databinding.LayoutEmojiVariantPopupBinding; import awais.instagrabber.utils.AppExecutors; import static android.view.View.MeasureSpec.makeMeasureSpec; public final class EmojiVariantPopup { private static final int DO_NOT_UPDATE_FLAG = -1; private final View rootView; private final OnEmojiClickListener listener; private PopupWindow popupWindow; private View rootImageView; private final EmojiVariantManager emojiVariantManager; private final AppExecutors appExecutors; public EmojiVariantPopup(@NonNull final View rootView, final OnEmojiClickListener listener) { this.rootView = rootView; this.listener = listener; emojiVariantManager = EmojiVariantManager.getInstance(); appExecutors = AppExecutors.INSTANCE; } public void show(@NonNull final View view, @NonNull final Emoji emoji) { dismiss(); rootImageView = view; final View content = initView(view.getContext(), emoji, view.getWidth()); popupWindow = new PopupWindow(content, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT); popupWindow.setFocusable(true); popupWindow.setOutsideTouchable(true); popupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); popupWindow.setBackgroundDrawable(new BitmapDrawable(view.getContext().getResources(), (Bitmap) null)); content.measure(makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); final Point location = locationOnScreen(view); final Point desiredLocation = new Point( location.x - content.getMeasuredWidth() / 2 + view.getWidth() / 2, location.y - content.getMeasuredHeight() ); popupWindow.showAtLocation(rootView, Gravity.NO_GRAVITY, desiredLocation.x, desiredLocation.y); rootImageView.getParent().requestDisallowInterceptTouchEvent(true); fixPopupLocation(popupWindow, desiredLocation); } public void dismiss() { rootImageView = null; if (popupWindow != null) { popupWindow.dismiss(); popupWindow = null; } } private View initView(@NonNull final Context context, @NonNull final Emoji emoji, final int width) { final LayoutInflater layoutInflater = LayoutInflater.from(context); final LayoutEmojiVariantPopupBinding binding = LayoutEmojiVariantPopupBinding.inflate(layoutInflater, null, false); final List variants = new ArrayList<>(emoji.getVariants()); // Add parent at start of list // variants.add(0, emoji); for (final Emoji variant : variants) { final ItemEmojiGridBinding itemBinding = ItemEmojiGridBinding.inflate(layoutInflater, binding.container, false); final ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) itemBinding.image.getLayoutParams(); // Use the same size for Emojis as in the picker. layoutParams.width = width; itemBinding.image.setImageDrawable(variant.getDrawable()); itemBinding.image.setOnClickListener(view -> { if (listener != null) { if (!variant.getUnicode().equals(emojiVariantManager.getVariant(emoji.getUnicode()))) { emojiVariantManager.setVariant(emoji.getUnicode(), variant.getUnicode()); } listener.onClick(view, variant); } dismiss(); }); binding.container.addView(itemBinding.getRoot()); } return binding.getRoot(); } @NonNull private Point locationOnScreen(@NonNull final View view) { final int[] location = new int[2]; view.getLocationOnScreen(location); return new Point(location[0], location[1]); } private void fixPopupLocation(@NonNull final PopupWindow popupWindow, @NonNull final Point desiredLocation) { popupWindow.getContentView().post(() -> { final Point actualLocation = locationOnScreen(popupWindow.getContentView()); if (!(actualLocation.x == desiredLocation.x && actualLocation.y == desiredLocation.y)) { final int differenceX = actualLocation.x - desiredLocation.x; final int differenceY = actualLocation.y - desiredLocation.y; final int fixedOffsetX; final int fixedOffsetY; if (actualLocation.x > desiredLocation.x) { fixedOffsetX = desiredLocation.x - differenceX; } else { fixedOffsetX = desiredLocation.x + differenceX; } if (actualLocation.y > desiredLocation.y) { fixedOffsetY = desiredLocation.y - differenceY; } else { fixedOffsetY = desiredLocation.y + differenceY; } popupWindow.update(fixedOffsetX, fixedOffsetY, DO_NOT_UPDATE_FLAG, DO_NOT_UPDATE_FLAG); } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/GoogleCompatEmojiDrawable.java ================================================ package awais.instagrabber.customviews.emoji; /* * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.text.Spanned; import android.text.TextPaint; import androidx.annotation.NonNull; import androidx.emoji.text.EmojiCompat; import androidx.emoji.text.EmojiSpan; /** * An emoji drawable backed by a span generated by the Google emoji support library. */ final class GoogleCompatEmojiDrawable extends Drawable { private static final String TAG = GoogleCompatEmojiDrawable.class.getSimpleName(); private static final float TEXT_SIZE_FACTOR = 0.8f; private static final float BASELINE_OFFSET_FACTOR = 0.225f; private EmojiSpan emojiSpan; private boolean processed; private CharSequence emojiCharSequence; private final TextPaint textPaint = new TextPaint(); GoogleCompatEmojiDrawable(@NonNull final String unicode) { emojiCharSequence = unicode; textPaint.setStyle(Paint.Style.FILL); textPaint.setColor(0x0ffffffff); textPaint.setAntiAlias(true); } private void process() { emojiCharSequence = EmojiCompat.get().process(emojiCharSequence); if (emojiCharSequence instanceof Spanned) { final Object[] spans = ((Spanned) emojiCharSequence).getSpans(0, emojiCharSequence.length(), EmojiSpan.class); if (spans.length > 0) { emojiSpan = (EmojiSpan) spans[0]; } } } @Override public void draw(@NonNull final Canvas canvas) { final Rect bounds = getBounds(); textPaint.setTextSize(bounds.height() * TEXT_SIZE_FACTOR); final int y = Math.round(bounds.bottom - bounds.height() * BASELINE_OFFSET_FACTOR); if (!processed && EmojiCompat.get().getLoadState() != EmojiCompat.LOAD_STATE_LOADING) { processed = true; if (EmojiCompat.get().getLoadState() != EmojiCompat.LOAD_STATE_FAILED) { process(); } } if (emojiSpan == null) { canvas.drawText(emojiCharSequence, 0, emojiCharSequence.length(), bounds.left, y, textPaint); } else { emojiSpan.draw(canvas, emojiCharSequence, 0, emojiCharSequence.length(), bounds.left, bounds.top, y, bounds.bottom, textPaint); } } @Override public void setAlpha(final int alpha) { textPaint.setAlpha(alpha); } @Override public void setColorFilter(final ColorFilter colorFilter) { textPaint.setColorFilter(colorFilter); } @Override public int getOpacity() { return PixelFormat.UNKNOWN; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/emoji/ReactionsManager.java ================================================ package awais.instagrabber.customviews.emoji; import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; import com.google.common.collect.ImmutableList; import org.json.JSONArray; import org.json.JSONException; import java.util.ArrayList; import java.util.List; import java.util.Map; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.emoji.EmojiParser; import static awais.instagrabber.utils.Constants.PREF_REACTIONS; public class ReactionsManager { private static final String TAG = ReactionsManager.class.getSimpleName(); private static final Object LOCK = new Object(); // private final AppExecutors appExecutors = AppExecutors.INSTANCE; private final List reactions = new ArrayList<>(); private static ReactionsManager instance; public static ReactionsManager getInstance(@NonNull final Context context) { if (instance == null) { synchronized (LOCK) { if (instance == null) { instance = new ReactionsManager(context); } } } return instance; } private ReactionsManager(@NonNull final Context context) { final EmojiParser emojiParser = EmojiParser.Companion.getInstance(context); String reactionsJson = Utils.settingsHelper.getString(PREF_REACTIONS); if (TextUtils.isEmpty(reactionsJson)) { final ImmutableList list = ImmutableList.of("❤️", "\uD83D\uDE02", "\uD83D\uDE2E", "\uD83D\uDE22", "\uD83D\uDE21", "\uD83D\uDC4D"); reactionsJson = new JSONArray(list).toString(); } final Map allEmojis = emojiParser.getAllEmojis(); try { final JSONArray reactionsJsonArray = new JSONArray(reactionsJson); for (int i = 0; i < reactionsJsonArray.length(); i++) { final String emojiUnicode = reactionsJsonArray.optString(i); if (emojiUnicode == null) continue; final Emoji emoji = allEmojis.get(emojiUnicode); if (emoji == null) continue; reactions.add(emoji); } } catch (JSONException e) { Log.e(TAG, "ReactionsManager: ", e); } } public List getReactions() { return reactions; } // public void setVariant(final String parent, final String variant) { // if (parent == null || variant == null) return; // selectedVariantMap.put(parent, variant); // appExecutors.tasksThread().execute(() -> { // final JSONObject jsonObject = new JSONObject(selectedVariantMap); // final String json = jsonObject.toString(); // Utils.settingsHelper.putString(PREF_EMOJI_VARIANTS, json); // }); // } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/ChangeText.java ================================================ package awais.instagrabber.customviews.helpers; /* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.graphics.Color; import android.util.Log; import android.view.ViewGroup; import android.widget.EditText; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.transition.Transition; import androidx.transition.TransitionListenerAdapter; import androidx.transition.TransitionValues; import java.util.Map; import java.util.Objects; import awais.instagrabber.BuildConfig; /** * This transition tracks changes to the text in TextView targets. If the text * changes between the start and end scenes, the transition ensures that the * starting text stays until the transition ends, at which point it changes * to the end text. This is useful in situations where you want to resize a * text view to its new size before displaying the text that goes there. */ public class ChangeText extends Transition { private static final String LOG_TAG = "TextChange"; private static final String PROPNAME_TEXT = "android:textchange:text"; private static final String PROPNAME_TEXT_SELECTION_START = "android:textchange:textSelectionStart"; private static final String PROPNAME_TEXT_SELECTION_END = "android:textchange:textSelectionEnd"; private static final String PROPNAME_TEXT_COLOR = "android:textchange:textColor"; private int mChangeBehavior = CHANGE_BEHAVIOR_KEEP; private boolean crossFade; /** * Flag specifying that the text in affected/changing TextView targets will keep * their original text during the transition, setting it to the final text when * the transition ends. This is the default behavior. * * @see #setChangeBehavior(int) */ public static final int CHANGE_BEHAVIOR_KEEP = 0; /** * Flag specifying that the text changing animation should first fade * out the original text completely. The new text is set on the target * view at the end of the fade-out animation. This transition is typically * used with a later {@link #CHANGE_BEHAVIOR_IN} transition, allowing more * flexibility than the {@link #CHANGE_BEHAVIOR_OUT_IN} by allowing other * transitions to be run sequentially or in parallel with these fades. * * @see #setChangeBehavior(int) */ public static final int CHANGE_BEHAVIOR_OUT = 1; /** * Flag specifying that the text changing animation should fade in the * end text into the affected target view(s). This transition is typically * used in conjunction with an earlier {@link #CHANGE_BEHAVIOR_OUT} * transition, possibly with other transitions running as well, such as * a sequence to fade out, then resize the view, then fade in. * * @see #setChangeBehavior(int) */ public static final int CHANGE_BEHAVIOR_IN = 2; /** * Flag specifying that the text changing animation should first fade * out the original text completely and then fade in the * new text. * * @see #setChangeBehavior(int) */ public static final int CHANGE_BEHAVIOR_OUT_IN = 3; private static final String[] sTransitionProperties = { PROPNAME_TEXT, PROPNAME_TEXT_SELECTION_START, PROPNAME_TEXT_SELECTION_END }; /** * Sets the type of changing animation that will be run, one of * {@link #CHANGE_BEHAVIOR_KEEP}, {@link #CHANGE_BEHAVIOR_OUT}, * {@link #CHANGE_BEHAVIOR_IN}, and {@link #CHANGE_BEHAVIOR_OUT_IN}. * * @param changeBehavior The type of fading animation to use when this * transition is run. * @return this textChange object. */ public ChangeText setChangeBehavior(int changeBehavior) { if (changeBehavior >= CHANGE_BEHAVIOR_KEEP && changeBehavior <= CHANGE_BEHAVIOR_OUT_IN) { mChangeBehavior = changeBehavior; } return this; } public ChangeText setCrossFade(final boolean crossFade) { this.crossFade = crossFade; return this; } @Override public String[] getTransitionProperties() { return sTransitionProperties; } /** * Returns the type of changing animation that will be run. * * @return either {@link #CHANGE_BEHAVIOR_KEEP}, {@link #CHANGE_BEHAVIOR_OUT}, * {@link #CHANGE_BEHAVIOR_IN}, or {@link #CHANGE_BEHAVIOR_OUT_IN}. */ public int getChangeBehavior() { return mChangeBehavior; } private void captureValues(TransitionValues transitionValues) { if (transitionValues.view instanceof TextView) { TextView textview = (TextView) transitionValues.view; transitionValues.values.put(PROPNAME_TEXT, textview.getText()); if (textview instanceof EditText) { transitionValues.values.put(PROPNAME_TEXT_SELECTION_START, textview.getSelectionStart()); transitionValues.values.put(PROPNAME_TEXT_SELECTION_END, textview.getSelectionEnd()); } if (mChangeBehavior > CHANGE_BEHAVIOR_KEEP) { transitionValues.values.put(PROPNAME_TEXT_COLOR, textview.getCurrentTextColor()); } } } @Override public void captureStartValues(@NonNull TransitionValues transitionValues) { captureValues(transitionValues); } @Override public void captureEndValues(@NonNull TransitionValues transitionValues) { captureValues(transitionValues); } @Override public Animator createAnimator(@NonNull ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) { if (startValues == null || endValues == null || !(startValues.view instanceof TextView) || !(endValues.view instanceof TextView)) { return null; } final TextView view = (TextView) endValues.view; Map startVals = startValues.values; Map endVals = endValues.values; final CharSequence startText = startVals.get(PROPNAME_TEXT) != null ? (CharSequence) startVals.get(PROPNAME_TEXT) : ""; final CharSequence endText = endVals.get(PROPNAME_TEXT) != null ? (CharSequence) endVals.get(PROPNAME_TEXT) : ""; final int startSelectionStart, startSelectionEnd, endSelectionStart, endSelectionEnd; if (view instanceof EditText) { startSelectionStart = startVals.get(PROPNAME_TEXT_SELECTION_START) != null ? (Integer) startVals.get(PROPNAME_TEXT_SELECTION_START) : -1; startSelectionEnd = startVals.get(PROPNAME_TEXT_SELECTION_END) != null ? (Integer) startVals.get(PROPNAME_TEXT_SELECTION_END) : startSelectionStart; endSelectionStart = endVals.get(PROPNAME_TEXT_SELECTION_START) != null ? (Integer) endVals.get(PROPNAME_TEXT_SELECTION_START) : -1; endSelectionEnd = endVals.get(PROPNAME_TEXT_SELECTION_END) != null ? (Integer) endVals.get(PROPNAME_TEXT_SELECTION_END) : endSelectionStart; } else { startSelectionStart = startSelectionEnd = endSelectionStart = endSelectionEnd = -1; } if (!Objects.equals(startText, endText)) { final int startColor; final int endColor; if (mChangeBehavior != CHANGE_BEHAVIOR_IN) { view.setText(startText); if (view instanceof EditText) { setSelection(((EditText) view), startSelectionStart, startSelectionEnd); } } Animator anim; if (mChangeBehavior == CHANGE_BEHAVIOR_KEEP) { startColor = endColor = 0; anim = ValueAnimator.ofFloat(0, 1); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { if (Objects.equals(startText, view.getText())) { // Only set if it hasn't been changed since anim started view.setText(endText); if (view instanceof EditText) { setSelection(((EditText) view), endSelectionStart, endSelectionEnd); } } } }); } else { startColor = (Integer) startVals.get(PROPNAME_TEXT_COLOR); endColor = (Integer) endVals.get(PROPNAME_TEXT_COLOR); // Fade out start text ValueAnimator outAnim = null, inAnim = null; if (mChangeBehavior == CHANGE_BEHAVIOR_OUT_IN || mChangeBehavior == CHANGE_BEHAVIOR_OUT) { outAnim = ValueAnimator.ofInt(Color.alpha(startColor), 0); outAnim.addUpdateListener(animation -> { int currAlpha = (Integer) animation.getAnimatedValue(); view.setTextColor(currAlpha << 24 | startColor & 0xffffff); }); outAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { if (Objects.equals(startText, view.getText())) { // Only set if it hasn't been changed since anim started view.setText(endText); if (view instanceof EditText) { setSelection(((EditText) view), endSelectionStart, endSelectionEnd); } } // restore opaque alpha and correct end color view.setTextColor(endColor); } }); } if (mChangeBehavior == CHANGE_BEHAVIOR_OUT_IN || mChangeBehavior == CHANGE_BEHAVIOR_IN) { inAnim = ValueAnimator.ofInt(0, Color.alpha(endColor)); inAnim.addUpdateListener(animation -> { int currAlpha = (Integer) animation.getAnimatedValue(); view.setTextColor(currAlpha << 24 | endColor & 0xffffff); }); inAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationCancel(Animator animation) { // restore opaque alpha and correct end color view.setTextColor(endColor); } }); } if (outAnim != null && inAnim != null) { anim = new AnimatorSet(); final AnimatorSet animatorSet = (AnimatorSet) anim; if (crossFade) { animatorSet.playTogether(outAnim, inAnim); } else { animatorSet.playSequentially(outAnim, inAnim); } } else if (outAnim != null) { anim = outAnim; } else { // Must be an in-only animation anim = inAnim; } } TransitionListener transitionListener = new TransitionListenerAdapter() { int mPausedColor = 0; @Override public void onTransitionPause(@NonNull Transition transition) { if (mChangeBehavior != CHANGE_BEHAVIOR_IN) { view.setText(endText); if (view instanceof EditText) { setSelection(((EditText) view), endSelectionStart, endSelectionEnd); } } if (mChangeBehavior > CHANGE_BEHAVIOR_KEEP) { mPausedColor = view.getCurrentTextColor(); view.setTextColor(endColor); } } @Override public void onTransitionResume(@NonNull Transition transition) { if (mChangeBehavior != CHANGE_BEHAVIOR_IN) { view.setText(startText); if (view instanceof EditText) { setSelection(((EditText) view), startSelectionStart, startSelectionEnd); } } if (mChangeBehavior > CHANGE_BEHAVIOR_KEEP) { view.setTextColor(mPausedColor); } } @Override public void onTransitionEnd(Transition transition) { transition.removeListener(this); } }; addListener(transitionListener); if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "createAnimator returning " + anim); } return anim; } return null; } private void setSelection(EditText editText, int start, int end) { if (start >= 0 && end >= 0) { editText.setSelection(start, end); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/ControlFocusInsetsAnimationCallback.java ================================================ /* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package awais.instagrabber.customviews.helpers; import android.view.View; import androidx.annotation.NonNull; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsAnimationCompat; import androidx.core.view.WindowInsetsCompat; import java.util.List; /** * A [WindowInsetsAnimationCompat.Callback] which will request and clear focus on the given view, * depending on the [WindowInsetsCompat.Type.ime] visibility state when an IME * [WindowInsetsAnimationCompat] has finished. *

* This is primarily used when animating the [WindowInsetsCompat.Type.ime], so that the * appropriate view is focused for accepting input from the IME. */ public class ControlFocusInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { private final View view; public ControlFocusInsetsAnimationCallback(@NonNull final View view) { this(view, DISPATCH_MODE_STOP); } /** * @param view the view to request/clear focus * @param dispatchMode The dispatch mode for this callback. * @see WindowInsetsAnimationCompat.Callback.DispatchMode */ public ControlFocusInsetsAnimationCallback(@NonNull final View view, final int dispatchMode) { super(dispatchMode); this.view = view; } @NonNull @Override public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, @NonNull final List runningAnimations) { // no-op and return the insets return insets; } @Override public void onEnd(final WindowInsetsAnimationCompat animation) { if ((animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0) { // The animation has now finished, so we can check the view's focus state. // We post the check because the rootWindowInsets has not yet been updated, but will // be in the next message traversal view.post(this::checkFocus); } } private void checkFocus() { final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); boolean imeVisible = false; if (rootWindowInsets != null) { imeVisible = rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); } if (imeVisible && view.getRootView().findFocus() == null) { // If the IME will be visible, and there is not a currently focused view in // the hierarchy, request focus on our view view.requestFocus(); } else if (!imeVisible && view.isFocused()) { // If the IME will not be visible and our view is currently focused, clear the focus view.clearFocus(); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/CustomHideBottomViewOnScrollBehavior.java ================================================ package awais.instagrabber.customviews.helpers; import android.content.Context; import android.util.AttributeSet; import android.view.View; import androidx.annotation.NonNull; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.view.ViewCompat; import com.google.android.material.behavior.HideBottomViewOnScrollBehavior; import com.google.android.material.bottomnavigation.BottomNavigationView; public class CustomHideBottomViewOnScrollBehavior extends HideBottomViewOnScrollBehavior { private static final String TAG = "CustomHideBottomView"; public CustomHideBottomViewOnScrollBehavior() { } public CustomHideBottomViewOnScrollBehavior(final Context context, final AttributeSet attrs) { super(context, attrs); } @Override public boolean onStartNestedScroll(@NonNull final CoordinatorLayout coordinatorLayout, @NonNull final BottomNavigationView child, @NonNull final View directTargetChild, @NonNull final View target, final int nestedScrollAxes, final int type) { return nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL; } @Override public void onNestedPreScroll(@NonNull final CoordinatorLayout coordinatorLayout, @NonNull final BottomNavigationView child, @NonNull final View target, final int dx, final int dy, @NonNull final int[] consumed, final int type) { if (dy > 0) { slideDown(child); } else if (dy < 0) { slideUp(child); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/EmojiPickerInsetsAnimationCallback.java ================================================ package awais.instagrabber.customviews.helpers; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsAnimationCompat; import androidx.core.view.WindowInsetsCompat; import java.util.List; /** * A customized {@link TranslateDeferringInsetsAnimationCallback} for the emoji picker */ public class EmojiPickerInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { private static final String TAG = EmojiPickerInsetsAnimationCallback.class.getSimpleName(); private final View view; private final int persistentInsetTypes; private final int deferredInsetTypes; private int kbHeight; private onKbVisibilityChangeListener listener; private boolean shouldTranslate; public EmojiPickerInsetsAnimationCallback(final View view, final int persistentInsetTypes, final int deferredInsetTypes) { this(view, persistentInsetTypes, deferredInsetTypes, DISPATCH_MODE_STOP); } public EmojiPickerInsetsAnimationCallback(final View view, final int persistentInsetTypes, final int deferredInsetTypes, final int dispatchMode) { super(dispatchMode); if ((persistentInsetTypes & deferredInsetTypes) != 0) { throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + "any of same WindowInsetsCompat.Type values"); } this.view = view; this.persistentInsetTypes = persistentInsetTypes; this.deferredInsetTypes = deferredInsetTypes; } @NonNull @Override public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, @NonNull final List runningAnimations) { // onProgress() is called when any of the running animations progress... // First we get the insets which are potentially deferred final Insets typesInset = insets.getInsets(deferredInsetTypes); // Then we get the persistent inset types which are applied as padding during layout final Insets otherInset = insets.getInsets(persistentInsetTypes); // Now that we subtract the two insets, to calculate the difference. We also coerce // the insets to be >= 0, to make sure we don't use negative insets. final Insets subtract = Insets.subtract(typesInset, otherInset); final Insets diff = Insets.max(subtract, Insets.NONE); // The resulting `diff` insets contain the values for us to apply as a translation // to the view view.setTranslationX(diff.left - diff.right); view.setTranslationY(shouldTranslate ? diff.top - diff.bottom : -kbHeight); return insets; } @Override public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { try { final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); if (kbHeight == 0) { if (rootWindowInsets == null) return; final Insets imeInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime()); final Insets navBarInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()); kbHeight = imeInsets.bottom - navBarInsets.bottom; final ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); if (layoutParams != null) { layoutParams.height = kbHeight; layoutParams.setMargins(layoutParams.leftMargin, layoutParams.topMargin, layoutParams.rightMargin, -kbHeight); } } view.setTranslationX(0f); final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); float translationY = 0; if (!shouldTranslate) { translationY = -kbHeight; if (visible) { translationY = 0; } } view.setTranslationY(translationY); if (listener != null && rootWindowInsets != null) { listener.onChange(visible); } } finally { shouldTranslate = true; } } public void setShouldTranslate(final boolean shouldTranslate) { this.shouldTranslate = shouldTranslate; } public void setKbVisibilityListener(final onKbVisibilityChangeListener listener) { this.listener = listener; } public interface onKbVisibilityChangeListener { void onChange(boolean isVisible); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/GridAutofitLayoutManager.java ================================================ package awais.instagrabber.customviews.helpers; import android.content.Context; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.utils.Utils; public class GridAutofitLayoutManager extends GridLayoutManager { private int mColumnWidth; private boolean mColumnWidthChanged = true; public GridAutofitLayoutManager(Context context, int columnWidth) { super(context, 1); if (columnWidth <= 0) columnWidth = (int) (48 * Utils.displayMetrics.density); if (columnWidth > 0 && columnWidth != mColumnWidth) { mColumnWidth = columnWidth; mColumnWidthChanged = true; } } @Override public void onLayoutChildren(final RecyclerView.Recycler recycler, final RecyclerView.State state) { final int width = getWidth(); final int height = getHeight(); if (mColumnWidthChanged && mColumnWidth > 0 && width > 0 && height > 0) { final int totalSpace = getOrientation() == VERTICAL ? width - getPaddingRight() - getPaddingLeft() : height - getPaddingTop() - getPaddingBottom(); setSpanCount(Math.max(1, Math.min(totalSpace / mColumnWidth, 3))); mColumnWidthChanged = false; } super.onLayoutChildren(recycler, state); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/GridSpacingItemDecoration.java ================================================ package awais.instagrabber.customviews.helpers; import android.graphics.Rect; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; public class GridSpacingItemDecoration extends RecyclerView.ItemDecoration { private final int halfSpace; private boolean hasHeader; public GridSpacingItemDecoration(int spacing) { halfSpace = spacing / 2; } @Override public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { if (hasHeader && parent.getChildAdapterPosition(view) == 0) { outRect.bottom = halfSpace; outRect.left = -halfSpace; outRect.right = -halfSpace; return; } if (parent.getPaddingLeft() != halfSpace) { parent.setPadding(halfSpace, hasHeader ? 0 : halfSpace, halfSpace, halfSpace); parent.setClipToPadding(false); } outRect.top = halfSpace; outRect.bottom = halfSpace; outRect.left = halfSpace; outRect.right = halfSpace; } public void setHasHeader(final boolean hasHeader) { this.hasHeader = hasHeader; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/HeaderItemDecoration.java ================================================ package awais.instagrabber.customviews.helpers; import android.graphics.Canvas; import android.graphics.Rect; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.Pair; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; /** * Java implementation of this gist by filipkowicz */ public class HeaderItemDecoration extends RecyclerView.ItemDecoration { private static final String TAG = HeaderItemDecoration.class.getSimpleName(); private final HeaderItemDecorationCallback callback; private boolean layoutReversed = false; private Pair currentHeader; public HeaderItemDecoration(@NonNull RecyclerView parent, @NonNull HeaderItemDecorationCallback callback) { this.callback = callback; final RecyclerView.LayoutManager layoutManager = parent.getLayoutManager(); if (layoutManager instanceof LinearLayoutManager) { layoutReversed = ((LinearLayoutManager) layoutManager).getReverseLayout(); } //noinspection rawtypes final RecyclerView.Adapter adapter = parent.getAdapter(); if (adapter == null) return; adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { @Override public void onChanged() { // clear saved header as it can be outdated now Log.d(TAG, "registerAdapterDataObserver"); currentHeader = null; } }); parent.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { // clear saved layout as it may need layout update Log.d(TAG, "addOnLayoutChangeListener"); currentHeader = null; }); parent.addOnItemTouchListener(new RecyclerView.SimpleOnItemTouchListener() { @Override public boolean onInterceptTouchEvent(@NonNull final RecyclerView rv, @NonNull final MotionEvent e) { if (e.getAction() == MotionEvent.ACTION_DOWN && currentHeader != null) { final RecyclerView.ViewHolder viewHolder = currentHeader.second; if (viewHolder != null && viewHolder.itemView != null) { final int bottom = viewHolder.itemView.getBottom(); return e.getY() <= bottom; } } return super.onInterceptTouchEvent(rv, e); } }); } @Override public void onDrawOver(@NonNull final Canvas c, @NonNull final RecyclerView parent, @NonNull final RecyclerView.State state) { super.onDrawOver(c, parent, state); final View topChild = parent.findChildViewUnder( parent.getPaddingLeft(), parent.getPaddingTop() ); if (topChild == null) { return; } final int topChildPosition = parent.getChildAdapterPosition(topChild); if (topChildPosition == RecyclerView.NO_POSITION) { return; } final View headerView = getHeaderViewForItem(topChildPosition, parent); if (headerView == null) { return; } final int contactPoint = headerView.getBottom() + parent.getPaddingTop(); final View childInContact = getChildInContact(parent, contactPoint); if (childInContact != null && callback.isHeader(parent.getChildAdapterPosition(childInContact))) { moveHeader(c, headerView, childInContact, parent.getPaddingTop()); return; } drawHeader(c, headerView, parent.getPaddingTop()); } private void drawHeader(@NonNull final Canvas c, @NonNull final View header, final int paddingTop) { c.save(); c.translate(0f, paddingTop); header.draw(c); c.restore(); } private void moveHeader(@NonNull final Canvas c, @NonNull final View currentHeader, @NonNull final View nextHeader, final int paddingTop) { c.save(); c.translate(0f, nextHeader.getTop() - currentHeader.getHeight() /*+ paddingTop*/); currentHeader.draw(c); c.restore(); } @Nullable private View getChildInContact(@NonNull final RecyclerView parent, final int contactPoint) { View childInContact = null; final int childCount = parent.getChildCount(); for (int i = 0; i < childCount; i++) { final View child = parent.getChildAt(i); final Rect mBounds = new Rect(); parent.getDecoratedBoundsWithMargins(child, mBounds); if (mBounds.bottom > contactPoint) { if (mBounds.top <= contactPoint) { // This child overlaps the contactPoint childInContact = child; break; } } } return childInContact; } @Nullable private View getHeaderViewForItem(final int itemPosition, @NonNull final RecyclerView parent) { if (parent.getAdapter() == null) { return null; } final int headerPosition = getHeaderPositionForItem(itemPosition, parent.getAdapter()); if (headerPosition == RecyclerView.NO_POSITION) return null; final int headerType = parent.getAdapter().getItemViewType(headerPosition); // if match reuse viewHolder if (currentHeader != null && currentHeader.first == headerPosition && currentHeader.second.getItemViewType() == headerType) { return currentHeader.second.itemView; } final RecyclerView.ViewHolder headerHolder = parent.getAdapter().createViewHolder(parent, headerType); if (headerHolder != null) { //noinspection unchecked parent.getAdapter().onBindViewHolder(headerHolder, headerPosition); fixLayoutSize(parent, headerHolder.itemView); // save for next draw currentHeader = new Pair<>(headerPosition, headerHolder); return headerHolder.itemView; } return null; } @SuppressWarnings("rawtypes") private int getHeaderPositionForItem(final int itemPosition, final RecyclerView.Adapter adapter) { int headerPosition = RecyclerView.NO_POSITION; int currentPosition = itemPosition; do { if (callback.isHeader(currentPosition)) { headerPosition = currentPosition; break; } currentPosition += layoutReversed ? 1 : -1; } while (layoutReversed ? currentPosition < adapter.getItemCount() : currentPosition >= 0); return headerPosition; } /** * Properly measures and layouts the top sticky header. * * @param parent ViewGroup: RecyclerView in this case. */ private void fixLayoutSize(@NonNull final ViewGroup parent, @NonNull final View view) { // Specs for parent (RecyclerView) final int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); final int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); // Specs for children (headers) final int childWidthSpec = ViewGroup.getChildMeasureSpec( widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), view.getLayoutParams().width ); final int childHeightSpec = ViewGroup.getChildMeasureSpec( heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), view.getLayoutParams().height ); view.measure(childWidthSpec, childHeightSpec); view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); } public View getCurrentHeader() { return currentHeader == null ? null : currentHeader.second.itemView; } public interface HeaderItemDecorationCallback { boolean isHeader(int itemPosition); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/HeightProvider.java ================================================ package awais.instagrabber.customviews.helpers; import android.app.Activity; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.view.Gravity; import android.view.View; import android.view.ViewTreeObserver.OnGlobalLayoutListener; import android.view.WindowManager.LayoutParams; import android.widget.PopupWindow; public class HeightProvider extends PopupWindow implements OnGlobalLayoutListener { private final Activity mActivity; private final View rootView; private HeightListener listener; private int heightMax; public HeightProvider(Activity activity) { super(activity); this.mActivity = activity; rootView = new View(activity); setContentView(rootView); rootView.getViewTreeObserver().addOnGlobalLayoutListener(this); setBackgroundDrawable(new ColorDrawable(0)); setWidth(0); setHeight(LayoutParams.MATCH_PARENT); setSoftInputMode(LayoutParams.SOFT_INPUT_ADJUST_RESIZE); setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED); } public HeightProvider init() { if (!isShowing()) { final View view = mActivity.getWindow().getDecorView(); view.post(() -> showAtLocation(view, Gravity.NO_GRAVITY, 0, 0)); } return this; } public HeightProvider setHeightListener(HeightListener listener) { this.listener = listener; return this; } @Override public void onGlobalLayout() { Rect rect = new Rect(); rootView.getWindowVisibleDisplayFrame(rect); if (rect.bottom > heightMax) { heightMax = rect.bottom; } int keyboardHeight = heightMax - rect.bottom; if (listener != null) { listener.onHeightChanged(keyboardHeight); } } public interface HeightListener { void onHeightChanged(int height); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/ImageResizingControllerListener.java ================================================ package awais.instagrabber.customviews.helpers; import android.graphics.drawable.Animatable; import android.view.ViewGroup; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.drawee.generic.GenericDraweeHierarchy; import com.facebook.drawee.view.DraweeView; import com.facebook.imagepipeline.image.ImageInfo; import awais.instagrabber.utils.NumberUtils; public class ImageResizingControllerListener> extends BaseControllerListener { private static final String TAG = "ImageResizingController"; private T imageView; private final int requiredWidth; public ImageResizingControllerListener(final T imageView, final int requiredWidth) { this.imageView = imageView; this.requiredWidth = requiredWidth; } @Override public void onIntermediateImageSet(final String id, final ImageInfo imageInfo) { super.onIntermediateImageSet(id, imageInfo); } public void onFinalImageSet(String id, ImageInfo imageInfo, Animatable animatable) { if (imageInfo != null) { // updateViewSize(imageInfo); final int height = imageInfo.getHeight(); final int width = imageInfo.getWidth(); // final float aspectRatio = ((float) width) / height; final ViewGroup.LayoutParams layoutParams = imageView.getLayoutParams(); // final int deviceWidth = Utils.displayMetrics.widthPixels; final int resultingHeight = NumberUtils.getResultingHeight(requiredWidth, height, width); layoutParams.width = requiredWidth; layoutParams.height = resultingHeight; imageView.requestLayout(); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/NestedCoordinatorLayout.java ================================================ package awais.instagrabber.customviews.helpers; import android.content.Context; import android.util.AttributeSet; import android.view.View; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.view.NestedScrollingChild; import androidx.core.view.NestedScrollingChildHelper; public class NestedCoordinatorLayout extends CoordinatorLayout implements NestedScrollingChild { private NestedScrollingChildHelper mChildHelper; public NestedCoordinatorLayout(Context context) { super(context); mChildHelper = new NestedScrollingChildHelper(this); setNestedScrollingEnabled(true); } public NestedCoordinatorLayout(Context context, AttributeSet attrs) { super(context, attrs); mChildHelper = new NestedScrollingChildHelper(this); setNestedScrollingEnabled(true); } public NestedCoordinatorLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mChildHelper = new NestedScrollingChildHelper(this); setNestedScrollingEnabled(true); } @Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) { int[][] tConsumed = new int[2][2]; super.onNestedPreScroll(target, dx, dy, consumed, type); dispatchNestedPreScroll(dx, dy, tConsumed[1], null); consumed[0] = tConsumed[0][0] + tConsumed[1][0]; consumed[1] = tConsumed[0][1] + tConsumed[1][1]; } @Override public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) { super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type); dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, null); } @Override public void onStopNestedScroll(View target, int type) { /* Disable the scrolling behavior of our own children */ super.onStopNestedScroll(target, type); /* Disable the scrolling behavior of the parent's other children */ stopNestedScroll(); } @Override public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes, int type) { /* Enable the scrolling behavior of our own children */ boolean tHandled = super.onStartNestedScroll(child, target, nestedScrollAxes, type); /* Enable the scrolling behavior of the parent's other children */ return startNestedScroll(nestedScrollAxes) || tHandled; } @Override public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { /* Enable the scrolling behavior of our own children */ boolean tHandled = super.onStartNestedScroll(child, target, nestedScrollAxes); /* Enable the scrolling behavior of the parent's other children */ return startNestedScroll(nestedScrollAxes) || tHandled; } @Override public void onStopNestedScroll(View target) { /* Disable the scrolling behavior of our own children */ super.onStopNestedScroll(target); /* Disable the scrolling behavior of the parent's other children */ stopNestedScroll(); } @Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { int[][] tConsumed = new int[2][2]; super.onNestedPreScroll(target, dx, dy, tConsumed[0]); dispatchNestedPreScroll(dx, dy, tConsumed[1], null); consumed[0] = tConsumed[0][0] + tConsumed[1][0]; consumed[1] = tConsumed[0][1] + tConsumed[1][1]; } @Override public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed); dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, null); } @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { boolean tHandled = super.onNestedPreFling(target, velocityX, velocityY); return dispatchNestedPreFling(velocityX, velocityY) || tHandled; } @Override public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { boolean tHandled = super.onNestedFling(target, velocityX, velocityY, consumed); return dispatchNestedFling(velocityX, velocityY, consumed) || tHandled; } @Override public boolean isNestedScrollingEnabled() { return mChildHelper.isNestedScrollingEnabled(); } @Override public void setNestedScrollingEnabled(boolean enabled) { mChildHelper.setNestedScrollingEnabled(enabled); } @Override public boolean startNestedScroll(int axes) { return mChildHelper.startNestedScroll(axes); } @Override public void stopNestedScroll() { mChildHelper.stopNestedScroll(); } @Override public boolean hasNestedScrollingParent() { return mChildHelper.hasNestedScrollingParent(); } @Override public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) { return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow); } @Override public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow); } @Override public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); } @Override public boolean dispatchNestedPreFling(float velocityX, float velocityY) { return mChildHelper.dispatchNestedPreFling(velocityX, velocityY); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/NestedScrollableHost.java ================================================ package awais.instagrabber.customviews.helpers; import android.content.Context; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.viewpager2.widget.ViewPager2; import static androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL; public class NestedScrollableHost extends FrameLayout { private int touchSlop; private float initialX = 0f; private float initialY = 0f; public NestedScrollableHost(@NonNull final Context context) { this(context, null); } public NestedScrollableHost(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); } @Override public boolean onInterceptTouchEvent(final MotionEvent ev) { handleInterceptTouchEvent(ev); return super.onInterceptTouchEvent(ev); } private void handleInterceptTouchEvent(final MotionEvent e) { if (getParentViewPager() == null) return; final int orientation = getParentViewPager().getOrientation(); // Early return if child can't scroll in same direction as parent if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) return; if (e.getAction() == MotionEvent.ACTION_DOWN) { initialX = e.getX(); initialY = e.getY(); getParent().requestDisallowInterceptTouchEvent(true); } else if (e.getAction() == MotionEvent.ACTION_MOVE) { final float dx = e.getX() - initialX; final float dy = e.getY() - initialY; final boolean isVpHorizontal = orientation == ORIENTATION_HORIZONTAL; // assuming ViewPager2 touch-slop is 2x touch-slop of child final float scaledDx = Math.abs(dx) * (isVpHorizontal ? .5f : 1f); final float scaledDy = Math.abs(dy) * (isVpHorizontal ? 1f : .5f); if (scaledDx > touchSlop || scaledDy > touchSlop) { if (isVpHorizontal == (scaledDy > scaledDx)) { // Gesture is perpendicular, allow all parents to intercept getParent().requestDisallowInterceptTouchEvent(false); } else { // Gesture is parallel, query child if movement in that direction is possible if (canChildScroll(orientation, (isVpHorizontal ? dx : dy))) { // Child can scroll, disallow all parents to intercept getParent().requestDisallowInterceptTouchEvent(true); } else { // Child cannot scroll, allow all parents to intercept getParent().requestDisallowInterceptTouchEvent(false); } } } } } private boolean canChildScroll(final int orientation, final float delta) { final int direction = -(int) Math.signum(delta); final View child = getChild(); if (child == null) return false; ViewPager2 viewPagerChild = null; if (child instanceof ViewPager2) { viewPagerChild = (ViewPager2) child; } boolean canScroll; switch (orientation) { case 0: canScroll = child.canScrollHorizontally(direction); break; case 1: canScroll = child.canScrollVertically(direction); break; default: throw new IllegalArgumentException(); } if (!canScroll || viewPagerChild == null || viewPagerChild.getAdapter() == null) return canScroll; // check if viewpager has reached its limits and decide accordingly return (direction < 0 && viewPagerChild.getCurrentItem() > 0) || (direction > 0 && viewPagerChild.getCurrentItem() < viewPagerChild.getAdapter().getItemCount() - 1); } public ViewPager2 getParentViewPager() { View v = (View) getParent(); while (v != null && !(v instanceof ViewPager2)) { v = (View) v.getParent(); } return (ViewPager2) v; } public View getChild() { return getChildCount() > 0 ? getChildAt(0) : null; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/PostFetcher.java ================================================ package awais.instagrabber.customviews.helpers; import android.util.Log; import java.util.List; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.repositories.responses.Media; public class PostFetcher { private static final String TAG = PostFetcher.class.getSimpleName(); private final PostFetchService postFetchService; private final FetchListener> fetchListener; private boolean fetching; public PostFetcher(final PostFetchService postFetchService, final FetchListener> fetchListener) { this.postFetchService = postFetchService; this.fetchListener = fetchListener; } public void fetch() { if (fetching) return; fetching = true; postFetchService.fetch(new FetchListener>() { @Override public void onResult(final List result) { fetching = false; fetchListener.onResult(result); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "onFailure: ", t); } }); } public void reset() { postFetchService.reset(); } public boolean isFetching() { return fetching; } public boolean hasMore() { return postFetchService.hasNextPage(); } public interface PostFetchService { void fetch(FetchListener> fetchListener); void reset(); boolean hasNextPage(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/RecordViewAnimationHelper.java ================================================ package awais.instagrabber.customviews.helpers; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.os.Handler; import android.view.View; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.animation.TranslateAnimation; import android.widget.ImageView; import androidx.appcompat.widget.AppCompatImageView; import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; import awais.instagrabber.R; import awais.instagrabber.customviews.RecordButton; import awais.instagrabber.customviews.RecordView.OnBasketAnimationEnd; import static android.view.View.INVISIBLE; import static android.view.View.VISIBLE; public class RecordViewAnimationHelper { private static final String TAG = RecordViewAnimationHelper.class.getSimpleName(); private final Context context; private final AnimatedVectorDrawableCompat animatedVectorDrawable; private final ImageView basketImg; private final ImageView smallBlinkingMic; private AlphaAnimation alphaAnimation; private OnBasketAnimationEnd onBasketAnimationEndListener; private boolean isBasketAnimating; private boolean isStartRecorded = false; private float micX = 0; private float micY = 0; private AnimatorSet micAnimation; private TranslateAnimation translateAnimation1, translateAnimation2; private Handler handler1, handler2; public RecordViewAnimationHelper(Context context, AppCompatImageView basketImg, AppCompatImageView smallBlinkingMic) { this.context = context; this.smallBlinkingMic = smallBlinkingMic; this.basketImg = basketImg; animatedVectorDrawable = AnimatedVectorDrawableCompat.create(context, R.drawable.recv_basket_animated); } @SuppressLint("RestrictedApi") public void animateBasket(float basketInitialY) { isBasketAnimating = true; clearAlphaAnimation(false); //save initial x,y values for mic icon if (micX == 0) { micX = smallBlinkingMic.getX(); micY = smallBlinkingMic.getY(); } micAnimation = (AnimatorSet) AnimatorInflaterCompat.loadAnimator(context, R.animator.delete_mic_animation); micAnimation.setTarget(smallBlinkingMic); // set the view you want to animate translateAnimation1 = new TranslateAnimation(0, 0, basketInitialY, basketInitialY - 90); translateAnimation1.setDuration(250); translateAnimation2 = new TranslateAnimation(0, 0, basketInitialY - 90, basketInitialY); translateAnimation2.setDuration(350); micAnimation.start(); basketImg.setImageDrawable(animatedVectorDrawable); handler1 = new Handler(); handler1.postDelayed(() -> { basketImg.setVisibility(VISIBLE); basketImg.startAnimation(translateAnimation1); }, 350); translateAnimation1.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) {} @Override public void onAnimationEnd(Animation animation) { animatedVectorDrawable.start(); handler2 = new Handler(); handler2.postDelayed(() -> { basketImg.startAnimation(translateAnimation2); smallBlinkingMic.setVisibility(INVISIBLE); basketImg.setVisibility(INVISIBLE); }, 450); } @Override public void onAnimationRepeat(Animation animation) {} }); translateAnimation2.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) {} @Override public void onAnimationEnd(Animation animation) { basketImg.setVisibility(INVISIBLE); isBasketAnimating = false; //if the user pressed the record button while the animation is running // then do NOT call on Animation end if (onBasketAnimationEndListener != null && !isStartRecorded) { onBasketAnimationEndListener.onAnimationEnd(); } } @Override public void onAnimationRepeat(Animation animation) {} }); } //if the user started a new Record while the Animation is running // then we want to stop the current animation and revert views back to default state public void resetBasketAnimation() { if (isBasketAnimating) { translateAnimation1.reset(); translateAnimation1.cancel(); translateAnimation2.reset(); translateAnimation2.cancel(); micAnimation.cancel(); smallBlinkingMic.clearAnimation(); basketImg.clearAnimation(); if (handler1 != null) { handler1.removeCallbacksAndMessages(null); } if (handler2 != null) { handler2.removeCallbacksAndMessages(null); } basketImg.setVisibility(INVISIBLE); smallBlinkingMic.setX(micX); smallBlinkingMic.setY(micY); smallBlinkingMic.setVisibility(View.GONE); isBasketAnimating = false; } } public void clearAlphaAnimation(boolean hideView) { if (alphaAnimation != null) { alphaAnimation.cancel(); alphaAnimation.reset(); } smallBlinkingMic.clearAnimation(); if (hideView) { smallBlinkingMic.setVisibility(View.GONE); } } public void animateSmallMicAlpha() { alphaAnimation = new AlphaAnimation(0.0f, 1.0f); alphaAnimation.setDuration(500); alphaAnimation.setRepeatMode(Animation.REVERSE); alphaAnimation.setRepeatCount(Animation.INFINITE); smallBlinkingMic.startAnimation(alphaAnimation); } public void moveRecordButtonAndSlideToCancelBack(final RecordButton recordBtn, View slideToCancelLayout, float initialX, float difX) { final ValueAnimator positionAnimator = ValueAnimator.ofFloat(recordBtn.getX(), initialX); positionAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); positionAnimator.addUpdateListener(animation -> { float x = (Float) animation.getAnimatedValue(); recordBtn.setX(x); }); recordBtn.stopScale(); positionAnimator.setDuration(200); positionAnimator.start(); // if the move event was not called ,then the difX will still 0 and there is no need to move it back if (difX != 0) { float x = initialX - difX; slideToCancelLayout.animate() .x(x) .setDuration(0) .start(); } } public void resetSmallMic() { smallBlinkingMic.setAlpha(1.0f); smallBlinkingMic.setScaleX(1.0f); smallBlinkingMic.setScaleY(1.0f); } public void setOnBasketAnimationEndListener(OnBasketAnimationEnd onBasketAnimationEndListener) { this.onBasketAnimationEndListener = onBasketAnimationEndListener; } public void onAnimationEnd() { if (onBasketAnimationEndListener != null) { onBasketAnimationEndListener.onAnimationEnd(); } } //check if the user started a new Record by pressing the RecordButton public void setStartRecorded(boolean startRecorded) { isStartRecorded = startRecorded; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoader.java ================================================ package awais.instagrabber.customviews.helpers; import android.os.Handler; import androidx.annotation.NonNull; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; import awais.instagrabber.interfaces.LazyLoadListener; /** * thanks to nesquena's EndlessRecyclerViewScrollListener */ public final class RecyclerLazyLoader extends RecyclerView.OnScrollListener { /** * The current offset index of data you have loaded */ private int currentPage = 0; /** * The total number of items in the data set after the last load */ private int previousTotalItemCount = 0; /** * true if we are still waiting for the last set of data to load. */ private boolean loading = true; /** * The minimum amount of items to have below your current scroll position before loading more. */ private final int visibleThreshold; private final LazyLoadListener lazyLoadListener; private final RecyclerView.LayoutManager layoutManager; public RecyclerLazyLoader(@NonNull final RecyclerView.LayoutManager layoutManager, final LazyLoadListener lazyLoadListener, final int threshold) { this.layoutManager = layoutManager; this.lazyLoadListener = lazyLoadListener; if (threshold > 0) { this.visibleThreshold = threshold; return; } if (layoutManager instanceof GridLayoutManager) { this.visibleThreshold = 5 * Math.max(3, ((GridLayoutManager) layoutManager).getSpanCount()); } else if (layoutManager instanceof StaggeredGridLayoutManager) { this.visibleThreshold = 4 * Math.max(3, ((StaggeredGridLayoutManager) layoutManager).getSpanCount()); } else if (layoutManager instanceof LinearLayoutManager) { this.visibleThreshold = ((LinearLayoutManager) layoutManager).getReverseLayout() ? 4 : 8; } else { this.visibleThreshold = 5; } } public RecyclerLazyLoader(@NonNull final RecyclerView.LayoutManager layoutManager, final LazyLoadListener lazyLoadListener) { this(layoutManager, lazyLoadListener, -1); } @Override public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { final int totalItemCount = layoutManager.getItemCount(); if (totalItemCount < previousTotalItemCount) { currentPage = 0; previousTotalItemCount = totalItemCount; if (totalItemCount == 0) loading = true; } if (loading && totalItemCount > previousTotalItemCount) { loading = false; previousTotalItemCount = totalItemCount; } int lastVisibleItemPosition; if (layoutManager instanceof GridLayoutManager) { final GridLayoutManager layoutManager = (GridLayoutManager) this.layoutManager; lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); } else if (layoutManager instanceof StaggeredGridLayoutManager) { final StaggeredGridLayoutManager layoutManager = (StaggeredGridLayoutManager) this.layoutManager; final int spanCount = layoutManager.getSpanCount(); final int[] lastVisibleItemPositions = layoutManager.findLastVisibleItemPositions(null); lastVisibleItemPosition = 0; for (final int itemPosition : lastVisibleItemPositions) { if (itemPosition > lastVisibleItemPosition) { lastVisibleItemPosition = itemPosition; } } } else { final LinearLayoutManager layoutManager = (LinearLayoutManager) this.layoutManager; lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition(); } if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) { loading = true; if (lazyLoadListener != null) { new Handler().postDelayed(() -> lazyLoadListener.onLoadMore(++currentPage, totalItemCount), 200); } } } public int getCurrentPage() { return currentPage; } public void resetState() { this.currentPage = 0; this.previousTotalItemCount = 0; this.loading = true; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/RecyclerLazyLoaderAtEdge.java ================================================ package awais.instagrabber.customviews.helpers; import android.os.Handler; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; public final class RecyclerLazyLoaderAtEdge extends RecyclerView.OnScrollListener { private final RecyclerView.LayoutManager layoutManager; private final LazyLoadListener lazyLoadListener; private final boolean atTop; private int currentPage; private int previousItemCount; private boolean loading; public RecyclerLazyLoaderAtEdge(@NonNull final RecyclerView.LayoutManager layoutManager, final LazyLoadListener lazyLoadListener) { this.layoutManager = layoutManager; this.atTop = false; this.lazyLoadListener = lazyLoadListener; } public RecyclerLazyLoaderAtEdge(@NonNull final RecyclerView.LayoutManager layoutManager, final boolean atTop, final LazyLoadListener lazyLoadListener) { this.layoutManager = layoutManager; this.atTop = atTop; this.lazyLoadListener = lazyLoadListener; } @Override public void onScrollStateChanged(@NonNull final RecyclerView recyclerView, final int newState) { super.onScrollStateChanged(recyclerView, newState); final int itemCount = layoutManager.getItemCount(); if (itemCount > previousItemCount) { loading = false; } if (!recyclerView.canScrollVertically(atTop ? -1 : 1) && newState == RecyclerView.SCROLL_STATE_IDLE && !loading && lazyLoadListener != null) { loading = true; new Handler().postDelayed(() -> lazyLoadListener.onLoadMore(++currentPage), 300); } } public int getCurrentPage() { return currentPage; } public void resetState() { currentPage = 0; previousItemCount = 0; loading = true; } public interface LazyLoadListener { void onLoadMore(final int page); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/RootViewDeferringInsetsCallback.java ================================================ package awais.instagrabber.customviews.helpers;/* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.graphics.Insets; import androidx.core.view.OnApplyWindowInsetsListener; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsAnimationCompat; import androidx.core.view.WindowInsetsCompat; import java.util.List; /** * A class which extends/implements both [WindowInsetsAnimationCompat.Callback] and * [View.OnApplyWindowInsetsListener], which should be set on the root view in your layout. *

* This class enables the root view is selectively defer handling any insets which match * [deferredInsetTypes], to enable better looking [WindowInsetsAnimationCompat]s. *

* An example is the following: when a [WindowInsetsAnimationCompat] is started, the system will dispatch * a [WindowInsetsCompat] instance which contains the end state of the animation. For the scenario of * the IME being animated in, that means that the insets contains the IME height. If the view's * [View.OnApplyWindowInsetsListener] simply always applied the combination of * [WindowInsetsCompat.Type.ime] and [WindowInsetsCompat.Type.systemBars] using padding, the viewport of any * child views would then be smaller. This results in us animating a smaller (padded-in) view into * a larger viewport. Visually, this results in the views looking clipped. *

* This class allows us to implement a different strategy for the above scenario, by selectively * deferring the [WindowInsetsCompat.Type.ime] insets until the [WindowInsetsAnimationCompat] is ended. * For the above example, you would create a [RootViewDeferringInsetsCallback] like so: *

* ``` * val callback = RootViewDeferringInsetsCallback( * persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), * deferredInsetTypes = WindowInsetsCompat.Type.ime() * ) * ``` *

* This class is not limited to just IME animations, and can work with any [WindowInsetsCompat.Type]s. */ public class RootViewDeferringInsetsCallback extends WindowInsetsAnimationCompat.Callback implements OnApplyWindowInsetsListener { private final int persistentInsetTypes; private final int deferredInsetTypes; @Nullable private View view = null; @Nullable private WindowInsetsCompat lastWindowInsets = null; private boolean deferredInsets = false; /** * @param persistentInsetTypes the bitmask of any inset types which should always be handled * through padding the attached view * @param deferredInsetTypes the bitmask of insets types which should be deferred until after * any related [WindowInsetsAnimationCompat]s have ended */ public RootViewDeferringInsetsCallback(final int persistentInsetTypes, final int deferredInsetTypes) { super(DISPATCH_MODE_CONTINUE_ON_SUBTREE); if ((persistentInsetTypes & deferredInsetTypes) != 0) { throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + "any of same WindowInsetsCompat.Type values"); } this.persistentInsetTypes = persistentInsetTypes; this.deferredInsetTypes = deferredInsetTypes; } @Override public WindowInsetsCompat onApplyWindowInsets(@NonNull final View v, @NonNull final WindowInsetsCompat windowInsets) { // Store the view and insets for us in onEnd() below view = v; lastWindowInsets = windowInsets; final int types = deferredInsets // When the deferred flag is enabled, we only use the systemBars() insets ? persistentInsetTypes // Otherwise we handle the combination of the the systemBars() and ime() insets : persistentInsetTypes | deferredInsetTypes; // Finally we apply the resolved insets by setting them as padding final Insets typeInsets = windowInsets.getInsets(types); v.setPadding(typeInsets.left, typeInsets.top, typeInsets.right, typeInsets.bottom); // We return the new WindowInsetsCompat.CONSUMED to stop the insets being dispatched any // further into the view hierarchy. This replaces the deprecated // WindowInsetsCompat.consumeSystemWindowInsets() and related functions. return WindowInsetsCompat.CONSUMED; } @Override public void onPrepare(WindowInsetsAnimationCompat animation) { if ((animation.getTypeMask() & deferredInsetTypes) != 0) { // We defer the WindowInsetsCompat.Type.ime() insets if the IME is currently not visible. // This results in only the WindowInsetsCompat.Type.systemBars() being applied, allowing // the scrolling view to remain at it's larger size. deferredInsets = true; } } @NonNull @Override public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, @NonNull final List runningAnims) { // This is a no-op. We don't actually want to handle any WindowInsetsAnimations return insets; } @Override public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { if (deferredInsets && (animation.getTypeMask() & deferredInsetTypes) != 0) { // If we deferred the IME insets and an IME animation has finished, we need to reset // the flag deferredInsets = false; // And finally dispatch the deferred insets to the view now. // Ideally we would just call view.requestApplyInsets() and let the normal dispatch // cycle happen, but this happens too late resulting in a visual flicker. // Instead we manually dispatch the most recent WindowInsets to the view. if (lastWindowInsets != null && view != null) { ViewCompat.dispatchApplyWindowInsets(view, lastWindowInsets); } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/SimpleImeAnimationController.java ================================================ /* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package awais.instagrabber.customviews.helpers; import android.os.CancellationSignal; import android.util.Log; import android.view.View; import android.view.animation.LinearInterpolator; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsAnimationControlListenerCompat; import androidx.core.view.WindowInsetsAnimationControllerCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import androidx.dynamicanimation.animation.FloatPropertyCompat; import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import awais.instagrabber.utils.ViewUtils; /** * A wrapper around the [WindowInsetsAnimationControllerCompat] APIs in AndroidX Core, to simplify * the implementation of common use-cases around the IME. *

* See [InsetsAnimationLinearLayout] and [InsetsAnimationTouchListener] for examples of how * to use this class. */ public class SimpleImeAnimationController { private static final String TAG = SimpleImeAnimationController.class.getSimpleName(); /** * Scroll threshold for determining whether to animating to the end state, or to the start state. * Currently 15% of the total swipe distance distance */ private static final float SCROLL_THRESHOLD = 0.15f; @Nullable private WindowInsetsAnimationControllerCompat insetsAnimationController = null; @Nullable private CancellationSignal pendingRequestCancellationSignal = null; @Nullable private OnRequestReadyListener pendingRequestOnReadyListener; /** * True if the IME was shown at the start of the current animation. */ private boolean isImeShownAtStart = false; @Nullable private SpringAnimation currentSpringAnimation = null; private WindowInsetsAnimationControlListenerCompat fwdListener; /** * A LinearInterpolator instance we can re-use across listeners. */ private final LinearInterpolator linearInterpolator = new LinearInterpolator(); /* To take control of the an WindowInsetsAnimation, we need to pass in a listener to controlWindowInsetsAnimation() in startControlRequest(). The listener created here keeps track of the current WindowInsetsAnimationController and resets our state. */ private final WindowInsetsAnimationControlListenerCompat animationControlListener = new WindowInsetsAnimationControlListenerCompat() { /** * Once the request is ready, call our [onRequestReady] function */ @Override public void onReady(@NonNull final WindowInsetsAnimationControllerCompat controller, final int types) { onRequestReady(controller); if (fwdListener != null) { fwdListener.onReady(controller, types); } } /** * If the request is finished, we should reset our internal state */ @Override public void onFinished(@NonNull final WindowInsetsAnimationControllerCompat controller) { reset(); if (fwdListener != null) { fwdListener.onFinished(controller); } } /** * If the request is cancelled, we should reset our internal state */ @Override public void onCancelled(@Nullable final WindowInsetsAnimationControllerCompat controller) { reset(); if (fwdListener != null) { fwdListener.onCancelled(controller); } } }; /** * Start a control request to the [view]s [android.view.WindowInsetsController]. This should * be called once the view is in a position to take control over the position of the IME. * * @param view The view which is triggering this request * @param onRequestReadyListener optional listener which will be called when the request is ready and * the animation can proceed */ public void startControlRequest(@NonNull final View view, @Nullable final OnRequestReadyListener onRequestReadyListener) { if (isInsetAnimationInProgress()) { Log.w(TAG, "startControlRequest: Animation in progress. Can not start a new request to controlWindowInsetsAnimation()"); return; } // Keep track of the IME insets, and the IME visibility, at the start of the request final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); if (rootWindowInsets != null) { isImeShownAtStart = rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); } // Create a cancellation signal, which we pass to controlWindowInsetsAnimation() below pendingRequestCancellationSignal = new CancellationSignal(); // Keep reference to the onReady callback pendingRequestOnReadyListener = onRequestReadyListener; // Finally we make a controlWindowInsetsAnimation() request: final WindowInsetsControllerCompat windowInsetsController = ViewCompat.getWindowInsetsController(view); if (windowInsetsController != null) { windowInsetsController.controlWindowInsetsAnimation( // We're only catering for IME animations in this listener WindowInsetsCompat.Type.ime(), // Animation duration. This is not used by the system, and is only passed to any // WindowInsetsAnimation.Callback set on views. We pass in -1 to indicate that we're // not starting a finite animation, and that this is completely controlled by // the user's touch. -1, // The time interpolator used in calculating the animation progress. The fraction value // we passed into setInsetsAndAlpha() which be passed into this interpolator before // being used by the system to inset the IME. LinearInterpolator is a good type // to use for scrolling gestures. linearInterpolator, // A cancellation signal, which allows us to cancel the request to control pendingRequestCancellationSignal, // The WindowInsetsAnimationControlListener animationControlListener ); } } /** * Start a control request to the [view]s [android.view.WindowInsetsController], similar to * [startControlRequest], but immediately fling to a finish using [velocityY] once ready. *

* This function is useful for fire-and-forget operations to animate the IME. * * @param view The view which is triggering this request * @param velocityY the velocity of the touch gesture which caused this call */ public void startAndFling(@NonNull final View view, final float velocityY) { startControlRequest(view, null); animateToFinish(velocityY); } /** * Update the inset position of the IME by the given [dy] value. This value will be coerced * into the hidden and shown inset values. *

* This function should only be called if [isInsetAnimationInProgress] returns true. * * @return the amount of [dy] consumed by the inset animation, in pixels */ public int insetBy(final int dy) { if (insetsAnimationController == null) { throw new IllegalStateException("Current WindowInsetsAnimationController is null." + "This should only be called if isAnimationInProgress() returns true"); } final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; // Call updateInsetTo() with the new inset value return insetTo(controller.getCurrentInsets().bottom - dy); } /** * Update the inset position of the IME to be the given [inset] value. This value will be * coerced into the hidden and shown inset values. *

* This function should only be called if [isInsetAnimationInProgress] returns true. * * @return the distance moved by the inset animation, in pixels */ public int insetTo(final int inset) { if (insetsAnimationController == null) { throw new IllegalStateException("Current WindowInsetsAnimationController is null." + "This should only be called if isAnimationInProgress() returns true"); } final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; final int hiddenBottom = controller.getHiddenStateInsets().bottom; final int shownBottom = controller.getShownStateInsets().bottom; final int startBottom = isImeShownAtStart ? shownBottom : hiddenBottom; final int endBottom = isImeShownAtStart ? hiddenBottom : shownBottom; // We coerce the given inset within the limits of the hidden and shown insets final int coercedBottom = coerceIn(inset, hiddenBottom, shownBottom); final int consumedDy = controller.getCurrentInsets().bottom - coercedBottom; // Finally update the insets in the WindowInsetsAnimationController using // setInsetsAndAlpha(). controller.setInsetsAndAlpha( // Here we update the animating insets. This is what controls where the IME is displayed. // It is also passed through to views via their WindowInsetsAnimation.Callback. Insets.of(0, 0, 0, coercedBottom), // This controls the alpha value. We don't want to alter the alpha so use 1f 1f, // Finally we calculate the animation progress fraction. This value is passed through // to any WindowInsetsAnimation.Callbacks, but it is not used by the system. (coercedBottom - startBottom) / (float) (endBottom - startBottom) ); return consumedDy; } /** * Return `true` if an inset animation is in progress. */ public boolean isInsetAnimationInProgress() { return insetsAnimationController != null; } /** * Return `true` if an inset animation is currently finishing. */ public boolean isInsetAnimationFinishing() { return currentSpringAnimation != null; } /** * Return `true` if a request to control an inset animation is in progress. */ public boolean isInsetAnimationRequestPending() { return pendingRequestCancellationSignal != null; } /** * Cancel the current [WindowInsetsAnimationControllerCompat]. We immediately finish * the animation, reverting back to the state at the start of the gesture. */ public void cancel() { if (insetsAnimationController != null) { insetsAnimationController.finish(isImeShownAtStart); } if (pendingRequestCancellationSignal != null) { pendingRequestCancellationSignal.cancel(); } if (currentSpringAnimation != null) { // Cancel the current spring animation currentSpringAnimation.cancel(); } reset(); } /** * Finish the current [WindowInsetsAnimationControllerCompat] immediately. */ public void finish() { final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; if (controller == null) { // If we don't currently have a controller, cancel any pending request and return if (pendingRequestCancellationSignal != null) { pendingRequestCancellationSignal.cancel(); } return; } final int current = controller.getCurrentInsets().bottom; final int shown = controller.getShownStateInsets().bottom; final int hidden = controller.getHiddenStateInsets().bottom; // The current inset matches either the shown/hidden inset, finish() immediately if (current == shown) { controller.finish(true); } else if (current == hidden) { controller.finish(false); } else { // Otherwise, we'll look at the current position... if (controller.getCurrentFraction() >= SCROLL_THRESHOLD) { // If the IME is past the 'threshold' we snap to the toggled state controller.finish(!isImeShownAtStart); } else { // ...otherwise, we snap back to the original visibility controller.finish(isImeShownAtStart); } } } /** * Finish the current [WindowInsetsAnimationControllerCompat]. We finish the animation, * animating to the end state if necessary. * * @param velocityY the velocity of the touch gesture which caused this call to [animateToFinish]. * Can be `null` if velocity is not available. */ public void animateToFinish(@Nullable final Float velocityY) { final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; if (controller == null) { // If we don't currently have a controller, cancel any pending request and return if (pendingRequestCancellationSignal != null) { pendingRequestCancellationSignal.cancel(); } return; } final int current = controller.getCurrentInsets().bottom; final int shown = controller.getShownStateInsets().bottom; final int hidden = controller.getHiddenStateInsets().bottom; if (velocityY != null) { // If we have a velocity, we can use it's direction to determine // the visibility. Upwards == visible animateImeToVisibility(velocityY > 0, velocityY); } else if (current == shown) { // The current inset matches either the shown/hidden inset, finish() immediately controller.finish(true); } else if (current == hidden) { controller.finish(false); } else { // Otherwise, we'll look at the current position... if (controller.getCurrentFraction() >= SCROLL_THRESHOLD) { // If the IME is past the 'threshold' we animate it to the toggled state animateImeToVisibility(!isImeShownAtStart, null); } else { // ...otherwise, we animate it back to the original visibility animateImeToVisibility(isImeShownAtStart, null); } } } private void onRequestReady(@NonNull final WindowInsetsAnimationControllerCompat controller) { // The request is ready, so clear out the pending cancellation signal pendingRequestCancellationSignal = null; // Store the current WindowInsetsAnimationController insetsAnimationController = controller; // Call any pending callback if (pendingRequestOnReadyListener != null) { pendingRequestOnReadyListener.onRequestReady(controller); } pendingRequestOnReadyListener = null; } /** * Resets all of our internal state. */ private void reset() { // Clear all of our internal state insetsAnimationController = null; pendingRequestCancellationSignal = null; isImeShownAtStart = false; if (currentSpringAnimation != null) { currentSpringAnimation.cancel(); } currentSpringAnimation = null; pendingRequestOnReadyListener = null; } /** * Animate the IME to a given visibility. * * @param visible `true` to animate the IME to it's fully shown state, `false` to it's * fully hidden state. * @param velocityY the velocity of the touch gesture which caused this call. Can be `null` * if velocity is not available. */ private void animateImeToVisibility(final boolean visible, @Nullable final Float velocityY) { if (insetsAnimationController == null) { throw new IllegalStateException("Controller should not be null"); } final WindowInsetsAnimationControllerCompat controller = insetsAnimationController; final FloatPropertyCompat property = new FloatPropertyCompat("property") { @Override public float getValue(final Object object) { return controller.getCurrentInsets().bottom; } @Override public void setValue(final Object object, final float value) { if (insetsAnimationController == null) { return; } insetTo((int) value); } }; final float finalPosition = visible ? controller.getShownStateInsets().bottom : controller.getHiddenStateInsets().bottom; final SpringForce force = new SpringForce(finalPosition) // Tweak the damping value, to remove any bounciness. .setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY) // The stiffness value controls the strength of the spring animation, which // controls the speed. Medium (the default) is a good value, but feel free to // play around with this value. .setStiffness(SpringForce.STIFFNESS_MEDIUM); ViewUtils.springAnimationOf(this, property, finalPosition) .setSpring(force) .setStartVelocity(velocityY != null ? velocityY : 0) .addEndListener((animation, canceled, value, velocity) -> { if (animation == currentSpringAnimation) { currentSpringAnimation = null; } // Once the animation has ended, finish the controller finish(); }).start(); } private int coerceIn(final int v, final int min, final int max) { if (v >= min && v <= max) { return v; } if (v < min) { return min; } return max; } public void setAnimationControlListener(final WindowInsetsAnimationControlListenerCompat listener) { fwdListener = listener; } public interface OnRequestReadyListener { void onRequestReady(WindowInsetsAnimationControllerCompat windowInsetsAnimationControllerCompat); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/SwipeAndRestoreItemTouchHelperCallback.java ================================================ package awais.instagrabber.customviews.helpers; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.utils.Utils; /** * Thanks to https://github.com/izjumovfs/SwipeToReply/blob/master/swipetoreply/src/main/java/com/capybaralabs/swipetoreply/SwipeController.java */ public class SwipeAndRestoreItemTouchHelperCallback extends ItemTouchHelper.Callback { private static final String TAG = "SwipeRestoreCallback"; private final float swipeThreshold; private final float swipeAutoCancelThreshold; private final OnSwipeListener onSwipeListener; private final Drawable replyIcon; // private final Drawable replyIconBackground; private final int replyIconShowThreshold; private final float replyIconMaxTranslation; private final Rect replyIconBounds = new Rect(); private final float replyIconXOffset; private final int replyIconSize; private boolean mSwipeBack = false; private boolean hasVibrated; public SwipeAndRestoreItemTouchHelperCallback(final Context context, final OnSwipeListener onSwipeListener) { this.onSwipeListener = onSwipeListener; swipeThreshold = Utils.displayMetrics.widthPixels * 0.25f; swipeAutoCancelThreshold = swipeThreshold + Utils.convertDpToPx(5); replyIcon = AppCompatResources.getDrawable(context, R.drawable.ic_round_reply_24); if (replyIcon == null) { throw new IllegalArgumentException("reply icon is null"); } replyIcon.setTint(context.getResources().getColor(R.color.white)); //todo need to update according to theme replyIconShowThreshold = Utils.convertDpToPx(24); replyIconMaxTranslation = swipeThreshold - replyIconShowThreshold; // Log.d(TAG, "replyIconShowThreshold: " + replyIconShowThreshold + ", swipeThreshold: " + swipeThreshold); replyIconSize = replyIconShowThreshold; // Utils.convertDpToPx(24); replyIconXOffset = swipeThreshold * 0.25f /*Utils.convertDpToPx(20)*/; } @Override public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { if (!(viewHolder instanceof SwipeableViewHolder)) { return makeMovementFlags(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.ACTION_STATE_IDLE); } return makeMovementFlags(ItemTouchHelper.ACTION_STATE_IDLE, ((SwipeableViewHolder) viewHolder).getSwipeDirection()); } @Override public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder viewHolder1) { return false; } @Override public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int i) {} @Override public int convertToAbsoluteDirection(int flags, int layoutDirection) { if (mSwipeBack) { mSwipeBack = false; return 0; } return super.convertToAbsoluteDirection(flags, layoutDirection); } @Override public void onChildDraw(@NonNull Canvas c, @NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { setTouchListener(recyclerView, viewHolder); } super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); drawReplyButton(c, viewHolder); } @SuppressLint("ClickableViewAccessibility") private void setTouchListener(RecyclerView recyclerView, final RecyclerView.ViewHolder viewHolder) { recyclerView.setOnTouchListener((v, event) -> { if (event.getAction() == MotionEvent.ACTION_MOVE) { if (Math.abs(viewHolder.itemView.getTranslationX()) >= swipeAutoCancelThreshold) { if (!hasVibrated) { viewHolder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS, HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING); hasVibrated = true; } // MotionEvent cancelEvent = MotionEvent.obtain(event); // cancelEvent.setAction(MotionEvent.ACTION_CANCEL); // recyclerView.dispatchTouchEvent(cancelEvent); // cancelEvent.recycle(); } } mSwipeBack = event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP; if (mSwipeBack) { hasVibrated = false; if (Math.abs(viewHolder.itemView.getTranslationX()) >= swipeThreshold) { if (onSwipeListener != null) { onSwipeListener.onSwipe(viewHolder.getBindingAdapterPosition(), viewHolder); } } } return false; }); } public interface SwipeableViewHolder { int getSwipeDirection(); } public interface OnSwipeListener { void onSwipe(final int adapterPosition, final RecyclerView.ViewHolder viewHolder); } private void drawReplyButton(Canvas canvas, final RecyclerView.ViewHolder viewHolder) { if (!(viewHolder instanceof SwipeableViewHolder)) return; final int swipeDirection = ((SwipeableViewHolder) viewHolder).getSwipeDirection(); if (swipeDirection != ItemTouchHelper.START && swipeDirection != ItemTouchHelper.END) return; final View view = viewHolder.itemView; float translationX = view.getTranslationX(); boolean show = false; float progress; final float translationXAbs = Math.abs(translationX); if (translationXAbs >= replyIconShowThreshold) { show = true; } if (show) { // replyIconShowThreshold -> swipeThreshold <=> progress 0 -> 1 final float replyIconTranslation = translationXAbs - replyIconShowThreshold; progress = replyIconTranslation / replyIconMaxTranslation; if (progress > 1) { progress = 1f; } if (progress < 0) { progress = 0; } // Log.d(TAG, /*"translationX: " + translationX + ", replyIconTranslation: " + replyIconTranslation +*/ "progress: " + progress); } else { progress = 0f; // Log.d(TAG, /*"translationX: " + translationX + ", replyIconTranslation: " + 0 +*/ "progress: " + progress); } if (progress > 0) { // calculate the reply icon y position, then offset top, bottom with icon size final int y = view.getTop() + (view.getMeasuredHeight() / 2); final int tempIconSize = (int) (replyIconSize * progress); final int tempIconSizeHalf = tempIconSize / 2; final int xOffset = (int) (replyIconXOffset * progress); final int left; if (swipeDirection == ItemTouchHelper.END) { // draw arrow of left side left = xOffset; } else { // draw arrow of right side left = view.getMeasuredWidth() - xOffset - tempIconSize; } final int right = tempIconSize + left; replyIconBounds.set(left, y - tempIconSizeHalf, right, y + tempIconSizeHalf); replyIcon.setBounds(replyIconBounds); replyIcon.draw(canvas); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/SwipeGestureListener.java ================================================ package awais.instagrabber.customviews.helpers; import android.util.Log; import android.view.GestureDetector; import android.view.MotionEvent; import awais.instagrabber.interfaces.SwipeEvent; public final class SwipeGestureListener extends GestureDetector.SimpleOnGestureListener { public static final int SWIPE_THRESHOLD = 200; public static final int SWIPE_VELOCITY_THRESHOLD = 200; private final SwipeEvent swipeEvent; public SwipeGestureListener(final SwipeEvent swipeEvent) { this.swipeEvent = swipeEvent; } @Override public boolean onFling(final MotionEvent e1, final MotionEvent e2, final float velocityX, final float velocityY) { try { final float diffY = e2.getY() - e1.getY(); final float diffX = e2.getX() - e1.getX(); final float diffXAbs = Math.abs(diffX); if (diffXAbs > Math.abs(diffY) && diffXAbs > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { if (diffX > 0) swipeEvent.onSwipe(true); else swipeEvent.onSwipe(false); return true; } } catch (final Exception e) { Log.e("AWAISKING_APP", "", e); } return false; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/TextWatcherAdapter.java ================================================ package awais.instagrabber.customviews.helpers; import android.text.Editable; import android.text.TextWatcher; public class TextWatcherAdapter implements TextWatcher { @Override public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {} @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) {} @Override public void afterTextChanged(final Editable s) {} } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/TranslateDeferringInsetsAnimationCallback.java ================================================ /* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package awais.instagrabber.customviews.helpers; import android.view.View; import androidx.annotation.NonNull; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsAnimationCompat; import androidx.core.view.WindowInsetsCompat; import java.util.List; /** * A [WindowInsetsAnimationCompat.Callback] which will translate/move the given view during any * inset animations of the given inset type. *

* This class works in tandem with [RootViewDeferringInsetsCallback] to support the deferring of * certain [WindowInsetsCompat.Type] values during a [WindowInsetsAnimationCompat], provided in * [deferredInsetTypes]. The values passed into this constructor should match those which * the [RootViewDeferringInsetsCallback] is created with. */ public class TranslateDeferringInsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { private final View view; private final int persistentInsetTypes; private final int deferredInsetTypes; private boolean shouldTranslate = true; private int kbHeight; public TranslateDeferringInsetsAnimationCallback(final View view, final int persistentInsetTypes, final int deferredInsetTypes) { this(view, persistentInsetTypes, deferredInsetTypes, DISPATCH_MODE_STOP); } /** * @param view the view to translate from it's start to end state * @param persistentInsetTypes the bitmask of any inset types which were handled as part of the * layout * @param deferredInsetTypes the bitmask of insets types which should be deferred until after * any [WindowInsetsAnimationCompat]s have ended * @param dispatchMode The dispatch mode for this callback. * See [WindowInsetsAnimationCompat.Callback.getDispatchMode]. */ public TranslateDeferringInsetsAnimationCallback(final View view, final int persistentInsetTypes, final int deferredInsetTypes, final int dispatchMode) { super(dispatchMode); if ((persistentInsetTypes & deferredInsetTypes) != 0) { throw new IllegalArgumentException("persistentInsetTypes and deferredInsetTypes can not contain " + "any of same WindowInsetsCompat.Type values"); } this.view = view; this.persistentInsetTypes = persistentInsetTypes; this.deferredInsetTypes = deferredInsetTypes; } @NonNull @Override public WindowInsetsCompat onProgress(@NonNull final WindowInsetsCompat insets, @NonNull final List runningAnimations) { // onProgress() is called when any of the running animations progress... // First we get the insets which are potentially deferred final Insets typesInset = insets.getInsets(deferredInsetTypes); // Then we get the persistent inset types which are applied as padding during layout final Insets otherInset = insets.getInsets(persistentInsetTypes); // Now that we subtract the two insets, to calculate the difference. We also coerce // the insets to be >= 0, to make sure we don't use negative insets. final Insets subtract = Insets.subtract(typesInset, otherInset); final Insets diff = Insets.max(subtract, Insets.NONE); // The resulting `diff` insets contain the values for us to apply as a translation // to the view view.setTranslationX(diff.left - diff.right); view.setTranslationY(shouldTranslate ? diff.top - diff.bottom : -kbHeight); return insets; } @Override public void onEnd(@NonNull final WindowInsetsAnimationCompat animation) { try { final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(view); if (kbHeight == 0) { if (rootWindowInsets == null) return; final Insets imeInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.ime()); final Insets navBarInsets = rootWindowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()); kbHeight = imeInsets.bottom - navBarInsets.bottom; } // Once the animation has ended, reset the translation values view.setTranslationX(0f); final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); float translationY = 0; if (!shouldTranslate) { translationY = -kbHeight; if (visible) { translationY = 0; } } view.setTranslationY(translationY); } finally { shouldTranslate = true; } } public void setShouldTranslate(final boolean shouldTranslate) { this.shouldTranslate = shouldTranslate; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/VerticalSpaceItemDecoration.java ================================================ package awais.instagrabber.customviews.helpers; import android.graphics.Rect; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; public class VerticalSpaceItemDecoration extends RecyclerView.ItemDecoration { private final int verticalSpaceHeight; public VerticalSpaceItemDecoration(int verticalSpaceHeight) { this.verticalSpaceHeight = verticalSpaceHeight; } @Override public void getItemOffsets(Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { outRect.bottom = verticalSpaceHeight; } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/helpers/VideoAwareRecyclerScroller.java ================================================ package awais.instagrabber.customviews.helpers; import android.graphics.Point; import android.graphics.Rect; import android.view.View; import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.exoplayer2.SimpleExoPlayer; import awais.instagrabber.R; import awais.instagrabber.adapters.viewholder.feed.FeedVideoViewHolder; import awais.instagrabber.repositories.responses.Media; import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_DRAGGING; import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE; public class VideoAwareRecyclerScroller extends RecyclerView.OnScrollListener { private static final String TAG = "VideoAwareRecScroll"; private static final int FLING_JUMP_LOW_THRESHOLD = 80; private static final int FLING_JUMP_HIGH_THRESHOLD = 120; private static final Object LOCK = new Object(); private LinearLayoutManager layoutManager; private boolean dragging; private boolean isLoadingPaused = false; private FeedVideoViewHolder currentlyPlayingViewHolder; @Override public void onScrollStateChanged(@NonNull final RecyclerView recyclerView, final int newState) { dragging = newState == SCROLL_STATE_DRAGGING; if (isLoadingPaused) { if (newState == SCROLL_STATE_DRAGGING || newState == SCROLL_STATE_IDLE) { // user is touchy or the scroll finished, show videos isLoadingPaused = false; } // settling means the user let the screen go, but it can still be flinging } } @Override public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { if (!dragging) { // TODO can be made better by a rolling average of last N calls to smooth out patterns like a,b,a int currentSpeed = Math.abs(dy); if (isLoadingPaused && currentSpeed < FLING_JUMP_LOW_THRESHOLD) { isLoadingPaused = false; } else if (!isLoadingPaused && FLING_JUMP_HIGH_THRESHOLD < currentSpeed) { isLoadingPaused = true; // stop playing video } } if (isLoadingPaused) return; if (layoutManager == null) { final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); if (layoutManager instanceof LinearLayoutManager) this.layoutManager = (LinearLayoutManager) layoutManager; } if (layoutManager == null) { return; } int firstVisibleItemPos = layoutManager.findFirstCompletelyVisibleItemPosition(); int lastVisibleItemPos = layoutManager.findLastCompletelyVisibleItemPosition(); if (firstVisibleItemPos == -1 && lastVisibleItemPos == -1) { firstVisibleItemPos = layoutManager.findFirstVisibleItemPosition(); lastVisibleItemPos = layoutManager.findLastVisibleItemPosition(); } synchronized (LOCK) { final FeedVideoViewHolder videoHolder = getFirstVideoHolder(recyclerView, firstVisibleItemPos, lastVisibleItemPos); if (videoHolder == null || videoHolder.getCurrentFeedModel() == null) { if (currentlyPlayingViewHolder != null) { // currentlyPlayingViewHolder.stopPlaying(); currentlyPlayingViewHolder = null; } return; } if (currentlyPlayingViewHolder != null && currentlyPlayingViewHolder.getCurrentFeedModel().getPk() .equals(videoHolder.getCurrentFeedModel().getPk())) { return; } if (currentlyPlayingViewHolder != null) { // currentlyPlayingViewHolder.stopPlaying(); } // videoHolder.startPlaying(); currentlyPlayingViewHolder = videoHolder; } // boolean processFirstItem = false, processLastItem = false; // View currView; // if (firstVisibleItemPos != -1) { // currView = layoutManager.findViewByPosition(firstVisibleItemPos); // if (currView != null && currView.getId() == R.id.videoHolder) { // firstItemView = currView; // // processFirstItem = true; // } // } // if (lastVisibleItemPos != -1) { // currView = layoutManager.findViewByPosition(lastVisibleItemPos); // if (currView != null && currView.getId() == R.id.videoHolder) { // lastItemView = currView; // // processLastItem = true; // } // } // if (firstItemView == null && lastItemView == null) { // return; // } // if (firstItemView != null) { // // Log.d(TAG, "view" + viewHolder); // } // if (lastItemView != null) { // final FeedVideoViewHolder viewHolder = (FeedVideoViewHolder) recyclerView.getChildViewHolder(lastItemView); // Log.d(TAG, "view" + viewHolder); // } // Log.d(TAG, firstItemView + " " + lastItemView); // final Rect visibleItemRect = new Rect(); // int firstVisibleItemHeight = 0, lastVisibleItemHeight = 0; // final boolean isFirstItemVideoHolder = firstItemView != null && firstItemView.getId() == R.id.videoHolder; // if (isFirstItemVideoHolder) { // firstItemView.getGlobalVisibleRect(visibleItemRect); // firstVisibleItemHeight = visibleItemRect.height(); // } // final boolean isLastItemVideoHolder = lastItemView != null && lastItemView.getId() == R.id.videoHolder; // if (isLastItemVideoHolder) { // lastItemView.getGlobalVisibleRect(visibleItemRect); // lastVisibleItemHeight = visibleItemRect.height(); // } // // if (processFirstItem && firstVisibleItemHeight > lastVisibleItemHeight) // videoPosShown = firstVisibleItemPos; // else if (processLastItem && lastVisibleItemHeight != 0) videoPosShown = lastVisibleItemPos; // // if (firstItemView != lastItemView) { // final int mox = lastVisibleItemHeight - firstVisibleItemHeight; // if (processLastItem && lastVisibleItemHeight > firstVisibleItemHeight) // videoPosShown = lastVisibleItemPos; // if ((processFirstItem || processLastItem) && mox >= 0) // videoPosShown = lastVisibleItemPos; // } // // if (lastChangedVideoPos != -1 && lastVideoPos != -1) { // currView = layoutManager.findViewByPosition(lastChangedVideoPos); // if (currView != null && currView.getId() == R.id.videoHolder && // lastStoppedVideoPos != lastChangedVideoPos && lastPlayedVideoPos != lastChangedVideoPos) { // lastStoppedVideoPos = lastChangedVideoPos; // stopVideo(lastChangedVideoPos, recyclerView, currView); // } // // currView = layoutManager.findViewByPosition(lastVideoPos); // if (currView != null && currView.getId() == R.id.videoHolder) { // final Rect rect = new Rect(); // currView.getGlobalVisibleRect(rect); // // final int holderTop = currView.getTop(); // final int holderHeight = currView.getBottom() - holderTop; // final int halfHeight = holderHeight / 2; // //halfHeight -= halfHeight / 5; // // if (rect.height() < halfHeight) { // if (lastStoppedVideoPos != lastVideoPos) { // lastStoppedVideoPos = lastVideoPos; // stopVideo(lastVideoPos, recyclerView, currView); // } // } else if (lastPlayedVideoPos != lastVideoPos) { // lastPlayedVideoPos = lastVideoPos; // playVideo(lastVideoPos, recyclerView, currView); // } // } // // if (lastChangedVideoPos != lastVideoPos) lastChangedVideoPos = lastVideoPos; // } // // if (lastVideoPos != -1 && lastVideoPos != videoPosShown) { // if (videoAttached) { // //if ((currView = layoutManager.findViewByPosition(lastVideoPos)) != null && currView.getId() == R.id.videoHolder) // releaseVideo(lastVideoPos, recyclerView, null); // videoAttached = false; // } // } // if (videoPosShown != -1) { // lastVideoPos = videoPosShown; // if (!videoAttached) { // if ((currView = layoutManager.findViewByPosition(videoPosShown)) != null && currView.getId() == R.id.videoHolder) // attachVideo(videoPosShown, recyclerView, currView); // videoAttached = true; // } // } } private FeedVideoViewHolder getFirstVideoHolder(final RecyclerView recyclerView, final int firstVisibleItemPos, final int lastVisibleItemPos) { final Rect visibleItemRect = new Rect(); final Point offset = new Point(); for (int pos = firstVisibleItemPos; pos <= lastVisibleItemPos; pos++) { final View view = layoutManager.findViewByPosition(pos); if (view != null && view.getId() == R.id.videoHolder) { final View viewSwitcher = view.findViewById(R.id.root); if (viewSwitcher == null) { continue; } final boolean result = viewSwitcher.getGlobalVisibleRect(visibleItemRect, offset); if (!result) continue; final FeedVideoViewHolder viewHolder = (FeedVideoViewHolder) recyclerView.getChildViewHolder(view); final Media currentFeedModel = viewHolder.getCurrentFeedModel(); visibleItemRect.offset(-offset.x, -offset.y); final int visibleHeight = visibleItemRect.height(); if (visibleHeight < currentFeedModel.getOriginalHeight()) { continue; } // Log.d(TAG, "post:" + currentFeedModel.getPostId() + ", visibleHeight: " + visibleHeight + ", post height: " + currentFeedModel.getImageHeight()); return viewHolder; } } return null; } public void startPlaying() { if (currentlyPlayingViewHolder == null) { return; } // currentlyPlayingViewHolder.startPlaying(); } public void stopPlaying() { if (currentlyPlayingViewHolder == null) { return; } // currentlyPlayingViewHolder.stopPlaying(); } // private synchronized void attachVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { // synchronized (LOCK) { // if (recyclerView != null) { // final RecyclerView.Adapter adapter = recyclerView.getAdapter(); // if (adapter instanceof FeedAdapter) { // final SimpleExoPlayer pagerPlayer = ((FeedAdapter) adapter).pagerPlayer; // if (pagerPlayer != null) pagerPlayer.setPlayWhenReady(false); // } // } // if (itemView == null) { // return; // } // final boolean shouldAutoplay = settingsHelper.getBoolean(Constants.AUTOPLAY_VIDEOS); // final FeedModel feedModel = feedModels.get(itemPos); // // loadVideo(itemPos, itemView, shouldAutoplay, feedModel); // } // } // // private void loadVideo(final int itemPos, final View itemView, final boolean shouldAutoplay, final FeedModel feedModel) { // final PlayerView playerView = itemView.findViewById(R.id.playerView); // if (playerView == null) { // return; // } // if (player != null) { // player.stop(true); // player.release(); // player = null; // } // // player = new SimpleExoPlayer.Builder(context) // .setUseLazyPreparation(!shouldAutoplay) // .build(); // player.setPlayWhenReady(shouldAutoplay); // // final View btnComments = itemView.findViewById(R.id.btnComments); // if (btnComments != null) { // if (feedModel.getCommentsCount() <= 0) btnComments.setEnabled(false); // else { // btnComments.setTag(feedModel); // btnComments.setEnabled(true); // btnComments.setOnClickListener(commentClickListener); // } // } // playerView.setPlayer(player); // btnMute = itemView.findViewById(R.id.btnMute); // float vol = settingsHelper.getBoolean(Constants.MUTED_VIDEOS) ? 0f : 1f; // if (vol == 0f && Utils.sessionVolumeFull) vol = 1f; // player.setVolume(vol); // // if (btnMute != null) { // btnMute.setVisibility(View.VISIBLE); // btnMute.setImageResource(vol == 0f ? R.drawable.vol : R.drawable.mute); // btnMute.setOnClickListener(muteClickListener); // } // final DataSource.Factory factory = cacheDataSourceFactory != null ? cacheDataSourceFactory : dataSourceFactory; // final ProgressiveMediaSource.Factory sourceFactory = new ProgressiveMediaSource.Factory(factory); // final ProgressiveMediaSource mediaSource = sourceFactory.createMediaSource(Uri.parse(feedModel.getDisplayUrl())); // // player.setRepeatMode(Player.REPEAT_MODE_ALL); // player.prepare(mediaSource); // player.setVolume(vol); // // playerView.setOnClickListener(v -> player.setPlayWhenReady(!player.getPlayWhenReady())); // // if (videoChangeCallback != null) videoChangeCallback.playerChanged(itemPos, player); // } // // private void releaseVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { // // Log.d("AWAISKING_APP", "release: " + itemPos); // // if (player != null) { // // player.stop(true); // // player.release(); // // } // // player = null; // } // // private void playVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { // // if (player != null) { // // final int playbackState = player.getPlaybackState(); // // if (!player.isPlaying() // // || playbackState == Player.STATE_READY || playbackState == Player.STATE_ENDED // // ) { // // player.setPlayWhenReady(true); // // } // // } // // if (player != null) { // // player.setPlayWhenReady(true); // // player.getPlaybackState(); // // } // } // // private void stopVideo(final int itemPos, final RecyclerView recyclerView, final View itemView) { // if (player != null) { // player.setPlayWhenReady(false); // player.getPlaybackState(); // } // } public interface VideoChangeCallback { void playerChanged(final int itemPos, final SimpleExoPlayer player); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/SoundParser.java ================================================ package awais.instagrabber.customviews.masoudss_waveform; import android.media.MediaCodec; import android.media.MediaExtractor; import android.media.MediaFormat; import android.nfc.FormatException; import android.os.Build; import androidx.annotation.NonNull; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.ShortBuffer; import java.util.ArrayList; import java.util.List; import java.util.Objects; final class SoundParser { private ProgressListener progressListener; int[] frameGains; ////////////////// private static String[] supportedExtensions = {"mp3", "wav", "3gpp", "3gp", "amr", "aac", "m4a", "ogg"}; private static ArrayList additionalExtensions = new ArrayList<>(); static void addCustomExtension(final String extension) { additionalExtensions.add(extension); } static void removeCustomExtension(final String extension) { additionalExtensions.remove(extension); } static void addCustomExtensions(final List extensions) { additionalExtensions.addAll(extensions); } static void removeCustomExtensions(final List extensions) { additionalExtensions.removeAll(extensions); } private static boolean isFilenameSupported(final String filename) { for (final String supportedExtension : supportedExtensions) if (filename.endsWith('.' + supportedExtension)) return true; for (final String additionalExtension : additionalExtensions) if (filename.endsWith('.' + additionalExtension)) return true; return false; } @NonNull public static SoundParser create(final String fileName, final boolean ignoreExtension) throws IOException, FormatException { if (!ignoreExtension && !isFilenameSupported(fileName)) throw new FormatException("Not supported file extension."); final File f = new File(fileName); if (!f.exists()) throw new FileNotFoundException(fileName); final SoundParser soundFile = new SoundParser(); soundFile.readFile(f); return soundFile; } public void setProgressListener(final ProgressListener progressListener) { this.progressListener = progressListener; } @SuppressWarnings("deprecation") private void readFile(@NonNull final File inputFile) throws IOException, FormatException { final MediaExtractor extractor = new MediaExtractor(); MediaFormat format = null; final int fileSizeBytes = (int) inputFile.length(); extractor.setDataSource(inputFile.getPath()); final int numTracks = extractor.getTrackCount(); int i = 0; while (i < numTracks) { format = extractor.getTrackFormat(i); if (Objects.requireNonNull(format.getString(MediaFormat.KEY_MIME)).startsWith("audio/")) { extractor.selectTrack(i); break; } i++; } if (i == numTracks) throw new FormatException("No audio track found in " + inputFile); assert format != null; final int channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT); final int sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE); final int expectedNumSamples = (int) (format.getLong(MediaFormat.KEY_DURATION) / 1000000f * sampleRate + 0.5f); final MediaCodec codec = MediaCodec.createDecoderByType(Objects.requireNonNull(format.getString(MediaFormat.KEY_MIME))); codec.configure(format, null, null, 0); codec.start(); final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); final ByteBuffer[] inputBuffers = codec.getInputBuffers(); boolean firstSampleData = true, doneReading = false; long presentationTime; int sampleSize, decodedSamplesSize = 0, totSizeRead = 0; byte[] decodedSamples = null; ByteBuffer mDecodedBytes = ByteBuffer.allocate(1 << 20); ByteBuffer[] outputBuffers = codec.getOutputBuffers(); while (true) { final int inputBufferIndex = codec.dequeueInputBuffer(100); if (!doneReading && inputBufferIndex >= 0) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) sampleSize = extractor.readSampleData(Objects.requireNonNull(codec.getInputBuffer(inputBufferIndex)), 0); else sampleSize = extractor.readSampleData(inputBuffers[inputBufferIndex], 0); if (firstSampleData && sampleSize == 2 && "audio/mp4a-latm".equals(format.getString(MediaFormat.KEY_MIME))) { extractor.advance(); totSizeRead += sampleSize; } else if (sampleSize < 0) { codec.queueInputBuffer(inputBufferIndex, 0, 0, -1, MediaCodec.BUFFER_FLAG_END_OF_STREAM); doneReading = true; } else { presentationTime = extractor.getSampleTime(); codec.queueInputBuffer(inputBufferIndex, 0, sampleSize, presentationTime, 0); extractor.advance(); totSizeRead += sampleSize; if (progressListener != null && !progressListener.reportProgress((double) totSizeRead / fileSizeBytes)) { // We are asked to stop reading the file. Returning immediately. // The SoundFile object is invalid and should NOT be used afterward! extractor.release(); codec.stop(); codec.release(); return; } } firstSampleData = false; } // Get decoded stream from the decoder output buffers. final int outputBufferIndex = codec.dequeueOutputBuffer(info, 100); if (outputBufferIndex >= 0 && info.size > 0) { if (decodedSamplesSize < info.size) { decodedSamplesSize = info.size; decodedSamples = new byte[decodedSamplesSize]; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { final ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferIndex); assert outputBuffer != null; outputBuffer.get(decodedSamples, 0, info.size); outputBuffer.clear(); } else { outputBuffers[outputBufferIndex].get(decodedSamples, 0, info.size); outputBuffers[outputBufferIndex].clear(); } // Check if buffer is big enough. Resize it if it's too small. if (mDecodedBytes.remaining() < info.size) { // Getting a rough estimate of the total size, allocate 20% more, and // make sure to allocate at least 5MB more than the initial size. final int position = mDecodedBytes.position(); int newSize = (int) (position * (1.0 * fileSizeBytes / totSizeRead) * 1.2); final int infoSize = info.size + 5 * (1 << 20); if (newSize - position < infoSize) newSize = position + infoSize; ByteBuffer newDecodedBytes = null; // Try to allocate memory. If we are OOM, try to run the garbage collector. int retry = 10; while (retry > 0) { try { newDecodedBytes = ByteBuffer.allocate(newSize); break; } catch (final OutOfMemoryError e) { retry--; } } if (retry == 0) break; mDecodedBytes.rewind(); assert newDecodedBytes != null; newDecodedBytes.put(mDecodedBytes); mDecodedBytes = newDecodedBytes; mDecodedBytes.position(position); } mDecodedBytes.put(decodedSamples, 0, info.size); codec.releaseOutputBuffer(outputBufferIndex, false); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) outputBuffers = codec.getOutputBuffers(); } if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 || mDecodedBytes.position() / (2 * channels) >= expectedNumSamples) break; } final int numSamples = mDecodedBytes.position() / (channels * 2); // One sample = 2 bytes. mDecodedBytes.rewind(); mDecodedBytes.order(ByteOrder.LITTLE_ENDIAN); final ShortBuffer mDecodedSamples = mDecodedBytes.asShortBuffer(); // final int avgBitrateKbps = (int) (fileSizeBytes * 8F * ((float) sampleRate / numSamples) / 1000F); extractor.release(); codec.stop(); codec.release(); final int samplesPerFrame = 1024; int numFrames = numSamples / samplesPerFrame; if (numSamples % samplesPerFrame != 0) numFrames++; frameGains = new int[numFrames]; // final int[] mFrameLens = new int[numFrames]; // final int[] mFrameOffsets = new int[numFrames]; // final int frameLens = (int) (1000F * avgBitrateKbps / 8F * ((float) samplesPerFrame / sampleRate)); int j, gain, value; i = 0; while (i < numFrames) { gain = -1; j = 0; while (j < samplesPerFrame) { value = 0; for (int k = 0; k < channels; ++k) if (mDecodedSamples.remaining() > 0) value += Math.abs(mDecodedSamples.get()); value /= channels; if (gain < value) gain = value; j++; } frameGains[i] = (int) Math.sqrt(gain); // mFrameLens[i] = frameLens; // mFrameOffsets[i] = (int) ((float) i * (1000F * avgBitrateKbps / 8F) * ((float) samplesPerFrame / sampleRate)); i++; } mDecodedSamples.rewind(); } private interface ProgressListener { boolean reportProgress(final double fractionComplete); } } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveFormProgressChangeListener.java ================================================ package awais.instagrabber.customviews.masoudss_waveform; public interface WaveFormProgressChangeListener { void onProgressChanged(final WaveformSeekBar waveformSeekBar, final int progress, final boolean fromUser); } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveGravity.java ================================================ package awais.instagrabber.customviews.masoudss_waveform; public enum WaveGravity { TOP, CENTER, BOTTOM, } ================================================ FILE: app/src/main/java/awais/instagrabber/customviews/masoudss_waveform/WaveformSeekBar.java ================================================ package awais.instagrabber.customviews.masoudss_waveform; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.BitmapShader; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.RectF; import android.graphics.Shader; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import awais.instagrabber.R; import awais.instagrabber.utils.CubicInterpolation; import awais.instagrabber.utils.Utils; public final class WaveformSeekBar extends View { private final int mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); private final Paint mWavePaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final RectF mWaveRect = new RectF(); private final Canvas mProgressCanvas = new Canvas(); private final WaveGravity waveGravity = WaveGravity.CENTER; private final int waveBackgroundColor; private final int waveProgressColor; private final float waveWidth = Utils.convertDpToPx(3); private final float waveMinHeight = Utils.convertDpToPx(4); private final float waveCornerRadius = Utils.convertDpToPx(2); private final float waveGap = Utils.convertDpToPx(1); // private int mCanvasWidth = 0; // private int mCanvasHeight = 0; private float mTouchDownX = 0F; private float[] sample; private int progress = 0; private WaveFormProgressChangeListener progressChangeListener; private int wavesCount; private CubicInterpolation interpolation; public WaveformSeekBar(final Context context) { this(context, null); } public WaveformSeekBar(final Context context, @Nullable final AttributeSet attrs) { this(context, attrs, 0); } public WaveformSeekBar(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); final TypedArray a = context.getTheme().obtainStyledAttributes( attrs, R.styleable.WaveformSeekBar, 0, 0); final int backgroundColor; final int progressColor; try { backgroundColor = a.getResourceId(R.styleable.WaveformSeekBar_waveformBackgroundColor, R.color.white); progressColor = a.getResourceId(R.styleable.WaveformSeekBar_waveformProgressColor, R.color.blue_800); } finally { a.recycle(); } this.waveBackgroundColor = context.getResources().getColor(backgroundColor); this.waveProgressColor = context.getResources().getColor(progressColor); } private float getSampleMax() { float max = -1f; if (sample != null) { for (final float v : sample) { if (v > max) max = v; } } return max; } @Override protected void onDraw(final Canvas canvas) { super.onDraw(canvas); if (sample == null || sample.length == 0) return; final int availableWidth = getAvailableWidth(); final int availableHeight = getAvailableHeight(); // final float step = availableWidth / (waveGap + waveWidth) / sample.size(); int i = 0; float lastWaveRight = (float) getPaddingLeft(); final float sampleMax = getSampleMax(); while (i < wavesCount) { final float t = lastWaveRight / availableWidth * sample.length; float waveHeight = availableHeight * (interpolation.interpolate(t) / sampleMax); if (waveHeight < waveMinHeight) waveHeight = waveMinHeight; final float top; if (waveGravity == WaveGravity.TOP) { top = (float) getPaddingTop(); } else if (waveGravity == WaveGravity.CENTER) { top = (float) getPaddingTop() + availableHeight / 2F - waveHeight / 2F; } else if (waveGravity == WaveGravity.BOTTOM) { top = getMeasuredHeight() - (float) getPaddingBottom() - waveHeight; } else { top = 0; } mWaveRect.set(lastWaveRight, top, lastWaveRight + waveWidth, top + waveHeight); if (mWaveRect.contains(availableWidth * progress / 100F, mWaveRect.centerY())) { int bitHeight = (int) mWaveRect.height(); if (bitHeight <= 0) bitHeight = (int) waveWidth; final Bitmap bitmap = Bitmap.createBitmap(availableWidth, bitHeight, Bitmap.Config.ARGB_8888); mProgressCanvas.setBitmap(bitmap); float fillWidth = availableWidth * progress / 100F; mWavePaint.setColor(waveProgressColor); mProgressCanvas.drawRect(0F, 0F, fillWidth, mWaveRect.bottom, mWavePaint); mWavePaint.setColor(waveBackgroundColor); mProgressCanvas.drawRect(fillWidth, 0F, (float) availableWidth, mWaveRect.bottom, mWavePaint); mWavePaint.setShader(new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)); } else { mWavePaint.setColor(mWaveRect.right <= availableWidth * progress / 100F ? waveProgressColor : waveBackgroundColor); mWavePaint.setShader(null); } canvas.drawRoundRect(mWaveRect, waveCornerRadius, waveCornerRadius, mWavePaint); lastWaveRight = mWaveRect.right + waveGap; if (lastWaveRight + waveWidth > availableWidth + getPaddingLeft()) { break; } i++; } } @Override public boolean onTouchEvent(final MotionEvent event) { if (!isEnabled()) return false; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: if (isParentScrolling()) mTouchDownX = event.getX(); else updateProgress(event); break; case MotionEvent.ACTION_MOVE: updateProgress(event); break; case MotionEvent.ACTION_UP: if (Math.abs(event.getX() - mTouchDownX) > mScaledTouchSlop) updateProgress(event); performClick(); break; } return true; } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (changed) { calculateWaveDimensions(); } } private void calculateWaveDimensions() { if (sample == null || sample.length == 0) return; final int availableWidth = getAvailableWidth(); wavesCount = (int) (availableWidth / (waveGap + waveWidth)); interpolation = new CubicInterpolation(sample); } // @Override // protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { // super.onSizeChanged(w, h, oldw, oldh); // mCanvasWidth = w; // mCanvasHeight = h; // } @Override public boolean performClick() { super.performClick(); return true; } private boolean isParentScrolling() { View parent = (View) getParent(); final View root = getRootView(); while (true) { if (parent.canScrollHorizontally(1) || parent.canScrollHorizontally(-1) || parent.canScrollVertically(1) || parent.canScrollVertically(-1)) return true; if (parent == root) return false; parent = (View) parent.getParent(); } } private void updateProgress(@NonNull final MotionEvent event) { progress = (int) (100 * event.getX() / getAvailableWidth()); invalidate(); if (progressChangeListener != null) progressChangeListener.onProgressChanged(this, Math.min(Math.max(0, progress), 100), true); } private int getAvailableWidth() { return getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); } private int getAvailableHeight() { return getMeasuredHeight() - getPaddingTop() - getPaddingBottom(); } public void setProgress(final int progress) { this.progress = progress; invalidate(); } public void setProgressChangeListener(final WaveFormProgressChangeListener progressChangeListener) { this.progressChangeListener = progressChangeListener; } public void setSample(final float[] sample) { if (sample == this.sample) return; this.sample = sample; calculateWaveDimensions(); invalidate(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/AppDatabase.kt ================================================ package awais.instagrabber.db import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase import android.util.Log import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import awais.instagrabber.db.dao.AccountDao import awais.instagrabber.db.dao.DMLastNotifiedDao import awais.instagrabber.db.dao.FavoriteDao import awais.instagrabber.db.dao.RecentSearchDao import awais.instagrabber.db.entities.Account import awais.instagrabber.db.entities.DMLastNotified import awais.instagrabber.db.entities.Favorite import awais.instagrabber.db.entities.RecentSearch import awais.instagrabber.utils.Utils import awais.instagrabber.utils.extensions.TAG import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.util.* @Database(entities = [Account::class, Favorite::class, DMLastNotified::class, RecentSearch::class], version = 6) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun favoriteDao(): FavoriteDao abstract fun dmLastNotifiedDao(): DMLastNotifiedDao abstract fun recentSearchDao(): RecentSearchDao companion object { private lateinit var INSTANCE: AppDatabase fun getDatabase(context: Context): AppDatabase { if (!this::INSTANCE.isInitialized) { synchronized(AppDatabase::class.java) { if (!this::INSTANCE.isInitialized) { INSTANCE = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "cookiebox.db") .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .build() } } } return INSTANCE } private val MIGRATION_1_2: Migration = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE cookies ADD " + Account.COL_FULL_NAME + " TEXT") db.execSQL("ALTER TABLE cookies ADD " + Account.COL_PROFILE_PIC + " TEXT") } } private val MIGRATION_2_3: Migration = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { val oldFavorites = backupOldFavorites(db) // recreate with new columns (as there will be no doubt about the `query_display` column being present or not in the future versions) db.execSQL("DROP TABLE " + Favorite.TABLE_NAME) db.execSQL("CREATE TABLE " + Favorite.TABLE_NAME + " (" + Favorite.COL_ID + " INTEGER PRIMARY KEY," + Favorite.COL_QUERY + " TEXT," + Favorite.COL_TYPE + " TEXT," + Favorite.COL_DISPLAY_NAME + " TEXT," + Favorite.COL_PIC_URL + " TEXT," + Favorite.COL_DATE_ADDED + " INTEGER)") // add the old favorites back for (oldFavorite in oldFavorites) { insertOrUpdateFavorite(db, oldFavorite) } } } private val MIGRATION_3_4: Migration = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { // Required when migrating to Room. // The original table primary keys were not 'NOT NULL', so the migration to Room were failing without the below migration. // Taking this opportunity to rename cookies table to accounts // Create new table with name 'accounts' db.execSQL("CREATE TABLE " + Account.TABLE_NAME + " (" + Account.COL_ID + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + Account.COL_UID + " TEXT," + Account.COL_USERNAME + " TEXT," + Account.COL_COOKIE + " TEXT," + Account.COL_FULL_NAME + " TEXT," + Account.COL_PROFILE_PIC + " TEXT)") // Insert all data from table 'cookies' to 'accounts' db.execSQL("INSERT INTO " + Account.TABLE_NAME + " (" + Account.COL_UID + "," + Account.COL_USERNAME + "," + Account.COL_COOKIE + "," + Account.COL_FULL_NAME + "," + Account.COL_PROFILE_PIC + ") " + "SELECT " + Account.COL_UID + "," + Account.COL_USERNAME + "," + Account.COL_COOKIE + "," + Account.COL_FULL_NAME + "," + Account.COL_PROFILE_PIC + " FROM cookies") // Drop old cookies table db.execSQL("DROP TABLE cookies") // Create favorite backup table db.execSQL("CREATE TABLE " + Favorite.TABLE_NAME + "_backup (" + Favorite.COL_ID + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + Favorite.COL_QUERY + " TEXT," + Favorite.COL_TYPE + " TEXT," + Favorite.COL_DISPLAY_NAME + " TEXT," + Favorite.COL_PIC_URL + " TEXT," + Favorite.COL_DATE_ADDED + " INTEGER)") // Insert all data from table 'favorite' to 'favorite_backup' db.execSQL("INSERT INTO " + Favorite.TABLE_NAME + "_backup (" + Favorite.COL_QUERY + "," + Favorite.COL_TYPE + "," + Favorite.COL_DISPLAY_NAME + "," + Favorite.COL_PIC_URL + "," + Favorite.COL_DATE_ADDED + ") " + "SELECT " + Favorite.COL_QUERY + "," + Favorite.COL_TYPE + "," + Favorite.COL_DISPLAY_NAME + "," + Favorite.COL_PIC_URL + "," + Favorite.COL_DATE_ADDED + " FROM " + Favorite.TABLE_NAME) // Drop favorites db.execSQL("DROP TABLE " + Favorite.TABLE_NAME) // Rename favorite_backup to favorites db.execSQL("ALTER TABLE " + Favorite.TABLE_NAME + "_backup RENAME TO " + Favorite.TABLE_NAME) } } @JvmField val MIGRATION_4_5: Migration = object : Migration(4, 5) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("CREATE TABLE IF NOT EXISTS `dm_last_notified` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`thread_id` TEXT, " + "`last_notified_msg_ts` INTEGER, " + "`last_notified_at` INTEGER)") database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_dm_last_notified_thread_id` ON `dm_last_notified` (`thread_id`)") } } @JvmField val MIGRATION_5_6: Migration = object : Migration(5, 6) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("CREATE TABLE IF NOT EXISTS `recent_searches` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`ig_id` TEXT NOT NULL, " + "`name` TEXT NOT NULL, " + "`username` TEXT, " + "`pic_url` TEXT, " + "`type` TEXT NOT NULL, " + "`last_searched_on` INTEGER NOT NULL)") database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_recent_searches_ig_id_type` ON `recent_searches` (`ig_id`, `type`)") } } private fun backupOldFavorites(db: SupportSQLiteDatabase): List { // check if old favorites table had the column query_display val queryDisplayExists = checkColumnExists(db, Favorite.TABLE_NAME, "query_display") Log.d(TAG, "backupOldFavorites: queryDisplayExists: $queryDisplayExists") val oldModels: MutableList = ArrayList() val sql = ("SELECT " + "query_text," + "date_added" + (if (queryDisplayExists) ",query_display" else "") + " FROM " + Favorite.TABLE_NAME) try { db.query(sql).use { cursor -> if (cursor != null && cursor.moveToFirst()) { do { try { val queryText = cursor.getString(cursor.getColumnIndex("query_text")) val favoriteTypeQueryPair = Utils.migrateOldFavQuery(queryText) ?: continue val type = favoriteTypeQueryPair.first val query = favoriteTypeQueryPair.second val epochMillis = cursor.getLong(cursor.getColumnIndex("date_added")) val localDateTime = LocalDateTime.ofInstant( Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault() ) oldModels.add(Favorite( 0, query, type, if (queryDisplayExists) cursor.getString(cursor.getColumnIndex("query_display")) else null, null, localDateTime )) } catch (e: Exception) { Log.e(TAG, "onUpgrade", e) } } while (cursor.moveToNext()) } } } catch (e: Exception) { Log.e(TAG, "onUpgrade", e) } Log.d(TAG, "backupOldFavorites: oldModels:$oldModels") return oldModels } @Synchronized private fun insertOrUpdateFavorite(db: SupportSQLiteDatabase, model: Favorite) { val values = ContentValues() values.put(Favorite.COL_QUERY, model.query) values.put(Favorite.COL_TYPE, model.type.toString()) values.put(Favorite.COL_DISPLAY_NAME, model.displayName) values.put(Favorite.COL_PIC_URL, model.picUrl) values.put(Favorite.COL_DATE_ADDED, model.dateAdded!!.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()) val rows: Int = if (model.id >= 1) { db.update(Favorite.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values, Favorite.COL_ID + "=?", arrayOf(model.id.toString())) } else { db.update(Favorite.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values, Favorite.COL_QUERY + "=?" + " AND " + Favorite.COL_TYPE + "=?", arrayOf(model.query, model.type.toString())) } if (rows != 1) { db.insert(Favorite.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values) } } @Suppress("SameParameterValue") private fun checkColumnExists( db: SupportSQLiteDatabase, tableName: String, columnName: String, ): Boolean { var exists = false try { db.query("PRAGMA table_info($tableName)").use { cursor -> if (cursor.moveToFirst()) { do { val currentColumn = cursor.getString(cursor.getColumnIndex("name")) if (currentColumn == columnName) { exists = true } } while (cursor.moveToNext()) } } } catch (ex: Exception) { Log.e(TAG, "checkColumnExists", ex) } return exists } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/Converters.kt ================================================ package awais.instagrabber.db import androidx.room.TypeConverter import awais.instagrabber.models.enums.FavoriteType import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.ZoneOffset class Converters { @TypeConverter fun fromFavoriteTypeString(value: String?): FavoriteType? = if (value == null) null else try { FavoriteType.valueOf(value) } catch (e: Exception) { null } @TypeConverter fun favoriteTypeToString(favoriteType: FavoriteType?): String? = favoriteType?.toString() @TypeConverter fun fromTimestampToLocalDateTime(value: Long?): LocalDateTime? = if (value == null) null else LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.systemDefault()) @TypeConverter fun localDateTimeToTimestamp(localDateTime: LocalDateTime?): Long? = localDateTime?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() } ================================================ FILE: app/src/main/java/awais/instagrabber/db/dao/AccountDao.kt ================================================ package awais.instagrabber.db.dao import androidx.room.* import awais.instagrabber.db.entities.Account @Dao interface AccountDao { @Query("SELECT * FROM accounts") suspend fun getAllAccounts(): List @Query("SELECT * FROM accounts WHERE uid = :uid") suspend fun findAccountByUid(uid: String): Account? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAccounts(vararg accounts: Account) @Update suspend fun updateAccounts(vararg accounts: Account) @Delete suspend fun deleteAccounts(vararg accounts: Account) @Query("DELETE from accounts") suspend fun deleteAllAccounts() } ================================================ FILE: app/src/main/java/awais/instagrabber/db/dao/DMLastNotifiedDao.kt ================================================ package awais.instagrabber.db.dao import androidx.room.* import awais.instagrabber.db.entities.DMLastNotified @Dao interface DMLastNotifiedDao { @Query("SELECT * FROM dm_last_notified") suspend fun getAllDMDmLastNotified(): List @Query("SELECT * FROM dm_last_notified WHERE thread_id = :threadId") suspend fun findDMLastNotifiedByThreadId(threadId: String): DMLastNotified? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertDMLastNotified(vararg dmLastNotified: DMLastNotified) @Update suspend fun updateDMLastNotified(vararg dmLastNotified: DMLastNotified) @Delete suspend fun deleteDMLastNotified(vararg dmLastNotified: DMLastNotified) @Query("DELETE from dm_last_notified") suspend fun deleteAllDMLastNotified() } ================================================ FILE: app/src/main/java/awais/instagrabber/db/dao/FavoriteDao.kt ================================================ package awais.instagrabber.db.dao import androidx.room.* import awais.instagrabber.db.entities.Favorite import awais.instagrabber.models.enums.FavoriteType @Dao interface FavoriteDao { @Query("SELECT * FROM favorites") suspend fun getAllFavorites(): List @Query("SELECT * FROM favorites WHERE query_text = :query and type = :type") suspend fun findFavoriteByQueryAndType(query: String, type: FavoriteType): Favorite? @Insert suspend fun insertFavorites(vararg favorites: Favorite) @Update suspend fun updateFavorites(vararg favorites: Favorite) @Delete suspend fun deleteFavorites(vararg favorites: Favorite) @Query("DELETE from favorites") suspend fun deleteAllFavorites() } ================================================ FILE: app/src/main/java/awais/instagrabber/db/dao/RecentSearchDao.kt ================================================ package awais.instagrabber.db.dao import androidx.room.* import awais.instagrabber.db.entities.RecentSearch import awais.instagrabber.models.enums.FavoriteType @Dao interface RecentSearchDao { @Query("SELECT * FROM recent_searches ORDER BY last_searched_on DESC") suspend fun getAllRecentSearches(): List @Query("SELECT * FROM recent_searches WHERE `ig_id` = :igId AND `type` = :type") suspend fun getRecentSearchByIgIdAndType(igId: String, type: FavoriteType): RecentSearch? @Query("SELECT * FROM recent_searches WHERE instr(`name`, :query) > 0") suspend fun findRecentSearchesWithNameContaining(query: String): List @Insert suspend fun insertRecentSearch(recentSearch: RecentSearch) @Update suspend fun updateRecentSearch(recentSearch: RecentSearch) @Delete suspend fun deleteRecentSearch(recentSearch: RecentSearch) // @Query("DELETE from recent_searches") // void deleteAllRecentSearches(); } ================================================ FILE: app/src/main/java/awais/instagrabber/db/datasources/AccountDataSource.kt ================================================ package awais.instagrabber.db.datasources import android.content.Context import awais.instagrabber.db.AppDatabase import awais.instagrabber.db.dao.AccountDao import awais.instagrabber.db.entities.Account class AccountDataSource(private val accountDao: AccountDao) { suspend fun getAccount(uid: String): Account? = accountDao.findAccountByUid(uid) suspend fun getAllAccounts(): List = accountDao.getAllAccounts() suspend fun insertOrUpdateAccount( uid: String?, username: String?, cookie: String?, fullName: String?, profilePicUrl: String?, ) { val account = uid?.let { getAccount(it) } val toUpdate = Account(account?.id ?: 0, uid, username, cookie, fullName, profilePicUrl) if (account != null) { accountDao.updateAccounts(toUpdate) return } accountDao.insertAccounts(toUpdate) } suspend fun deleteAccount(account: Account) = accountDao.deleteAccounts(account) suspend fun deleteAllAccounts() = accountDao.deleteAllAccounts() companion object { @Volatile private var INSTANCE: AccountDataSource? = null fun getInstance(context: Context): AccountDataSource { return INSTANCE ?: synchronized(this) { val dao: AccountDao = AppDatabase.getDatabase(context).accountDao() AccountDataSource(dao).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/datasources/DMLastNotifiedDataSource.kt ================================================ package awais.instagrabber.db.datasources import android.content.Context import awais.instagrabber.db.AppDatabase import awais.instagrabber.db.dao.DMLastNotifiedDao import awais.instagrabber.db.entities.DMLastNotified import java.time.LocalDateTime class DMLastNotifiedDataSource private constructor(private val dmLastNotifiedDao: DMLastNotifiedDao) { suspend fun getDMLastNotified(threadId: String): DMLastNotified? = dmLastNotifiedDao.findDMLastNotifiedByThreadId(threadId) suspend fun getAllDMDmLastNotified(): List = dmLastNotifiedDao.getAllDMDmLastNotified() suspend fun insertOrUpdateDMLastNotified( threadId: String?, lastNotifiedMsgTs: LocalDateTime?, lastNotifiedAt: LocalDateTime?, ) { if (threadId == null) return val dmLastNotified = getDMLastNotified(threadId) val toUpdate = DMLastNotified( dmLastNotified?.id ?: 0, threadId, lastNotifiedMsgTs, lastNotifiedAt ) if (dmLastNotified != null) { dmLastNotifiedDao.updateDMLastNotified(toUpdate) return } dmLastNotifiedDao.insertDMLastNotified(toUpdate) } suspend fun deleteDMLastNotified(dmLastNotified: DMLastNotified) = dmLastNotifiedDao.deleteDMLastNotified(dmLastNotified) suspend fun deleteAllDMLastNotified() = dmLastNotifiedDao.deleteAllDMLastNotified() companion object { private lateinit var INSTANCE: DMLastNotifiedDataSource @JvmStatic fun getInstance(context: Context): DMLastNotifiedDataSource { if (!this::INSTANCE.isInitialized) { synchronized(DMLastNotifiedDataSource::class.java) { if (!this::INSTANCE.isInitialized) { val database = AppDatabase.getDatabase(context) INSTANCE = DMLastNotifiedDataSource(database.dmLastNotifiedDao()) } } } return INSTANCE } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/datasources/FavoriteDataSource.kt ================================================ package awais.instagrabber.db.datasources import android.content.Context import awais.instagrabber.db.AppDatabase import awais.instagrabber.db.dao.FavoriteDao import awais.instagrabber.db.entities.Favorite import awais.instagrabber.models.enums.FavoriteType class FavoriteDataSource(private val favoriteDao: FavoriteDao) { suspend fun getFavorite(query: String, type: FavoriteType): Favorite? = favoriteDao.findFavoriteByQueryAndType(query, type) suspend fun getAllFavorites(): List = favoriteDao.getAllFavorites() suspend fun insertOrUpdateFavorite(favorite: Favorite) { if (favorite.id != 0) { favoriteDao.updateFavorites(favorite) return } favoriteDao.insertFavorites(favorite) } suspend fun deleteFavorite(query: String?, type: FavoriteType?) { if (query == null || type == null) return val favorite = getFavorite(query, type) ?: return favoriteDao.deleteFavorites(favorite) } companion object { @Volatile private var INSTANCE: FavoriteDataSource? = null fun getInstance(context: Context): FavoriteDataSource { return INSTANCE ?: synchronized(this) { val dao: FavoriteDao = AppDatabase.getDatabase(context).favoriteDao() FavoriteDataSource(dao).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/datasources/RecentSearchDataSource.kt ================================================ package awais.instagrabber.db.datasources import android.content.Context import awais.instagrabber.db.AppDatabase import awais.instagrabber.db.dao.RecentSearchDao import awais.instagrabber.db.entities.RecentSearch import awais.instagrabber.models.enums.FavoriteType class RecentSearchDataSource private constructor(private val recentSearchDao: RecentSearchDao) { suspend fun getRecentSearchByIgIdAndType(igId: String, type: FavoriteType): RecentSearch? = recentSearchDao.getRecentSearchByIgIdAndType(igId, type) suspend fun getAllRecentSearches(): List = recentSearchDao.getAllRecentSearches() suspend fun insertOrUpdateRecentSearch(recentSearch: RecentSearch) { if (recentSearch.id != 0) { recentSearchDao.updateRecentSearch(recentSearch) return } recentSearchDao.insertRecentSearch(recentSearch) } suspend fun deleteRecentSearch(recentSearch: RecentSearch) = recentSearchDao.deleteRecentSearch(recentSearch) companion object { private lateinit var INSTANCE: RecentSearchDataSource @JvmStatic @Synchronized fun getInstance(context: Context): RecentSearchDataSource { if (!this::INSTANCE.isInitialized) { synchronized(RecentSearchDataSource::class.java) { if (!this::INSTANCE.isInitialized) { val database = AppDatabase.getDatabase(context) INSTANCE = RecentSearchDataSource(database.recentSearchDao()) } } } return INSTANCE } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/entities/Account.kt ================================================ package awais.instagrabber.db.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey @Entity(tableName = Account.TABLE_NAME) data class Account( @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, @ColumnInfo(name = COL_UID) val uid: String?, @ColumnInfo(name = COL_USERNAME) val username: String?, @ColumnInfo(name = COL_COOKIE) val cookie: String?, @ColumnInfo(name = COL_FULL_NAME) val fullName: String?, @ColumnInfo(name = COL_PROFILE_PIC) val profilePic: String?, ) { @Ignore var isSelected = false val isValid: Boolean get() = !uid.isNullOrBlank() && !username.isNullOrBlank() && !cookie.isNullOrBlank() companion object { const val TABLE_NAME = "accounts" const val COL_ID = "id" const val COL_USERNAME = "username" const val COL_COOKIE = "cookie" const val COL_UID = "uid" const val COL_FULL_NAME = "full_name" const val COL_PROFILE_PIC = "profile_pic" } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/entities/DMLastNotified.kt ================================================ package awais.instagrabber.db.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import java.time.LocalDateTime @Entity(tableName = DMLastNotified.TABLE_NAME, indices = [Index(value = [DMLastNotified.COL_THREAD_ID], unique = true)]) data class DMLastNotified( @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, @ColumnInfo(name = COL_THREAD_ID) val threadId: String?, @ColumnInfo(name = COL_LAST_NOTIFIED_MSG_TS) val lastNotifiedMsgTs: LocalDateTime?, @ColumnInfo(name = COL_LAST_NOTIFIED_AT) val lastNotifiedAt: LocalDateTime?, ) { companion object { const val TABLE_NAME = "dm_last_notified" const val COL_ID = "id" const val COL_THREAD_ID = "thread_id" const val COL_LAST_NOTIFIED_MSG_TS = "last_notified_msg_ts" const val COL_LAST_NOTIFIED_AT = "last_notified_at" } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/entities/Favorite.kt ================================================ package awais.instagrabber.db.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import awais.instagrabber.models.enums.FavoriteType import java.time.LocalDateTime @Entity(tableName = Favorite.TABLE_NAME) data class Favorite( @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, @ColumnInfo(name = COL_QUERY) val query: String?, @ColumnInfo(name = COL_TYPE) val type: FavoriteType?, @ColumnInfo(name = COL_DISPLAY_NAME) val displayName: String?, @ColumnInfo(name = COL_PIC_URL) val picUrl: String?, @ColumnInfo(name = COL_DATE_ADDED) val dateAdded: LocalDateTime?, ) { companion object { const val TABLE_NAME = "favorites" const val COL_ID = "id" const val COL_QUERY = "query_text" const val COL_TYPE = "type" const val COL_DISPLAY_NAME = "display_name" const val COL_PIC_URL = "pic_url" const val COL_DATE_ADDED = "date_added" } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/entities/RecentSearch.kt ================================================ package awais.instagrabber.db.entities import android.util.Log import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.repositories.responses.search.SearchItem import awais.instagrabber.utils.extensions.TAG import java.time.LocalDateTime @Entity(tableName = RecentSearch.TABLE_NAME, indices = [Index(value = [RecentSearch.COL_IG_ID, RecentSearch.COL_TYPE], unique = true)]) data class RecentSearch( @ColumnInfo(name = COL_ID) @PrimaryKey(autoGenerate = true) val id: Int, @ColumnInfo(name = COL_IG_ID) val igId: String, @ColumnInfo(name = COL_NAME) val name: String, @ColumnInfo(name = COL_USERNAME) val username: String?, @ColumnInfo(name = COL_PIC_URL) val picUrl: String?, @ColumnInfo(name = COL_TYPE) val type: FavoriteType, @ColumnInfo(name = COL_LAST_SEARCHED_ON) val lastSearchedOn: LocalDateTime, ) { companion object { const val TABLE_NAME = "recent_searches" private const val COL_ID = "id" const val COL_IG_ID = "ig_id" private const val COL_NAME = "name" private const val COL_USERNAME = "username" private const val COL_PIC_URL = "pic_url" const val COL_TYPE = "type" private const val COL_LAST_SEARCHED_ON = "last_searched_on" @JvmStatic fun fromSearchItem(searchItem: SearchItem): RecentSearch? { val type = searchItem.type ?: return null try { val igId: String val name: String val username: String? val picUrl: String? when (type) { FavoriteType.USER -> { igId = searchItem.user.pk.toString() name = searchItem.user.fullName ?: "" username = searchItem.user.username picUrl = searchItem.user.profilePicUrl } FavoriteType.HASHTAG -> { igId = searchItem.hashtag.id name = searchItem.hashtag.name username = null picUrl = null } FavoriteType.LOCATION -> { igId = searchItem.place.location.pk.toString() name = searchItem.place.title username = null picUrl = null } else -> return null } return RecentSearch(id = 0, igId, name, username, picUrl, type, LocalDateTime.now()) } catch (e: Exception) { Log.e(TAG, "fromSearchItem: ", e) } return null } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/repositories/AccountRepository.kt ================================================ package awais.instagrabber.db.repositories import android.content.Context import awais.instagrabber.db.datasources.AccountDataSource import awais.instagrabber.db.entities.Account class AccountRepository(private val accountDataSource: AccountDataSource) { suspend fun getAccount(uid: Long): Account? = accountDataSource.getAccount(uid.toString()) suspend fun getAllAccounts(): List = accountDataSource.getAllAccounts() suspend fun insertOrUpdateAccounts(accounts: List) { for (account in accounts) { accountDataSource.insertOrUpdateAccount( account.uid, account.username, account.cookie, account.fullName, account.profilePic ) } } suspend fun insertOrUpdateAccount( uid: Long, username: String, cookie: String, fullName: String, profilePicUrl: String?, ): Account? { accountDataSource.insertOrUpdateAccount(uid.toString(), username, cookie, fullName, profilePicUrl) return accountDataSource.getAccount(uid.toString()) } suspend fun deleteAccount(account: Account) = accountDataSource.deleteAccount(account) suspend fun deleteAllAccounts() = accountDataSource.deleteAllAccounts() companion object { @Volatile private var INSTANCE: AccountRepository? = null fun getInstance(context: Context): AccountRepository { return INSTANCE ?: synchronized(this) { val dataSource: AccountDataSource = AccountDataSource.getInstance(context) AccountRepository(dataSource).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/repositories/DMLastNotifiedRepository.kt ================================================ package awais.instagrabber.db.repositories import awais.instagrabber.db.datasources.DMLastNotifiedDataSource import awais.instagrabber.db.entities.DMLastNotified import java.time.LocalDateTime class DMLastNotifiedRepository private constructor(private val dmLastNotifiedDataSource: DMLastNotifiedDataSource) { suspend fun getDMLastNotified(threadId: String): DMLastNotified? = dmLastNotifiedDataSource.getDMLastNotified(threadId) suspend fun getAllDMDmLastNotified(): List = dmLastNotifiedDataSource.getAllDMDmLastNotified() suspend fun insertOrUpdateDMLastNotified(dmLastNotifiedList: List) { for (dmLastNotified in dmLastNotifiedList) { dmLastNotifiedDataSource.insertOrUpdateDMLastNotified( dmLastNotified.threadId, dmLastNotified.lastNotifiedMsgTs, dmLastNotified.lastNotifiedAt ) } } suspend fun insertOrUpdateDMLastNotified( threadId: String, lastNotifiedMsgTs: LocalDateTime, lastNotifiedAt: LocalDateTime, ): DMLastNotified? { dmLastNotifiedDataSource.insertOrUpdateDMLastNotified(threadId, lastNotifiedMsgTs, lastNotifiedAt) return dmLastNotifiedDataSource.getDMLastNotified(threadId) } suspend fun deleteDMLastNotified(dmLastNotified: DMLastNotified) = dmLastNotifiedDataSource.deleteDMLastNotified(dmLastNotified) suspend fun deleteAllDMLastNotified() = dmLastNotifiedDataSource.deleteAllDMLastNotified() companion object { private lateinit var instance: DMLastNotifiedRepository @JvmStatic fun getInstance(dmLastNotifiedDataSource: DMLastNotifiedDataSource): DMLastNotifiedRepository { if (!this::instance.isInitialized) { instance = DMLastNotifiedRepository(dmLastNotifiedDataSource) } return instance } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/repositories/FavoriteRepository.kt ================================================ package awais.instagrabber.db.repositories import android.content.Context import awais.instagrabber.db.datasources.FavoriteDataSource import awais.instagrabber.db.entities.Favorite import awais.instagrabber.models.enums.FavoriteType class FavoriteRepository(private val favoriteDataSource: FavoriteDataSource) { suspend fun getFavorite(query: String, type: FavoriteType): Favorite? = favoriteDataSource.getFavorite(query, type) suspend fun getAllFavorites(): List = favoriteDataSource.getAllFavorites() suspend fun insertOrUpdateFavorite(favorite: Favorite) = favoriteDataSource.insertOrUpdateFavorite(favorite) suspend fun deleteFavorite(query: String?, type: FavoriteType?) = favoriteDataSource.deleteFavorite(query, type) companion object { @Volatile private var INSTANCE: FavoriteRepository? = null fun getInstance(context: Context): FavoriteRepository { return INSTANCE ?: synchronized(this) { val dataSource: FavoriteDataSource = FavoriteDataSource.getInstance(context) FavoriteRepository(dataSource).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/db/repositories/RecentSearchRepository.kt ================================================ package awais.instagrabber.db.repositories import awais.instagrabber.db.datasources.RecentSearchDataSource import awais.instagrabber.db.entities.RecentSearch import awais.instagrabber.models.enums.FavoriteType import java.time.LocalDateTime class RecentSearchRepository private constructor(private val recentSearchDataSource: RecentSearchDataSource) { suspend fun getRecentSearch(igId: String, type: FavoriteType): RecentSearch? = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type) suspend fun getAllRecentSearches(): List = recentSearchDataSource.getAllRecentSearches() suspend fun insertOrUpdateRecentSearch(recentSearch: RecentSearch) = insertOrUpdateRecentSearch(recentSearch.igId, recentSearch.name, recentSearch.username, recentSearch.picUrl, recentSearch.type) private suspend fun insertOrUpdateRecentSearch( igId: String, name: String, username: String?, picUrl: String?, type: FavoriteType, ) { var recentSearch = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type) recentSearch = RecentSearch(recentSearch?.id ?: 0, igId, name, username, picUrl, type, LocalDateTime.now()) recentSearchDataSource.insertOrUpdateRecentSearch(recentSearch) } suspend fun deleteRecentSearchByIgIdAndType(igId: String, type: FavoriteType) { val recentSearch = recentSearchDataSource.getRecentSearchByIgIdAndType(igId, type) if (recentSearch != null) { recentSearchDataSource.deleteRecentSearch(recentSearch) } } suspend fun deleteRecentSearch(recentSearch: RecentSearch) = recentSearchDataSource.deleteRecentSearch(recentSearch) companion object { private lateinit var instance: RecentSearchRepository @JvmStatic fun getInstance(recentSearchDataSource: RecentSearchDataSource): RecentSearchRepository { if (!this::instance.isInitialized) { instance = RecentSearchRepository(recentSearchDataSource) } return instance } } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/AccountSwitcherDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Window; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import java.util.ArrayList; import java.util.Collections; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.AccountSwitcherAdapter; import awais.instagrabber.databinding.DialogAccountSwitcherBinding; import awais.instagrabber.db.entities.Account; import awais.instagrabber.db.repositories.AccountRepository; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.ProcessPhoenix; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class AccountSwitcherDialogFragment extends DialogFragment { private static final String TAG = AccountSwitcherDialogFragment.class.getSimpleName(); private AccountRepository accountRepository; private OnAddAccountClickListener onAddAccountClickListener; private DialogAccountSwitcherBinding binding; public AccountSwitcherDialogFragment() {} public AccountSwitcherDialogFragment(final OnAddAccountClickListener onAddAccountClickListener) { this.onAddAccountClickListener = onAddAccountClickListener; } private final AccountSwitcherAdapter.OnAccountClickListener accountClickListener = (model, isCurrent) -> { if (isCurrent) { dismiss(); return; } CookieUtils.setupCookies(model.getCookie()); settingsHelper.putString(Constants.COOKIE, model.getCookie()); // final FragmentActivity activity = getActivity(); // if (activity != null) activity.recreate(); // dismiss(); AppExecutors.INSTANCE.getMainThread().execute(() -> { final Context context = getContext(); if (context == null) return; ProcessPhoenix.triggerRebirth(context); }, 200); }; private final AccountSwitcherAdapter.OnAccountLongClickListener accountLongClickListener = (model, isCurrent) -> { final Context context = getContext(); if (context == null) return false; if (isCurrent) { new AlertDialog.Builder(context) .setMessage(R.string.quick_access_cannot_delete_curr) .setPositiveButton(R.string.ok, null) .show(); return true; } new AlertDialog.Builder(context) .setMessage(getString(R.string.quick_access_confirm_delete, model.getUsername())) .setPositiveButton(R.string.yes, (dialog, which) -> { if (accountRepository == null) return; accountRepository.deleteAccount( model, CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { dismiss(); if (throwable != null) { Log.e(TAG, "deleteAccount: ", throwable); } }), Dispatchers.getIO()) ); }) .setNegativeButton(R.string.cancel, null) .show(); dismiss(); return true; }; @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { binding = DialogAccountSwitcherBinding.inflate(inflater, container, false); binding.accounts.setLayoutManager(new LinearLayoutManager(getContext())); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); init(); } @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); accountRepository = AccountRepository.Companion.getInstance(context); } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final Window window = dialog.getWindow(); if (window == null) return; final int height = ViewGroup.LayoutParams.WRAP_CONTENT; final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); window.setLayout(width, height); } private void init() { final AccountSwitcherAdapter adapter = new AccountSwitcherAdapter(accountClickListener, accountLongClickListener); binding.accounts.setAdapter(adapter); if (accountRepository == null) return; accountRepository.getAllAccounts( CoroutineUtilsKt.getContinuation((accounts, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "init: ", throwable); return; } if (accounts == null) return; final String cookie = settingsHelper.getString(Constants.COOKIE); final List copy = new ArrayList<>(accounts); sortUserList(cookie, copy); adapter.submitList(copy); }), Dispatchers.getIO()) ); binding.addAccountBtn.setOnClickListener(v -> { if (onAddAccountClickListener == null) return; onAddAccountClickListener.onAddAccountClick(this); }); } /** * Sort the user list by following logic: *

    *
  1. Keep currently active account at top. *
  2. Check if any user does not have a full name. *
  3. If all have full names, sort by full names. *
  4. Otherwise, sort by the usernames *
* * @param cookie active cookie * @param allUsers list of users */ private void sortUserList(final String cookie, final List allUsers) { boolean sortByName = true; for (final Account user : allUsers) { if (TextUtils.isEmpty(user.getFullName())) { sortByName = false; break; } } final boolean finalSortByName = sortByName; Collections.sort(allUsers, (o1, o2) -> { // keep current account at top if (o1.getCookie().equals(cookie)) return -1; if (finalSortByName) { // sort by full name return o1.getFullName().compareTo(o2.getFullName()); } // otherwise sort by username return o1.getUsername().compareTo(o2.getUsername()); }); } public interface OnAddAccountClickListener { void onAddAccountClick(final AccountSwitcherDialogFragment dialogFragment); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/ConfirmDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.text.method.LinkMovementMethod; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import awais.instagrabber.R; public class ConfirmDialogFragment extends DialogFragment { private Context context; private ConfirmDialogFragmentCallback callback; private final int defaultPositiveButtonText = R.string.ok; // private final int defaultNegativeButtonText = R.string.cancel; @NonNull public static ConfirmDialogFragment newInstance(final int requestCode, @StringRes final int title, @NonNull final CharSequence message, @StringRes final int positiveText, @StringRes final int negativeText, @StringRes final int neutralText) { return newInstance(requestCode, title, 0, message, positiveText, negativeText, neutralText); } @NonNull public static ConfirmDialogFragment newInstance(final int requestCode, @StringRes final int title, @StringRes final int messageResId, @StringRes final int positiveText, @StringRes final int negativeText, @StringRes final int neutralText) { return newInstance(requestCode, title, messageResId, null, positiveText, negativeText, neutralText); } @NonNull private static ConfirmDialogFragment newInstance(final int requestCode, @StringRes final int title, @StringRes final int messageResId, @Nullable final CharSequence message, @StringRes final int positiveText, @StringRes final int negativeText, @StringRes final int neutralText) { Bundle args = new Bundle(); args.putInt("requestCode", requestCode); if (title != 0) { args.putInt("title", title); } if (messageResId != 0) { args.putInt("messageResId", messageResId); } else if (message != null) { args.putCharSequence("message", message); } if (positiveText != 0) { args.putInt("positive", positiveText); } if (negativeText != 0) { args.putInt("negative", negativeText); } if (neutralText != 0) { args.putInt("neutral", neutralText); } ConfirmDialogFragment fragment = new ConfirmDialogFragment(); fragment.setArguments(args); return fragment; } public ConfirmDialogFragment() {} @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); this.context = context; final Fragment parentFragment = getParentFragment(); if (parentFragment instanceof ConfirmDialogFragmentCallback) { callback = (ConfirmDialogFragmentCallback) parentFragment; return; } final FragmentActivity fragmentActivity = getActivity(); if (fragmentActivity instanceof ConfirmDialogFragmentCallback) { callback = (ConfirmDialogFragmentCallback) fragmentActivity; } } @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { final Bundle arguments = getArguments(); int title = 0; int messageResId = 0; CharSequence message = null; int neutralButtonText = 0; int negativeButtonText = 0; final int positiveButtonText; final int requestCode; if (arguments != null) { title = arguments.getInt("title", 0); messageResId = arguments.getInt("messageResId", 0); message = arguments.getCharSequence("message", null); positiveButtonText = arguments.getInt("positive", defaultPositiveButtonText); negativeButtonText = arguments.getInt("negative", 0); neutralButtonText = arguments.getInt("neutral", 0); requestCode = arguments.getInt("requestCode", 0); } else { requestCode = 0; positiveButtonText = defaultPositiveButtonText; } final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context) .setPositiveButton(positiveButtonText, (d, w) -> { if (callback == null) return; callback.onPositiveButtonClicked(requestCode); }); if (title != 0) { builder.setTitle(title); } if (messageResId != 0) { builder.setMessage(messageResId); } else if (message != null) { builder.setMessage(message); } if (negativeButtonText != 0) { builder.setNegativeButton(negativeButtonText, (dialog, which) -> { if (callback == null) return; callback.onNegativeButtonClicked(requestCode); }); } if (neutralButtonText != 0) { builder.setNeutralButton(neutralButtonText, (dialog, which) -> { if (callback == null) return; callback.onNeutralButtonClicked(requestCode); }); } return builder.create(); } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final TextView view = dialog.findViewById(android.R.id.message); view.setMovementMethod(LinkMovementMethod.getInstance()); } public interface ConfirmDialogFragmentCallback { void onPositiveButtonClicked(int requestCode); void onNegativeButtonClicked(int requestCode); void onNeutralButtonClicked(int requestCode); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/CreateBackupDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.provider.DocumentsContract; import android.text.Editable; import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.inputmethod.InputMethodManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import java.time.format.DateTimeFormatter; import java.time.LocalDateTime; import java.util.Locale; import awais.instagrabber.databinding.DialogCreateBackupBinding; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.ExportImportUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import static android.app.Activity.RESULT_OK; public class CreateBackupDialogFragment extends DialogFragment { private static final String TAG = CreateBackupDialogFragment.class.getSimpleName(); private static final int STORAGE_PERM_REQUEST_CODE = 8020; private static final DateTimeFormatter BACKUP_FILE_DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss", Locale.US); private static final int CREATE_FILE_REQUEST_CODE = 1; private final OnResultListener onResultListener; private DialogCreateBackupBinding binding; public CreateBackupDialogFragment(final OnResultListener onResultListener) { this.onResultListener = onResultListener; } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { binding = DialogCreateBackupBinding.inflate(inflater, container, false); return binding.getRoot(); } @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { Dialog dialog = super.onCreateDialog(savedInstanceState); dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); return dialog; } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final Window window = dialog.getWindow(); if (window == null) return; final int height = ViewGroup.LayoutParams.WRAP_CONTENT; final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); window.setLayout(width, height); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); init(); } private void init() { binding.etPassword.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {} @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { binding.btnSaveTo.setEnabled(!TextUtils.isEmpty(s)); } @Override public void afterTextChanged(final Editable s) {} }); final Context context = getContext(); if (context == null) { return; } binding.cbPassword.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked) { if (TextUtils.isEmpty(binding.etPassword.getText())) { binding.btnSaveTo.setEnabled(false); } binding.passwordField.setVisibility(View.VISIBLE); binding.etPassword.requestFocus(); final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; imm.showSoftInput(binding.etPassword, InputMethodManager.SHOW_IMPLICIT); return; } binding.btnSaveTo.setEnabled(true); binding.passwordField.setVisibility(View.GONE); final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; imm.hideSoftInputFromWindow(binding.etPassword.getWindowToken(), InputMethodManager.RESULT_UNCHANGED_SHOWN); }); binding.btnSaveTo.setOnClickListener(v -> { createFile(); }); } @Override public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { if (data == null || data.getData() == null) return; if (resultCode != RESULT_OK || requestCode != CREATE_FILE_REQUEST_CODE) return; final Context context = getContext(); if (context == null) return; final Editable passwordText = binding.etPassword.getText(); final String password = binding.cbPassword.isChecked() && passwordText != null && !TextUtils.isEmpty(passwordText.toString()) ? passwordText.toString().trim() : null; int flags = 0; if (binding.cbExportFavorites.isChecked()) { flags |= ExportImportUtils.FLAG_FAVORITES; } if (binding.cbExportSettings.isChecked()) { flags |= ExportImportUtils.FLAG_SETTINGS; } if (binding.cbExportLogins.isChecked()) { flags |= ExportImportUtils.FLAG_COOKIES; } ExportImportUtils.exportData(context, flags, data.getData(), password, result -> { if (onResultListener != null) { onResultListener.onResult(result); } dismiss(); }); } private void createFile() { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/octet-stream"); final String fileName = String.format("barinsta_%s.backup", LocalDateTime.now().format(BACKUP_FILE_DATE_TIME_FORMAT)); intent.putExtra(Intent.EXTRA_TITLE, fileName); // Optionally, specify a URI for the directory that should be opened in // the system file picker when your app creates the document. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, DownloadUtils.getBackupsDir().getUri()); } startActivityForResult(intent, CREATE_FILE_REQUEST_CODE); } public interface OnResultListener { void onResult(boolean result); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/DirectItemReactionDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectReactionsAdapter; import awais.instagrabber.adapters.DirectReactionsAdapter.OnReactionClickListener; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItemReactions; import awais.instagrabber.utils.TextUtils; public class DirectItemReactionDialogFragment extends BottomSheetDialogFragment { private static final String ARG_VIEWER_ID = "viewerId"; private static final String ARG_ITEM_ID = "itemId"; private static final String ARG_USERS = "users"; private static final String ARG_REACTIONS = "reactions"; private RecyclerView recyclerView; private OnReactionClickListener onReactionClickListener; public static DirectItemReactionDialogFragment newInstance(final long viewerId, @NonNull final ArrayList users, @NonNull final String itemId, @NonNull final DirectItemReactions reactions) { Bundle args = new Bundle(); args.putLong(ARG_VIEWER_ID, viewerId); args.putSerializable(ARG_USERS, users); args.putString(ARG_ITEM_ID, itemId); args.putSerializable(ARG_REACTIONS, reactions); DirectItemReactionDialogFragment fragment = new DirectItemReactionDialogFragment(); fragment.setArguments(args); return fragment; } public DirectItemReactionDialogFragment() {} @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setStyle(DialogFragment.STYLE_NORMAL, R.style.ThemeOverlay_Rounded_BottomSheetDialog); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { final Context context = getContext(); if (context == null) { return null; } recyclerView = new RecyclerView(context); return recyclerView; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { init(); } @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); try { onReactionClickListener = (OnReactionClickListener) getParentFragment(); } catch (ClassCastException e) { throw new ClassCastException("Calling fragment must implement DirectReactionsAdapter.OnReactionClickListener interface"); } } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); if (bottomSheetInternal == null) return; bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; bottomSheetInternal.requestLayout(); } private void init() { final Context context = getContext(); if (context == null) return; final Bundle arguments = getArguments(); if (arguments == null) return; final long viewerId = arguments.getLong(ARG_VIEWER_ID); final Serializable usersSerializable = arguments.getSerializable(ARG_USERS); if (!(usersSerializable instanceof ArrayList)) return; //noinspection unchecked final List users = (ArrayList) usersSerializable; final Serializable reactionsSerializable = arguments.getSerializable(ARG_REACTIONS); if (!(reactionsSerializable instanceof DirectItemReactions)) return; final DirectItemReactions reactions = (DirectItemReactions) reactionsSerializable; final String itemId = arguments.getString(ARG_ITEM_ID); if (TextUtils.isEmpty(itemId)) return; recyclerView.setLayoutManager(new LinearLayoutManager(context)); final DirectReactionsAdapter adapter = new DirectReactionsAdapter(viewerId, users, itemId, onReactionClickListener); recyclerView.setAdapter(adapter); adapter.submitList(reactions.getEmojis()); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/EditTextDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.view.ViewGroup; import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.widget.AppCompatEditText; import androidx.fragment.app.DialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import awais.instagrabber.R; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; public class EditTextDialogFragment extends DialogFragment { private final int margin; private final int topMargin; private Context context; private EditTextDialogFragmentCallback callback; public static EditTextDialogFragment newInstance(@StringRes final int title, @StringRes final int positiveText, @StringRes final int negativeText, @Nullable final String initialText) { Bundle args = new Bundle(); args.putInt("title", title); args.putInt("positive", positiveText); args.putInt("negative", negativeText); args.putString("initial", initialText); EditTextDialogFragment fragment = new EditTextDialogFragment(); fragment.setArguments(args); return fragment; } public EditTextDialogFragment() { margin = Utils.convertDpToPx(20); topMargin = Utils.convertDpToPx(8); } @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); try { callback = (EditTextDialogFragmentCallback) getParentFragment(); } catch (ClassCastException e) { throw new ClassCastException("Calling fragment must implement EditTextDialogFragmentCallback interface"); } this.context = context; } @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { final Bundle arguments = getArguments(); int title = -1; int positiveButtonText = R.string.ok; int negativeButtonText = R.string.cancel; String initialText = null; if (arguments != null) { title = arguments.getInt("title", -1); positiveButtonText = arguments.getInt("positive", R.string.ok); negativeButtonText = arguments.getInt("negative", R.string.cancel); initialText = arguments.getString("initial", null); } final AppCompatEditText input = new AppCompatEditText(context); if (!TextUtils.isEmpty(initialText)) { input.setText(initialText); } final FrameLayout container = new FrameLayout(context); final FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); layoutParams.leftMargin = margin; layoutParams.rightMargin = margin; layoutParams.topMargin = topMargin; input.setLayoutParams(layoutParams); container.addView(input); final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context) .setView(container) .setPositiveButton(positiveButtonText, (d, w) -> { final String string = input.getText() != null ? input.getText().toString() : ""; if (callback != null) { callback.onPositiveButtonClicked(string); } }) .setNegativeButton(negativeButtonText, (dialog, which) -> { if (callback != null) { callback.onNegativeButtonClicked(); } }); if (title > 0) { builder.setTitle(title); } return builder.create(); } public interface EditTextDialogFragmentCallback { void onPositiveButtonClicked(String text); void onNegativeButtonClicked(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/GifPickerBottomDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.text.Editable; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.snackbar.Snackbar; import awais.instagrabber.R; import awais.instagrabber.adapters.GifItemsAdapter; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.LayoutGifPickerBinding; import awais.instagrabber.repositories.responses.giphy.GiphyGif; import awais.instagrabber.utils.Debouncer; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.viewmodels.GifPickerViewModel; public class GifPickerBottomDialogFragment extends BottomSheetDialogFragment { private static final String TAG = GifPickerBottomDialogFragment.class.getSimpleName(); private static final int INPUT_DEBOUNCE_INTERVAL = 500; private static final String INPUT_KEY = "gif_search_input"; private LayoutGifPickerBinding binding; private GifPickerViewModel viewModel; private GifItemsAdapter gifItemsAdapter; private OnSelectListener onSelectListener; private Debouncer inputDebouncer; public static GifPickerBottomDialogFragment newInstance() { final Bundle args = new Bundle(); final GifPickerBottomDialogFragment fragment = new GifPickerBottomDialogFragment(); fragment.setArguments(args); return fragment; } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setStyle(DialogFragment.STYLE_NORMAL, R.style.ThemeOverlay_Rounded_BottomSheetDialog); final Debouncer.Callback callback = new Debouncer.Callback() { @Override public void call(final String key) { final Editable text = binding.input.getText(); if (TextUtils.isEmpty(text)) { viewModel.search(null); return; } viewModel.search(text.toString().trim()); } @Override public void onError(final Throwable t) { Log.e(TAG, "onError: ", t); } }; inputDebouncer = new Debouncer<>(callback, INPUT_DEBOUNCE_INTERVAL); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = LayoutGifPickerBinding.inflate(inflater, container, false); viewModel = new ViewModelProvider(this).get(GifPickerViewModel.class); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { init(); } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); if (bottomSheetInternal == null) return; bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; bottomSheetInternal.requestLayout(); } private void init() { setupList(); setupInput(); setupObservers(); } private void setupList() { final Context context = getContext(); if (context == null) return; binding.gifList.setLayoutManager(new GridLayoutManager(context, 3)); binding.gifList.setHasFixedSize(true); gifItemsAdapter = new GifItemsAdapter(entry -> { if (onSelectListener == null) return; onSelectListener.onSelect(entry); }); binding.gifList.setAdapter(gifItemsAdapter); } private void setupInput() { binding.input.addTextChangedListener(new TextWatcherAdapter() { @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { inputDebouncer.call(INPUT_KEY); } }); } private void setupObservers() { viewModel.getImages().observe(getViewLifecycleOwner(), imagesResource -> { if (imagesResource == null) return; switch (imagesResource.status) { case SUCCESS: gifItemsAdapter.submitList(imagesResource.data); break; case ERROR: final Context context = getContext(); if (context != null && imagesResource.message != null) { Snackbar.make(context, binding.getRoot(), imagesResource.message, Snackbar.LENGTH_LONG).show(); } if (context != null && imagesResource.resId != 0) { Snackbar.make(context, binding.getRoot(), getString(imagesResource.resId), Snackbar.LENGTH_LONG).show(); } break; case LOADING: break; } }); } public void setOnSelectListener(final OnSelectListener onSelectListener) { this.onSelectListener = onSelectListener; } public interface OnSelectListener { void onSelect(GiphyGif giphyGif); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/KeywordsFilterDialog.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.widget.EditText; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.HashSet; import awais.instagrabber.R; import awais.instagrabber.adapters.KeywordsFilterAdapter; import awais.instagrabber.databinding.DialogKeywordsFilterBinding; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.utils.SettingsHelper; import awais.instagrabber.utils.Utils; public final class KeywordsFilterDialog extends DialogFragment { @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final Window window = dialog.getWindow(); if (window == null) return; final int height = ViewGroup.LayoutParams.WRAP_CONTENT; final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); window.setLayout(width, height); } @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { final DialogKeywordsFilterBinding dialogKeywordsFilterBinding = DialogKeywordsFilterBinding.inflate(inflater, container, false); init(dialogKeywordsFilterBinding, getContext()); dialogKeywordsFilterBinding.btnOK.setOnClickListener(view -> this.dismiss()); return dialogKeywordsFilterBinding.getRoot(); } private void init(DialogKeywordsFilterBinding dialogKeywordsFilterBinding, Context context){ final LinearLayoutManager linearLayoutManager = new LinearLayoutManager(context); final RecyclerView recyclerView = dialogKeywordsFilterBinding.recyclerKeyword; recyclerView.setLayoutManager(linearLayoutManager); final SettingsHelper settingsHelper = new SettingsHelper(context); final ArrayList items = new ArrayList<>(settingsHelper.getStringSet(PreferenceKeys.KEYWORD_FILTERS)); final KeywordsFilterAdapter adapter = new KeywordsFilterAdapter(context, items); recyclerView.setAdapter(adapter); final EditText editText = dialogKeywordsFilterBinding.editText; dialogKeywordsFilterBinding.btnAdd.setOnClickListener(view ->{ final String s = editText.getText().toString(); if(s.isEmpty()) return; if(items.contains(s)) { editText.setText(""); return; } items.add(s.toLowerCase()); settingsHelper.putStringSet(PreferenceKeys.KEYWORD_FILTERS, new HashSet<>(items)); adapter.notifyItemInserted(items.size()); final String message = context.getString(R.string.added_keywords, s); Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); editText.setText(""); }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.util.SparseBooleanArray; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.primitives.Booleans; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; public class MultiOptionDialogFragment extends DialogFragment { private static final String TAG = MultiOptionDialogFragment.class.getSimpleName(); public enum Type { MULTIPLE, SINGLE_CHECKED, SINGLE } private Context context; private Type type; private MultiOptionDialogCallback callback; private MultiOptionDialogSingleCallback singleCallback; private List> options; @NonNull public static MultiOptionDialogFragment newInstance(final int requestCode, @StringRes final int title, @NonNull final ArrayList> options) { return newInstance(requestCode, title, 0, 0, options, Type.SINGLE); } @NonNull public static MultiOptionDialogFragment newInstance(final int requestCode, @StringRes final int title, @StringRes final int positiveButtonText, @StringRes final int negativeButtonText, @NonNull final ArrayList> options, @NonNull final Type type) { Bundle args = new Bundle(); args.putInt("requestCode", requestCode); args.putInt("title", title); args.putInt("positiveButtonText", positiveButtonText); args.putInt("negativeButtonText", negativeButtonText); args.putSerializable("options", options); args.putSerializable("type", type); MultiOptionDialogFragment fragment = new MultiOptionDialogFragment<>(); fragment.setArguments(args); return fragment; } @SuppressWarnings({"rawtypes", "unchecked"}) @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); this.context = context; final Fragment parentFragment = getParentFragment(); if (parentFragment != null) { if (parentFragment instanceof MultiOptionDialogCallback) { callback = (MultiOptionDialogCallback) parentFragment; } if (parentFragment instanceof MultiOptionDialogSingleCallback) { singleCallback = (MultiOptionDialogSingleCallback) parentFragment; } return; } final FragmentActivity fragmentActivity = getActivity(); if (fragmentActivity instanceof MultiOptionDialogCallback) { callback = (MultiOptionDialogCallback) fragmentActivity; } if (fragmentActivity instanceof MultiOptionDialogSingleCallback) { singleCallback = (MultiOptionDialogSingleCallback) fragmentActivity; } } @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { final Bundle arguments = getArguments(); int title = 0; int rc = 0; if (arguments != null) { rc = arguments.getInt("requestCode"); title = arguments.getInt("title"); type = (Type) arguments.getSerializable("type"); } final int requestCode = rc; final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context); if (title != 0) { builder.setTitle(title); } try { //noinspection unchecked options = arguments != null ? (List>) arguments.getSerializable("options") : Collections.emptyList(); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); options = Collections.emptyList(); } final int negativeButtonText = arguments != null ? arguments.getInt("negativeButtonText", -1) : -1; if (negativeButtonText > 0) { builder.setNegativeButton(negativeButtonText, (dialog, which) -> { if (callback != null) { callback.onCancel(requestCode); return; } if (singleCallback != null) { singleCallback.onCancel(requestCode); } }); } if (type == Type.MULTIPLE || type == Type.SINGLE_CHECKED) { final int positiveButtonText = arguments != null ? arguments.getInt("positiveButtonText", -1) : -1; if (positiveButtonText > 0) { builder.setPositiveButton(positiveButtonText, (dialog, which) -> { if (callback == null || options == null || options.isEmpty()) return; try { final List selected = new ArrayList<>(); final SparseBooleanArray checkedItemPositions = ((AlertDialog) dialog).getListView().getCheckedItemPositions(); for (int i = 0; i < checkedItemPositions.size(); i++) { final int position = checkedItemPositions.keyAt(i); final boolean checked = checkedItemPositions.get(position); if (!checked) continue; //noinspection unchecked final Option option = (Option) options.get(position); selected.add(option.value); } callback.onMultipleSelect(requestCode, selected); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } }); } } if (type == Type.MULTIPLE) { if (options != null && !options.isEmpty()) { final String[] items = options.stream() .map(option -> option.label) .toArray(String[]::new); final boolean[] checkedItems = Booleans.toArray(options.stream() .map(option -> option.checked) .collect(Collectors.toList())); builder.setMultiChoiceItems(items, checkedItems, (dialog, which, isChecked) -> { if (callback == null) return; try { final Option option = options.get(which); //noinspection unchecked callback.onCheckChange(requestCode, (T) option.value, isChecked); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } }); } } else { if (options != null && !options.isEmpty()) { final String[] items = options.stream() .map(option -> option.label) .toArray(String[]::new); if (type == Type.SINGLE_CHECKED) { int index = -1; for (int i = 0; i < options.size(); i++) { if (options.get(i).checked) { index = i; break; } } builder.setSingleChoiceItems(items, index, (dialog, which) -> { if (callback == null) return; try { final Option option = options.get(which); //noinspection unchecked callback.onCheckChange(requestCode, (T) option.value, true); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } }); } else if (type == Type.SINGLE) { builder.setItems(items, (dialog, which) -> { if (singleCallback == null) return; try { final Option option = options.get(which); //noinspection unchecked singleCallback.onSelect(requestCode, (T) option.value); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } }); } } } return builder.create(); } public void setCallback(final MultiOptionDialogCallback callback) { if (callback == null) return; this.callback = callback; } public void setSingleCallback(final MultiOptionDialogSingleCallback callback) { if (callback == null) return; this.singleCallback = callback; } public interface MultiOptionDialogCallback { void onSelect(int requestCode, T result); void onMultipleSelect(int requestCode, List result); void onCheckChange(int requestCode, T item, boolean isChecked); void onCancel(int requestCode); } public interface MultiOptionDialogSingleCallback { void onSelect(int requestCode, T result); void onCancel(int requestCode); } public static class Option { private final String label; private final T value; private final boolean checked; public Option(final String label, final T value) { this.label = label; this.value = value; this.checked = false; } public Option(final String label, final T value, final boolean checked) { this.label = label; this.value = value; this.checked = checked; } public String getLabel() { return label; } public T getValue() { return value; } public boolean isChecked() { return checked; } } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/PostLoadingDialogFragment.kt ================================================ package awais.instagrabber.dialogs import android.app.Dialog import android.content.Context import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import awais.instagrabber.R import awais.instagrabber.utils.* import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.webservices.GraphQLRepository import awais.instagrabber.webservices.MediaRepository import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.* class PostLoadingDialogFragment : DialogFragment() { private var isLoggedIn: Boolean = false private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } private val graphQLRepository: GraphQLRepository by lazy { GraphQLRepository.getInstance() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val cookie = Utils.settingsHelper.getString(Constants.COOKIE) var userId: Long = 0 var csrfToken: String? = null if (cookie.isNotBlank()) { userId = getUserIdFromCookie(cookie) csrfToken = getCsrfTokenFromCookie(cookie) } if (cookie.isBlank() || userId == 0L || csrfToken.isNullOrBlank()) { isLoggedIn = false return } isLoggedIn = true } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return MaterialAlertDialogBuilder(requireContext()) .setCancelable(false) .setView(R.layout.dialog_opening_post) .create() } override fun onAttach(context: Context) { super.onAttach(context) val arguments = PostLoadingDialogFragmentArgs.fromBundle(arguments ?: return) val shortCode = arguments.shortCode lifecycleScope.launch(Dispatchers.IO) { try { val media = if (isLoggedIn) mediaRepository.fetch(TextUtils.shortcodeToId(shortCode)) else graphQLRepository.fetchPost(shortCode) withContext(Dispatchers.Main) { if (media == null) { Toast.makeText(context, R.string.post_not_found, Toast.LENGTH_SHORT).show() return@withContext } try { findNavController().navigate(PostLoadingDialogFragmentDirections.actionToPost(media, 0)) } catch (e: Exception) { Log.e(TAG, "showPostView: ", e) } } } catch (e: Exception) { Log.e(TAG, "showPostView: ", e) } finally { withContext(Dispatchers.Main) { dismiss() } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/PostsLayoutPreferencesDialogFragment.kt ================================================ package awais.instagrabber.dialogs import android.app.Dialog import android.content.DialogInterface import android.os.Bundle import android.view.View import android.widget.CompoundButton import androidx.fragment.app.DialogFragment import awais.instagrabber.R import awais.instagrabber.databinding.DialogPostLayoutPreferencesBinding import awais.instagrabber.models.PostsLayoutPreferences import awais.instagrabber.models.PostsLayoutPreferences.PostsLayoutType import awais.instagrabber.models.PostsLayoutPreferences.ProfilePicSize import awais.instagrabber.utils.Utils import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder class PostsLayoutPreferencesDialogFragment( private val layoutPreferenceKey: String, private val onApplyListener: OnApplyListener ) : DialogFragment() { private lateinit var binding: DialogPostLayoutPreferencesBinding private val preferencesBuilder: PostsLayoutPreferences.Builder init { val preferences = PostsLayoutPreferences.fromJson(Utils.settingsHelper.getString(layoutPreferenceKey)) preferencesBuilder = PostsLayoutPreferences.builder().mergeFrom(preferences) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { binding = DialogPostLayoutPreferencesBinding.inflate(layoutInflater) init() return MaterialAlertDialogBuilder(requireContext()) .setView(binding.getRoot()) .setPositiveButton(R.string.apply) { _: DialogInterface?, _: Int -> val preferences = preferencesBuilder.build() val json = preferences.json Utils.settingsHelper.putString(layoutPreferenceKey, json) onApplyListener.onApply(preferences) } .create() } private fun init() { initLayoutToggle() if (preferencesBuilder.type != PostsLayoutType.LINEAR) { initStaggeredOrGridOptions() } } private fun initStaggeredOrGridOptions() { initColCountToggle() initNamesToggle() initAvatarsToggle() initCornersToggle() initGapToggle() } private fun initLayoutToggle() { val selectedLayoutId = selectedLayoutId binding.layoutToggle.check(selectedLayoutId) if (selectedLayoutId == R.id.layout_linear) { binding.staggeredOrGridOptions.visibility = View.GONE } binding.layoutToggle.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean -> if (!isChecked) return@addOnButtonCheckedListener when (checkedId) { R.id.layout_linear -> { preferencesBuilder.type = PostsLayoutType.LINEAR preferencesBuilder.colCount = 1 binding.staggeredOrGridOptions.visibility = View.GONE } R.id.layout_staggered -> { preferencesBuilder.type = PostsLayoutType.STAGGERED_GRID if (preferencesBuilder.colCount == 1) { preferencesBuilder.colCount = 2 } binding.staggeredOrGridOptions.visibility = View.VISIBLE initStaggeredOrGridOptions() } else -> { preferencesBuilder.type = PostsLayoutType.GRID if (preferencesBuilder.colCount == 1) { preferencesBuilder.colCount = 2 } binding.staggeredOrGridOptions.visibility = View.VISIBLE initStaggeredOrGridOptions() } } } } private fun initColCountToggle() { binding.colCountToggle.check(selectedColCountId) binding.colCountToggle.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean -> if (!isChecked) return@addOnButtonCheckedListener preferencesBuilder.colCount = (if (checkedId == R.id.col_count_two) 2 else 3) } } private fun initAvatarsToggle() { binding.showAvatarToggle.isChecked = preferencesBuilder.isAvatarVisible binding.avatarSizeToggle.check(selectedAvatarSizeId) binding.showAvatarToggle.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> preferencesBuilder.isAvatarVisible = isChecked binding.labelAvatarSize.visibility = if (isChecked) View.VISIBLE else View.GONE binding.avatarSizeToggle.visibility = if (isChecked) View.VISIBLE else View.GONE } binding.labelAvatarSize.visibility = if (preferencesBuilder.isAvatarVisible) View.VISIBLE else View.GONE binding.avatarSizeToggle.visibility = if (preferencesBuilder.isAvatarVisible) View.VISIBLE else View.GONE binding.avatarSizeToggle.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean -> if (!isChecked) return@addOnButtonCheckedListener preferencesBuilder.profilePicSize = when (checkedId) { R.id.avatar_size_tiny -> ProfilePicSize.TINY R.id.avatar_size_small -> ProfilePicSize.SMALL else -> ProfilePicSize.REGULAR } } } private fun initNamesToggle() { binding.showNamesToggle.isChecked = preferencesBuilder.isNameVisible binding.showNamesToggle.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> preferencesBuilder.isNameVisible = isChecked } } private fun initCornersToggle() { binding.cornersToggle.check(selectedCornersId) binding.cornersToggle.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean -> if (!isChecked) return@addOnButtonCheckedListener if (checkedId == R.id.corners_round) { preferencesBuilder.hasRoundedCorners = true return@addOnButtonCheckedListener } preferencesBuilder.hasRoundedCorners = false } } private fun initGapToggle() { binding.showGapToggle.isChecked = preferencesBuilder.hasGap binding.showGapToggle.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> preferencesBuilder.hasGap = isChecked } } private val selectedLayoutId: Int get() = when (preferencesBuilder.type) { PostsLayoutType.STAGGERED_GRID -> R.id.layout_staggered PostsLayoutType.LINEAR -> R.id.layout_linear PostsLayoutType.GRID -> R.id.layout_grid else -> R.id.layout_grid } private val selectedColCountId: Int get() = when (preferencesBuilder.colCount) { 2 -> R.id.col_count_two 3 -> R.id.col_count_three else -> R.id.col_count_three } private val selectedCornersId: Int get() = if (preferencesBuilder.hasRoundedCorners) { R.id.corners_round } else R.id.corners_square private val selectedAvatarSizeId: Int get() = when (preferencesBuilder.profilePicSize) { ProfilePicSize.TINY -> R.id.avatar_size_tiny ProfilePicSize.SMALL -> R.id.avatar_size_small ProfilePicSize.REGULAR -> R.id.avatar_size_regular else -> R.id.avatar_size_regular } fun interface OnApplyListener { fun onApply(preferences: PostsLayoutPreferences) } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/ProfilePicDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Color; import android.graphics.drawable.Animatable; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.DialogFragment; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.drawee.interfaces.DraweeController; import com.facebook.imagepipeline.image.ImageInfo; // import java.io.File; import awais.instagrabber.R; import awais.instagrabber.customviews.drawee.AnimatedZoomableController; import awais.instagrabber.customviews.drawee.DoubleTapGestureListener; import awais.instagrabber.databinding.DialogProfilepicBinding; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.webservices.UserRepository; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class ProfilePicDialogFragment extends DialogFragment { private static final String TAG = "ProfilePicDlgFragment"; private long id; private String name; private String fallbackUrl; private boolean isLoggedIn; private DialogProfilepicBinding binding; private String url; public static ProfilePicDialogFragment getInstance(final long id, final String name, final String fallbackUrl) { final Bundle args = new Bundle(); args.putLong("id", id); args.putString("name", name); args.putString("fallbackUrl", fallbackUrl); final ProfilePicDialogFragment fragment = new ProfilePicDialogFragment(); fragment.setArguments(args); return fragment; } public ProfilePicDialogFragment() {} @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { binding = DialogProfilepicBinding.inflate(inflater, container, false); final String cookie = settingsHelper.getString(Constants.COOKIE); isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; return binding.getRoot(); } @NonNull @Override public Dialog onCreateDialog(final Bundle savedInstanceState) { final Dialog dialog = super.onCreateDialog(savedInstanceState); dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); return dialog; } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final Window window = dialog.getWindow(); if (window == null) return; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); int width = ViewGroup.LayoutParams.MATCH_PARENT; int height = ViewGroup.LayoutParams.MATCH_PARENT; window.setLayout(width, height); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); init(); fetchAvatar(); } private void init() { final Bundle arguments = getArguments(); if (arguments == null) { dismiss(); return; } id = arguments.getLong("id"); name = arguments.getString("name"); fallbackUrl = arguments.getString("fallbackUrl"); binding.download.setOnClickListener(v -> { final Context context = getContext(); if (context == null) return; // if (ContextCompat.checkSelfPermission(context, DownloadUtils.PERMS[0]) == PackageManager.PERMISSION_GRANTED) { downloadProfilePicture(); // return; // } // requestPermissions(DownloadUtils.PERMS, 8020); }); } @Override public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == 8020 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { downloadProfilePicture(); } } private void fetchAvatar() { if (isLoggedIn) { final UserRepository repository = UserRepository.Companion.getInstance(); repository.getUserInfo(id, CoroutineUtilsKt.getContinuation((user, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { final Context context = getContext(); if (context == null) { dismiss(); return; } Toast.makeText(context, throwable.getMessage(), Toast.LENGTH_SHORT).show(); dismiss(); return; } if (user != null) { final String url = user.getHDProfilePicUrl(); if (TextUtils.isEmpty(url)) { final Context context = getContext(); if (context == null) return; Toast.makeText(context, R.string.no_profile_pic_found, Toast.LENGTH_LONG).show(); return; } setupPhoto(url); } }), Dispatchers.getIO())); } else setupPhoto(fallbackUrl); } private void setupPhoto(final String result) { if (TextUtils.isEmpty(result)) url = fallbackUrl; else url = result; final DraweeController controller = Fresco .newDraweeControllerBuilder() .setUri(url) .setOldController(binding.imageViewer.getController()) .setControllerListener(new BaseControllerListener() { @Override public void onFailure(final String id, final Throwable throwable) { super.onFailure(id, throwable); binding.download.setVisibility(View.GONE); binding.progressView.setVisibility(View.GONE); } @Override public void onFinalImageSet(final String id, final ImageInfo imageInfo, final Animatable animatable) { super.onFinalImageSet(id, imageInfo, animatable); binding.download.setVisibility(View.VISIBLE); binding.progressView.setVisibility(View.GONE); } }) .build(); binding.imageViewer.setController(controller); final AnimatedZoomableController zoomableController = (AnimatedZoomableController) binding.imageViewer.getZoomableController(); zoomableController.setMaxScaleFactor(3f); zoomableController.setGestureZoomEnabled(true); zoomableController.setEnabled(true); binding.imageViewer.setZoomingEnabled(true); final DoubleTapGestureListener tapListener = new DoubleTapGestureListener(binding.imageViewer); binding.imageViewer.setTapListener(tapListener); } private void downloadProfilePicture() { if (url == null) return; // final File dir = new File(Environment.getExternalStorageDirectory(), "Download"); final Context context = getContext(); if (context == null) return; // if (dir.exists() || dir.mkdirs()) { // // } final String fileName = name + '_' + System.currentTimeMillis() + ".jpg"; // final File saveFile = new File(dir, fileName); final DocumentFile downloadDir = DownloadUtils.getDownloadDir(); final DocumentFile saveFile = downloadDir.createFile(Utils.mimeTypeMap.getMimeTypeFromExtension("jpg"), fileName); DownloadUtils.download(context, url, saveFile); // return; // Toast.makeText(context, R.string.downloader_error_creating_folder, Toast.LENGTH_SHORT).show(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/RestoreBackupDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.provider.MediaStore; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.inputmethod.InputMethodManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import awais.instagrabber.databinding.DialogRestoreBackupBinding; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.ExportImportUtils; import awais.instagrabber.utils.PasswordUtils.IncorrectPasswordException; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import static android.app.Activity.RESULT_OK; public class RestoreBackupDialogFragment extends DialogFragment { private static final String TAG = RestoreBackupDialogFragment.class.getSimpleName(); private static final int STORAGE_PERM_REQUEST_CODE = 8020; private static final int OPEN_FILE_REQUEST_CODE = 1; private OnResultListener onResultListener; private DialogRestoreBackupBinding binding; private boolean isEncrypted; private Uri uri; public RestoreBackupDialogFragment() {} public RestoreBackupDialogFragment(final OnResultListener onResultListener) { this.onResultListener = onResultListener; } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { binding = DialogRestoreBackupBinding.inflate(inflater, container, false); return binding.getRoot(); } @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { Dialog dialog = super.onCreateDialog(savedInstanceState); dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); return dialog; } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final Window window = dialog.getWindow(); if (window == null) return; final int height = ViewGroup.LayoutParams.WRAP_CONTENT; final int width = (int) (Utils.displayMetrics.widthPixels * 0.8); window.setLayout(width, height); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); init(); } @Override public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); } @Override public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { if (data == null || data.getData() == null) return; if (resultCode != RESULT_OK || requestCode != OPEN_FILE_REQUEST_CODE) return; final Context context = getContext(); if (context == null) return; isEncrypted = ExportImportUtils.isEncrypted(context, data.getData()); if (isEncrypted) { binding.passwordGroup.setVisibility(View.VISIBLE); binding.passwordGroup.post(() -> { binding.etPassword.requestFocus(); binding.etPassword.post(() -> { final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; imm.showSoftInput(binding.etPassword, InputMethodManager.SHOW_IMPLICIT); }); binding.btnRestore.setEnabled(!TextUtils.isEmpty(binding.etPassword.getText())); }); } else { binding.passwordGroup.setVisibility(View.GONE); binding.btnRestore.setEnabled(true); } uri = data.getData(); AppExecutors.INSTANCE.getMainThread().execute(() -> { Cursor c = null; try { String[] projection = {MediaStore.Files.FileColumns.DISPLAY_NAME}; final ContentResolver contentResolver = context.getContentResolver(); c = contentResolver.query(uri, projection, null, null, null); if (c != null) { while (c.moveToNext()) { final String displayName = c.getString(0); binding.filePath.setText(displayName); } } } catch (Exception e) { Log.e(TAG, "onActivityResult: ", e); } finally { if (c != null) { c.close(); } } }); } private void init() { final Context context = getContext(); if (context == null) return; binding.btnRestore.setEnabled(false); binding.btnRestore.setOnClickListener(v -> new Handler(Looper.getMainLooper()).post(() -> { if (uri == null) return; int flags = 0; if (binding.cbFavorites.isChecked()) { flags |= ExportImportUtils.FLAG_FAVORITES; } if (binding.cbSettings.isChecked()) { flags |= ExportImportUtils.FLAG_SETTINGS; } if (binding.cbAccounts.isChecked()) { flags |= ExportImportUtils.FLAG_COOKIES; } final Editable text = binding.etPassword.getText(); if (isEncrypted && text == null) return; try { ExportImportUtils.importData( context, flags, uri, !isEncrypted ? null : text.toString(), result -> { if (onResultListener != null) { onResultListener.onResult(result); } dismiss(); } ); } catch (IncorrectPasswordException e) { binding.passwordField.setError("Incorrect password"); } })); binding.etPassword.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {} @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { binding.btnRestore.setEnabled(!TextUtils.isEmpty(s)); binding.passwordField.setError(null); } @Override public void afterTextChanged(final Editable s) {} }); final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.setType("*/*"); startActivityForResult(intent, OPEN_FILE_REQUEST_CODE); } public interface OnResultListener { void onResult(boolean result); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/TabOrderPreferenceDialogFragment.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.content.Context; import android.graphics.Canvas; import android.os.Bundle; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.collect.ImmutableList; import java.util.List; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.adapters.DirectUsersAdapter; import awais.instagrabber.adapters.TabsAdapter; import awais.instagrabber.adapters.viewholder.TabViewHolder; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.models.Tab; import awais.instagrabber.utils.NavigationHelperKt; import awais.instagrabber.utils.Utils; import kotlin.Pair; import static androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_DRAG; import static androidx.recyclerview.widget.ItemTouchHelper.DOWN; import static androidx.recyclerview.widget.ItemTouchHelper.UP; public class TabOrderPreferenceDialogFragment extends DialogFragment { private Callback callback; private Context context; private List tabsInPref; private ItemTouchHelper itemTouchHelper; private AlertDialog dialog; private List newOrderTabs; private List newOtherTabs; private final TabsAdapter.TabAdapterCallback tabAdapterCallback = new TabsAdapter.TabAdapterCallback() { @Override public void onStartDrag(final TabViewHolder viewHolder) { if (itemTouchHelper == null || viewHolder == null) return; itemTouchHelper.startDrag(viewHolder); } @Override public void onOrderChange(final List newOrderTabs) { if (newOrderTabs == null || tabsInPref == null || dialog == null) return; TabOrderPreferenceDialogFragment.this.newOrderTabs = newOrderTabs; setSaveButtonState(newOrderTabs); } @Override public void onAdd(final Tab tab) { // Add this tab to newOrderTabs newOrderTabs = ImmutableList.builder() .addAll(newOrderTabs) .add(tab) .build(); // Remove this tab from newOtherTabs if (newOtherTabs != null) { newOtherTabs = newOtherTabs.stream() .filter(t -> !t.equals(tab)) .collect(Collectors.toList()); } setSaveButtonState(newOrderTabs); // submit these tab lists to adapter if (adapter == null) return; adapter.submitList(newOrderTabs, newOtherTabs, () -> list.postDelayed(() -> adapter.notifyDataSetChanged(), 300)); } @Override public void onRemove(final Tab tab) { // Remove this tab from newOrderTabs newOrderTabs = newOrderTabs.stream() .filter(t -> !t.equals(tab)) .collect(Collectors.toList()); // Add this tab to newOtherTabs if (newOtherTabs != null) { newOtherTabs = ImmutableList.builder() .addAll(newOtherTabs) .add(tab) .build(); } setSaveButtonState(newOrderTabs); // submit these tab lists to adapter if (adapter == null) return; adapter.submitList(newOrderTabs, newOtherTabs, () -> list.postDelayed(() -> { adapter.notifyDataSetChanged(); if (tab.getNavigationRootId() == R.id.direct_messages_nav_graph) { final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( 111, 0, R.string.dm_remove_warning, R.string.ok, 0, 0 ); dialogFragment.show(getChildFragmentManager(), "dm_warning_dialog"); } }, 500)); } private void setSaveButtonState(final List newOrderTabs) { dialog.getButton(AlertDialog.BUTTON_POSITIVE) .setEnabled(!newOrderTabs.equals(tabsInPref)); } }; private final SimpleCallback simpleCallback = new SimpleCallback(UP | DOWN, 0) { private int movePosition = RecyclerView.NO_POSITION; @Override public int getMovementFlags(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder) { if (viewHolder instanceof DirectUsersAdapter.HeaderViewHolder) return 0; if (viewHolder instanceof TabViewHolder && !((TabViewHolder) viewHolder).isDraggable()) return 0; return super.getMovementFlags(recyclerView, viewHolder); } @Override public void onChildDraw(@NonNull final Canvas c, @NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder, final float dX, final float dY, final int actionState, final boolean isCurrentlyActive) { if (actionState != ACTION_STATE_DRAG) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); return; } final TabsAdapter adapter = (TabsAdapter) recyclerView.getAdapter(); if (adapter == null) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); return; } // Do not allow dragging into 'Other tabs' category float edgeY = dY; final int lastPosition = adapter.getCurrentCount() - 1; final View view = viewHolder.itemView; // final int topEdge = recyclerView.getTop(); final int bottomEdge = view.getHeight() * adapter.getCurrentCount() - view.getBottom(); // if (movePosition == 0 && dY < topEdge) { // edgeY = topEdge; // } else if (movePosition >= lastPosition && dY >= bottomEdge) { edgeY = bottomEdge; } super.onChildDraw(c, recyclerView, viewHolder, dX, edgeY, actionState, isCurrentlyActive); } @Override public boolean onMove(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder, @NonNull final RecyclerView.ViewHolder target) { final TabsAdapter adapter = (TabsAdapter) recyclerView.getAdapter(); if (adapter == null) return false; movePosition = target.getBindingAdapterPosition(); if (movePosition >= adapter.getCurrentCount()) { return false; } final int from = viewHolder.getBindingAdapterPosition(); final int to = target.getBindingAdapterPosition(); adapter.moveItem(from, to); // adapter.notifyItemMoved(from, to); return true; } @Override public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int direction) {} @Override public void onSelectedChanged(@Nullable final RecyclerView.ViewHolder viewHolder, final int actionState) { super.onSelectedChanged(viewHolder, actionState); if (!(viewHolder instanceof TabViewHolder)) { movePosition = RecyclerView.NO_POSITION; return; } if (actionState == ACTION_STATE_DRAG) { ((TabViewHolder) viewHolder).setDragging(true); movePosition = viewHolder.getBindingAdapterPosition(); } } @Override public void clearView(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); ((TabViewHolder) viewHolder).setDragging(false); movePosition = RecyclerView.NO_POSITION; } }; private TabsAdapter adapter; private RecyclerView list; public static TabOrderPreferenceDialogFragment newInstance() { final Bundle args = new Bundle(); final TabOrderPreferenceDialogFragment fragment = new TabOrderPreferenceDialogFragment(); fragment.setArguments(args); return fragment; } public TabOrderPreferenceDialogFragment() {} @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); try { callback = (Callback) getParentFragment(); } catch (ClassCastException e) { // throw new ClassCastException("Calling fragment must implement TabOrderPreferenceDialogFragment.Callback interface"); } this.context = context; } @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { return new MaterialAlertDialogBuilder(context) .setView(createView()) .setPositiveButton(R.string.save, (d, w) -> { final boolean hasChanged = newOrderTabs != null && !newOrderTabs.equals(tabsInPref); if (hasChanged) { saveNewOrder(); } if (callback == null) return; callback.onSave(hasChanged); }) .setNegativeButton(R.string.cancel, (dialog, which) -> { if (callback == null) return; callback.onCancel(); }) .create(); } private void saveNewOrder() { final String newOrderString = newOrderTabs .stream() .map(tab -> NavigationHelperKt.getNavGraphNameForNavRootId(tab.getNavigationRootId())) .collect(Collectors.joining(",")); Utils.settingsHelper.putString(PreferenceKeys.PREF_TAB_ORDER, newOrderString); } @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (!(dialog instanceof AlertDialog)) return; this.dialog = (AlertDialog) dialog; this.dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); } @NonNull private View createView() { list = new RecyclerView(context); list.setLayoutManager(new LinearLayoutManager(context)); itemTouchHelper = new ItemTouchHelper(simpleCallback); itemTouchHelper.attachToRecyclerView(list); adapter = new TabsAdapter(tabAdapterCallback); list.setAdapter(adapter); final Pair, List> navTabListPair = NavigationHelperKt.getLoggedInNavTabs(context); tabsInPref = navTabListPair.getFirst(); // initially set newOrderTabs and newOtherTabs same as current tabs newOrderTabs = navTabListPair.getFirst(); newOtherTabs = navTabListPair.getSecond(); adapter.submitList(navTabListPair.getFirst(), navTabListPair.getSecond()); return list; } public interface Callback { void onSave(final boolean orderHasChanged); void onCancel(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/dialogs/TimeSettingsDialog.java ================================================ package awais.instagrabber.dialogs; import android.app.Dialog; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.WindowManager; import android.widget.AdapterView; import android.widget.CompoundButton; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import awais.instagrabber.R; import awais.instagrabber.databinding.DialogTimeSettingsBinding; import awais.instagrabber.utils.DateUtils; import awais.instagrabber.utils.LocaleUtils; import awais.instagrabber.utils.TextUtils; public final class TimeSettingsDialog extends DialogFragment implements AdapterView.OnItemSelectedListener, CompoundButton.OnCheckedChangeListener, View.OnClickListener, TextWatcher { private DialogTimeSettingsBinding binding; private final LocalDateTime magicDate; private DateTimeFormatter currentFormat; private String selectedFormat; private final boolean customDateTimeFormatEnabled; private final String customDateTimeFormat; private final String dateTimeSelection; private final boolean swapDateTimeEnabled; private final OnConfirmListener onConfirmListener; public TimeSettingsDialog(final boolean customDateTimeFormatEnabled, final String customDateTimeFormat, final String dateTimeSelection, final boolean swapDateTimeEnabled, final OnConfirmListener onConfirmListener) { this.customDateTimeFormatEnabled = customDateTimeFormatEnabled; this.customDateTimeFormat = customDateTimeFormat; this.dateTimeSelection = dateTimeSelection; this.swapDateTimeEnabled = swapDateTimeEnabled; this.onConfirmListener = onConfirmListener; magicDate = LocalDateTime.ofInstant( Instant.now(), ZoneId.systemDefault() ); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = DialogTimeSettingsBinding.inflate(inflater, container, false); binding.cbCustomFormat.setOnCheckedChangeListener(this); binding.cbCustomFormat.setChecked(customDateTimeFormatEnabled); binding.cbSwapTimeDate.setChecked(swapDateTimeEnabled); binding.customFormatEditText.setText(customDateTimeFormat); final String[] dateTimeFormat = dateTimeSelection.split(";"); // output = time;separator;date binding.spTimeFormat.setSelection(Integer.parseInt(dateTimeFormat[0])); binding.spSeparator.setSelection(Integer.parseInt(dateTimeFormat[1])); binding.spDateFormat.setSelection(Integer.parseInt(dateTimeFormat[2])); binding.cbSwapTimeDate.setOnCheckedChangeListener(this); refreshTimeFormat(); binding.spTimeFormat.setOnItemSelectedListener(this); binding.spDateFormat.setOnItemSelectedListener(this); binding.spSeparator.setOnItemSelectedListener(this); binding.customFormatEditText.addTextChangedListener(this); binding.btnConfirm.setOnClickListener(this); binding.customFormatField.setEndIconOnClickListener(this); return binding.getRoot(); } private void refreshTimeFormat() { final boolean isCustom = binding.cbCustomFormat.isChecked(); if (isCustom) { final Editable text = binding.customFormatEditText.getText(); if (text != null) { selectedFormat = text.toString(); } } else { final String sepStr = String.valueOf(binding.spSeparator.getSelectedItem()); final String timeStr = String.valueOf(binding.spTimeFormat.getSelectedItem()); final String dateStr = String.valueOf(binding.spDateFormat.getSelectedItem()); final boolean isSwapTime = binding.cbSwapTimeDate.isChecked(); final boolean isBlankSeparator = binding.spSeparator.getSelectedItemPosition() <= 0; selectedFormat = (isSwapTime ? dateStr : timeStr) + (isBlankSeparator ? " " : " '" + sepStr + "' ") + (isSwapTime ? timeStr : dateStr); } binding.btnConfirm.setEnabled(true); try { currentFormat = DateTimeFormatter.ofPattern(selectedFormat, LocaleUtils.getCurrentLocale()); if (isCustom) { final boolean valid = !TextUtils.isEmpty(selectedFormat) && DateUtils.checkFormatterValid(currentFormat); binding.customFormatField.setError(valid ? null :getString(R.string.invalid_format)); if (!valid) { binding.btnConfirm.setEnabled(false); } } binding.timePreview.setText(magicDate.format(currentFormat)); } catch (Exception e) { binding.btnConfirm.setEnabled(false); binding.timePreview.setText(null); } } @Override public void onItemSelected(final AdapterView p, final View v, final int pos, final long id) { refreshTimeFormat(); } @Override public void onCheckedChanged(final CompoundButton buttonView, final boolean isChecked) { if (buttonView == binding.cbCustomFormat) { binding.customFormatField.setVisibility(isChecked ? View.VISIBLE : View.GONE); binding.customFormatField.setEnabled(isChecked); binding.spTimeFormat.setEnabled(!isChecked); binding.spDateFormat.setEnabled(!isChecked); binding.spSeparator.setEnabled(!isChecked); binding.cbSwapTimeDate.setEnabled(!isChecked); } refreshTimeFormat(); } @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { refreshTimeFormat(); } @Override public void onClick(final View v) { if (v == binding.btnConfirm) { if (onConfirmListener != null) { onConfirmListener.onConfirm( binding.cbCustomFormat.isChecked(), binding.spTimeFormat.getSelectedItemPosition(), binding.spSeparator.getSelectedItemPosition(), binding.spDateFormat.getSelectedItemPosition(), selectedFormat, binding.cbSwapTimeDate.isChecked()); } dismiss(); } else if (v == binding.customFormatField.findViewById(R.id.text_input_end_icon)) { binding.customPanel.setVisibility( binding.customPanel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE ); } } public interface OnConfirmListener { void onConfirm(boolean isCustomFormat, int spTimeFormatSelectedItemPosition, int spSeparatorSelectedItemPosition, int spDateFormatSelectedItemPosition, final String selectedFormat, final boolean swapDateTime); } @Override public void onNothingSelected(final AdapterView parent) { } @Override public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) { } @Override public void afterTextChanged(final Editable s) { } @Override public void onResume() { super.onResume(); final Dialog dialog = getDialog(); if (dialog == null) return; final Window window = dialog.getWindow(); if (window == null) return; final WindowManager.LayoutParams params = window.getAttributes(); params.width = ViewGroup.LayoutParams.MATCH_PARENT; params.height = ViewGroup.LayoutParams.WRAP_CONTENT; window.setAttributes(params); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/CollectionPostsFragment.java ================================================ package awais.instagrabber.fragments; import android.animation.ArgbEvaluator; import android.content.Context; import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.graphics.ColorUtils; import androidx.fragment.app.Fragment; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.transition.ChangeBounds; import androidx.transition.TransitionInflater; import androidx.transition.TransitionSet; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.drawee.interfaces.DraweeController; import com.facebook.imagepipeline.image.ImageInfo; import com.google.common.collect.ImmutableList; import java.util.Set; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.asyncs.SavedPostFetchService; import awais.instagrabber.customviews.PrimaryActionModeCallback; import awais.instagrabber.databinding.FragmentCollectionPostsBinding; import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.PostItemType; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.saved.SavedCollection; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.webservices.CollectionService; import awais.instagrabber.webservices.ServiceCallback; public class CollectionPostsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = "CollectionPostsFragment"; private MainActivity fragmentActivity; private FragmentCollectionPostsBinding binding; private CoordinatorLayout root; private boolean shouldRefresh = true; private SavedCollection savedCollection; private ActionMode actionMode; private Set selectedFeedModels; private CollectionService collectionService; private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_SAVED_POSTS_LAYOUT); private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { binding.posts.endSelection(); } }; private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( R.menu.saved_collection_select_menu, new PrimaryActionModeCallback.CallbacksHelper() { @Override public void onDestroy(final ActionMode mode) { binding.posts.endSelection(); } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { if (item.getItemId() == R.id.action_download) { if (CollectionPostsFragment.this.selectedFeedModels == null) return false; final Context context = getContext(); if (context == null) return false; DownloadUtils.download(context, ImmutableList.copyOf(CollectionPostsFragment.this.selectedFeedModels)); binding.posts.endSelection(); } return false; } }); private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { @Override public void onPostClick(final Media feedModel) { openPostDialog(feedModel, -1); } @Override public void onSliderClick(final Media feedModel, final int position) { openPostDialog(feedModel, position); } @Override public void onCommentsClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; try { final NavDirections commentsAction = CollectionPostsFragmentDirections.actionToComments( feedModel.getCode(), feedModel.getPk(), user.getPk() ); NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(commentsAction); } catch (Exception e) { Log.e(TAG, "onCommentsClick: ", e); } } @Override public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { final Context context = getContext(); if (context == null) return; DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); } @Override public void onHashtagClick(final String hashtag) { try { final NavDirections action = CollectionPostsFragmentDirections.actionToHashtag(hashtag); NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onLocationClick(final Media feedModel) { final Location location = feedModel.getLocation(); if (location == null) return; try { final NavDirections action = CollectionPostsFragmentDirections.actionToLocation(location.getPk()); NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onLocationClick: ", e); } } @Override public void onMentionClick(final String mention) { navigateToProfile(mention.trim()); } @Override public void onNameClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onProfilePicClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onURLClick(final String url) { Utils.openURL(getContext(), url); } @Override public void onEmailClick(final String emailId) { Utils.openEmailAddress(getContext(), emailId); } private void openPostDialog(final Media feedModel, final int position) { try { final NavDirections action = CollectionPostsFragmentDirections.actionToPost(feedModel, position); NavHostFragment.findNavController(CollectionPostsFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "openPostDialog: ", e); } } }; private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { @Override public void onSelectionStart() { if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } if (actionMode == null) { actionMode = fragmentActivity.startActionMode(multiSelectAction); } } @Override public void onSelectionChange(final Set selectedFeedModels) { final String title = getString(R.string.number_selected, selectedFeedModels.size()); if (actionMode != null) { actionMode.setTitle(title); } CollectionPostsFragment.this.selectedFeedModels = selectedFeedModels; } @Override public void onSelectionEnd() { if (onBackPressedCallback.isEnabled()) { onBackPressedCallback.setEnabled(false); onBackPressedCallback.remove(); } if (actionMode != null) { actionMode.finish(); actionMode = null; } } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) requireActivity(); final TransitionSet transitionSet = new TransitionSet(); final Context context = getContext(); if (context == null) return; transitionSet.addTransition(new ChangeBounds()) .addTransition(TransitionInflater.from(context).inflateTransition(android.R.transition.move)) .setDuration(200); setSharedElementEnterTransition(transitionSet); postponeEnterTransition(); setHasOptionsMenu(true); final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); final long userId = CookieUtils.getUserIdFromCookie(cookie); final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); collectionService = CollectionService.getInstance(deviceUuid, csrfToken, userId); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentCollectionPostsBinding.inflate(inflater, container, false); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; binding.swipeRefreshLayout.setOnRefreshListener(this); init(); shouldRefresh = false; } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.collection_posts_menu, menu); final MenuItem deleteMenu = menu.findItem(R.id.delete); if (deleteMenu != null) deleteMenu.setVisible(savedCollection.getCollectionType().equals("MEDIA")); final MenuItem editMenu = menu.findItem(R.id.edit); if (editMenu != null) editMenu.setVisible(savedCollection.getCollectionType().equals("MEDIA")); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (item.getItemId() == R.id.layout) { showPostsLayoutPreferences(); return true; } else if (item.getItemId() == R.id.delete) { final Context context = getContext(); if (context == null) return false; new AlertDialog.Builder(context) .setTitle(R.string.are_you_sure) .setMessage(R.string.delete_collection_note) .setPositiveButton(R.string.confirm, (d, w) -> collectionService.deleteCollection( savedCollection.getCollectionId(), new ServiceCallback() { @Override public void onSuccess(final String result) { SavedCollectionsFragment.pleaseRefresh = true; NavHostFragment.findNavController(CollectionPostsFragment.this).navigateUp(); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error deleting collection", t); try { final Context context = getContext(); if (context == null) return; Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); } catch (final Throwable ignored) {} } })) .setNegativeButton(R.string.cancel, null) .show(); } else if (item.getItemId() == R.id.edit) { final Context context = getContext(); if (context == null) return false; final EditText input = new EditText(context); new AlertDialog.Builder(context) .setTitle(R.string.edit_collection) .setView(input) .setPositiveButton(R.string.confirm, (d, w) -> collectionService.editCollectionName( savedCollection.getCollectionId(), input.getText().toString(), new ServiceCallback() { @Override public void onSuccess(final String result) { binding.collapsingToolbarLayout.setTitle(input.getText().toString()); SavedCollectionsFragment.pleaseRefresh = true; } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error editing collection", t); try { final Context context = getContext(); if (context == null) return; Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); } catch (final Throwable ignored) {} } })) .setNegativeButton(R.string.cancel, null) .show(); } return super.onOptionsItemSelected(item); } @Override public void onResume() { super.onResume(); fragmentActivity.setToolbar(binding.toolbar, this); } @Override public void onRefresh() { binding.posts.refresh(); } @Override public void onStop() { super.onStop(); fragmentActivity.resetToolbar(this); } private void init() { if (getArguments() == null) return; final CollectionPostsFragmentArgs fragmentArgs = CollectionPostsFragmentArgs.fromBundle(getArguments()); savedCollection = fragmentArgs.getSavedCollection(); setupToolbar(fragmentArgs.getTitleColor(), fragmentArgs.getBackgroundColor()); setupPosts(); } private void setupToolbar(final int titleColor, final int backgroundColor) { if (savedCollection == null) { return; } binding.cover.setTransitionName("collection-" + savedCollection.getCollectionId()); fragmentActivity.setToolbar(binding.toolbar, this); binding.collapsingToolbarLayout.setTitle(savedCollection.getCollectionName()); final int collapsedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0xFF); final int expandedTitleTextColor = ColorUtils.setAlphaComponent(titleColor, 0x99); binding.collapsingToolbarLayout.setExpandedTitleColor(expandedTitleTextColor); binding.collapsingToolbarLayout.setCollapsedTitleTextColor(collapsedTitleTextColor); binding.collapsingToolbarLayout.setContentScrimColor(backgroundColor); final Drawable navigationIcon = binding.toolbar.getNavigationIcon(); final Drawable overflowIcon = binding.toolbar.getOverflowIcon(); if (navigationIcon != null && overflowIcon != null) { final Drawable navDrawable = navigationIcon.mutate(); final Drawable overflowDrawable = overflowIcon.mutate(); navDrawable.setAlpha(0xFF); overflowDrawable.setAlpha(0xFF); final ArgbEvaluator argbEvaluator = new ArgbEvaluator(); binding.appBarLayout.addOnOffsetChangedListener((appBarLayout, verticalOffset) -> { final int totalScrollRange = appBarLayout.getTotalScrollRange(); final float current = totalScrollRange + verticalOffset; final float fraction = current / totalScrollRange; final int tempColor = (int) argbEvaluator.evaluate(fraction, collapsedTitleTextColor, expandedTitleTextColor); navDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); overflowDrawable.setColorFilter(tempColor, PorterDuff.Mode.SRC_ATOP); }); } final GradientDrawable gd = new GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, new int[]{Color.TRANSPARENT, backgroundColor}); binding.background.setBackground(gd); setupCover(); } private void setupCover() { final Media coverMedia = savedCollection.getCoverMediaList() == null ? savedCollection.getCoverMedia() : savedCollection.getCoverMediaList().get(0); final String coverUrl = ResponseBodyUtils.getImageUrl(coverMedia); final DraweeController controller = Fresco .newDraweeControllerBuilder() .setOldController(binding.cover.getController()) .setUri(coverUrl) .setControllerListener(new BaseControllerListener() { @Override public void onFailure(final String id, final Throwable throwable) { super.onFailure(id, throwable); startPostponedEnterTransition(); } @Override public void onFinalImageSet(final String id, @Nullable final ImageInfo imageInfo, @Nullable final Animatable animatable) { startPostponedEnterTransition(); } }) .build(); binding.cover.setController(controller); } private void setupPosts() { binding.posts.setViewModelStoreOwner(this) .setLifeCycleOwner(this) .setPostFetchService(new SavedPostFetchService(0, PostItemType.COLLECTION, true, savedCollection.getCollectionId())) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) .setFeedItemCallback(feedItemCallback) .setSelectionModeCallback(selectionModeCallback) .init(); } private void updateSwipeRefreshState() { AppExecutors.INSTANCE.getMainThread().execute(() -> binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) ); } private void navigateToProfile(final String username) { try { final NavDirections action = CollectionPostsFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "navigateToProfile: ", e); } } private void showPostsLayoutPreferences() { final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( Constants.PREF_SAVED_POSTS_LAYOUT, preferences -> { layoutPreferences = preferences; new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); }); fragment.show(getChildFragmentManager(), "posts_layout_preferences"); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/FavoritesFragment.kt ================================================ package awais.instagrabber.fragments import android.content.DialogInterface import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import awais.instagrabber.R import awais.instagrabber.adapters.FavoritesAdapter import awais.instagrabber.databinding.FragmentFavoritesBinding import awais.instagrabber.db.entities.Favorite import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.viewmodels.FavoritesViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder class FavoritesFragment : Fragment() { private var shouldRefresh = true private lateinit var binding: FragmentFavoritesBinding private lateinit var root: RecyclerView private lateinit var adapter: FavoritesAdapter private val favoritesViewModel: FavoritesViewModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { if (this::root.isInitialized) { shouldRefresh = false return root } binding = FragmentFavoritesBinding.inflate(layoutInflater) root = binding.root binding.favoriteList.layoutManager = LinearLayoutManager(context) return root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (!shouldRefresh) return init() shouldRefresh = false } override fun onPause() { super.onPause() adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT } override fun onResume() { super.onResume() if (!this::adapter.isInitialized) return // refresh list every time in onViewStateRestored since it is cheaper than implementing pull down to refresh favoritesViewModel.list.observe(viewLifecycleOwner, { list: List? -> adapter.submitList(list) { adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.ALLOW } }) } private fun init() { adapter = FavoritesAdapter({ model: Favorite -> when (model.type) { FavoriteType.USER -> { try { val username = model.query ?: return@FavoritesAdapter val actionToProfile = FavoritesFragmentDirections.actionToProfile().apply { this.username = username } findNavController().navigate(actionToProfile) } catch (e: Exception) { Log.e(TAG, "init: ", e) } } FavoriteType.LOCATION -> { try { val locationId = model.query ?: return@FavoritesAdapter val actionToLocation = FavoritesFragmentDirections.actionToLocation(locationId.toLong()) findNavController().navigate(actionToLocation) } catch (e: Exception) { Log.e(TAG, "init: ", e) } } FavoriteType.HASHTAG -> { try { val hashtag = model.query ?: return@FavoritesAdapter val actionToHashtag = FavoritesFragmentDirections.actionToHashtag(hashtag) findNavController().navigate(actionToHashtag) } catch (e: Exception) { Log.e(TAG, "init: ", e) } } else -> { } } }, { model: Favorite -> // delete val context = context ?: return@FavoritesAdapter false MaterialAlertDialogBuilder(context) .setMessage(getString(R.string.quick_access_confirm_delete, model.query)) .setPositiveButton(R.string.yes) { d: DialogInterface, _: Int -> favoritesViewModel.delete(model) { d.dismiss() } } .setNegativeButton(R.string.no, null) .show() true }) binding.favoriteList.adapter = adapter } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/FollowViewerFragment.kt ================================================ package awais.instagrabber.fragments import android.os.Bundle import android.view.* import androidx.appcompat.app.ActionBar import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import awais.instagrabber.R import awais.instagrabber.adapters.FollowAdapter import awais.instagrabber.customviews.helpers.RecyclerLazyLoader import awais.instagrabber.databinding.FragmentFollowersViewerBinding import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.viewmodels.FollowViewModel import thoughtbot.expandableadapter.ExpandableGroup import java.util.* class FollowViewerFragment : Fragment(), SwipeRefreshLayout.OnRefreshListener { private var isFollowersList = false private var isCompare = false private var shouldRefresh = true private var searching = false private var username: String? = null private var namePost: String? = null private var type = 0 private var root: SwipeRefreshLayout? = null private var adapter: FollowAdapter? = null private lateinit var lazyLoader: RecyclerLazyLoader private lateinit var fragmentActivity: AppCompatActivity private lateinit var viewModel: FollowViewModel private lateinit var binding: FragmentFollowersViewerBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) fragmentActivity = activity as AppCompatActivity viewModel = ViewModelProvider(this).get(FollowViewModel::class.java) setHasOptionsMenu(true) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { if (root != null) { shouldRefresh = false return root!! } binding = FragmentFollowersViewerBinding.inflate(layoutInflater) root = binding.root return root!! } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (!shouldRefresh) return init() shouldRefresh = false } private fun init() { val args = arguments ?: return val fragmentArgs = FollowViewerFragmentArgs.fromBundle(args) viewModel.userId.value = fragmentArgs.profileId isFollowersList = fragmentArgs.isFollowersList username = fragmentArgs.username namePost = username setTitle(username) binding.swipeRefreshLayout.setOnRefreshListener(this) if (isCompare) listCompare() else listFollows() viewModel.fetch(isFollowersList, null) } override fun onResume() { super.onResume() setTitle(username) setSubtitle(type) } private fun setTitle(title: String?) { val actionBar: ActionBar = fragmentActivity.supportActionBar ?: return actionBar.title = title } private fun setSubtitle(subtitleRes: Int) { val actionBar: ActionBar = fragmentActivity.supportActionBar ?: return actionBar.setSubtitle(subtitleRes) } override fun onRefresh() { lazyLoader.resetState() viewModel.clearProgress() if (isCompare) listCompare() else viewModel.fetch(isFollowersList, null) } override fun onDestroy() { fragmentActivity.supportActionBar?.subtitle = null super.onDestroy() } private fun listFollows() { viewModel.comparison.removeObservers(viewLifecycleOwner) viewModel.status.removeObservers(viewLifecycleOwner) type = if (isFollowersList) R.string.followers_type_followers else R.string.followers_type_following setSubtitle(type) val layoutManager = LinearLayoutManager(context) lazyLoader = RecyclerLazyLoader(layoutManager) { _, totalItemsCount -> binding.swipeRefreshLayout.isRefreshing = true val liveData = if (searching) viewModel.search(isFollowersList) else viewModel.fetch(isFollowersList, null) liveData.observe(viewLifecycleOwner) { binding.swipeRefreshLayout.isRefreshing = it.status != Resource.Status.SUCCESS layoutManager.scrollToPosition(totalItemsCount) } } binding.rvFollow.addOnScrollListener(lazyLoader) binding.rvFollow.layoutManager = layoutManager viewModel.getList(isFollowersList).observe(viewLifecycleOwner) { binding.swipeRefreshLayout.isRefreshing = false refreshAdapter(it, null, null, null) } } private fun listCompare() { viewModel.getList(isFollowersList).removeObservers(viewLifecycleOwner) binding.rvFollow.clearOnScrollListeners() binding.swipeRefreshLayout.isRefreshing = true setSubtitle(R.string.followers_compare) viewModel.status.observe(viewLifecycleOwner) {} viewModel.comparison.observe(viewLifecycleOwner) { if (it != null) { binding.swipeRefreshLayout.isRefreshing = false refreshAdapter(null, it.first, it.second, it.third) } } } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.follow, menu) val menuSearch = menu.findItem(R.id.action_search) val searchView = menuSearch.actionView as SearchView searchView.queryHint = resources.getString(R.string.action_search) searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { return false } override fun onQueryTextChange(query: String): Boolean { if (query.isEmpty()) { if (!isCompare && searching) { viewModel.setQuery(null, isFollowersList) viewModel.getSearch().removeObservers(viewLifecycleOwner) viewModel.getList(isFollowersList).observe(viewLifecycleOwner) { refreshAdapter(it, null, null, null) } } searching = false return true } searching = true if (isCompare && adapter != null) { adapter!!.filter.filter(query) return true } viewModel.getList(isFollowersList).removeObservers(viewLifecycleOwner) binding.swipeRefreshLayout.isRefreshing = true viewModel.setQuery(query, isFollowersList) viewModel.getSearch().observe(viewLifecycleOwner) { binding.swipeRefreshLayout.isRefreshing = false refreshAdapter(it, null, null, null) } return true } }) } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId != R.id.action_compare) return super.onOptionsItemSelected(item) binding.rvFollow.adapter = null if (isCompare) { isCompare = false listFollows() } else { isCompare = true listCompare() } return true } private fun refreshAdapter( followModels: List?, allFollowing: List?, followingModels: List?, followersModels: List? ) { val groups: ArrayList = ArrayList(1) if (isCompare && followingModels != null && followersModels != null && allFollowing != null) { if (followingModels.isNotEmpty()) groups.add( ExpandableGroup( getString( R.string.followers_not_following, username ), followingModels ) ) if (followersModels.isNotEmpty()) groups.add( ExpandableGroup( getString( R.string.followers_not_follower, namePost ), followersModels ) ) if (allFollowing.isNotEmpty()) groups.add( ExpandableGroup( getString(R.string.followers_both_following), allFollowing ) ) } else if (followModels != null) { groups.add(ExpandableGroup(getString(type), followModels)) } else return adapter = FollowAdapter({ v -> val tag = v.tag if (tag is User) { findNavController().navigate(FollowViewerFragmentDirections.actionToProfile().setUsername(tag.username)) } }, groups).also { it.toggleGroup(0) binding.rvFollow.adapter = it } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/HashTagFragment.java ================================================ package awais.instagrabber.fragments; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Typeface; import android.os.Bundle; import android.os.Handler; import android.text.SpannableStringBuilder; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; import android.util.Log; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.fragment.app.Fragment; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.android.material.snackbar.BaseTransientBottomBar; import com.google.android.material.snackbar.Snackbar; import com.google.common.collect.ImmutableList; import java.time.LocalDateTime; import java.util.Set; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.asyncs.HashtagPostFetchService; import awais.instagrabber.customviews.PrimaryActionModeCallback; import awais.instagrabber.databinding.FragmentHashtagBinding; import awais.instagrabber.databinding.LayoutHashtagDetailsBinding; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.db.repositories.FavoriteRepository; import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.models.enums.FollowingType; import awais.instagrabber.repositories.responses.Hashtag; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.ServiceCallback; import awais.instagrabber.webservices.TagsService; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class HashTagFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = "HashTagFragment"; private MainActivity fragmentActivity; private FragmentHashtagBinding binding; private CoordinatorLayout root; private boolean shouldRefresh = true; private boolean opening = false; private String hashtag; private Hashtag hashtagModel = null; private ActionMode actionMode; // private StoriesRepository storiesRepository; private boolean isLoggedIn; private TagsService tagsService; private GraphQLRepository graphQLRepository; // private boolean storiesFetching; private Set selectedFeedModels; private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_HASHTAG_POSTS_LAYOUT); private LayoutHashtagDetailsBinding hashtagDetailsBinding; private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { binding.posts.endSelection(); } }; private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { @Override public void onDestroy(final ActionMode mode) { binding.posts.endSelection(); } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { if (item.getItemId() == R.id.action_download) { if (HashTagFragment.this.selectedFeedModels == null) return false; final Context context = getContext(); if (context == null) return false; DownloadUtils.download(context, ImmutableList.copyOf(HashTagFragment.this.selectedFeedModels)); binding.posts.endSelection(); return true; } return false; } }); private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { @Override public void onPostClick(final Media feedModel) { openPostDialog(feedModel, -1); } @Override public void onSliderClick(final Media feedModel, final int position) { openPostDialog(feedModel, position); } @Override public void onCommentsClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; try { final NavDirections commentsAction = HashTagFragmentDirections.actionToComments( feedModel.getCode(), feedModel.getCode(), user.getPk() ); NavHostFragment.findNavController(HashTagFragment.this).navigate(commentsAction); } catch (Exception e) { Log.e(TAG, "onCommentsClick: ", e); } } @Override public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { final Context context = getContext(); if (context == null) return; DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); } @Override public void onHashtagClick(final String hashtag) { try { final NavDirections action = HashTagFragmentDirections.actionToHashtag(hashtag); NavHostFragment.findNavController(HashTagFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onLocationClick(final Media media) { final Location location = media.getLocation(); if (location == null) return; try { final NavDirections action = HashTagFragmentDirections.actionToLocation(location.getPk()); NavHostFragment.findNavController(HashTagFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onLocationClick: ", e); } } @Override public void onMentionClick(final String mention) { navigateToProfile(mention.trim()); } @Override public void onNameClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onProfilePicClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onURLClick(final String url) { Utils.openURL(getContext(), url); } @Override public void onEmailClick(final String emailId) { Utils.openEmailAddress(getContext(), emailId); } private void openPostDialog(@NonNull final Media feedModel, final int position) { if (opening) return; final User user = feedModel.getUser(); if (user == null) return; if (TextUtils.isEmpty(user.getUsername())) { // this only happens for anons opening = true; final String code = feedModel.getCode(); if (code == null) return; graphQLRepository.fetchPost(code, CoroutineUtilsKt.getContinuation((media, throwable) -> { opening = false; if (throwable != null) { Log.e(TAG, "Error", throwable); return; } if (media == null) return; AppExecutors.INSTANCE.getMainThread().execute(() -> openPostDialog(media, position)); }, Dispatchers.getIO())); return; } opening = true; try { final NavDirections action = HashTagFragmentDirections.actionToPost(feedModel, position); NavHostFragment.findNavController(HashTagFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "openPostDialog: ", e); } opening = false; } }; private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { @Override public void onSelectionStart() { if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } if (actionMode == null) { actionMode = fragmentActivity.startActionMode(multiSelectAction); } } @Override public void onSelectionChange(final Set selectedFeedModels) { final String title = getString(R.string.number_selected, selectedFeedModels.size()); if (actionMode != null) { actionMode.setTitle(title); } HashTagFragment.this.selectedFeedModels = selectedFeedModels; } @Override public void onSelectionEnd() { if (onBackPressedCallback.isEnabled()) { onBackPressedCallback.setEnabled(false); onBackPressedCallback.remove(); } if (actionMode != null) { actionMode.finish(); actionMode = null; } } }; private final ServiceCallback cb = new ServiceCallback() { @Override public void onSuccess(final Hashtag result) { hashtagModel = result; binding.swipeRefreshLayout.setRefreshing(false); setHashtagDetails(); } @Override public void onFailure(final Throwable t) { setHashtagDetails(); } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) requireActivity(); final String cookie = settingsHelper.getString(Constants.COOKIE); isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; tagsService = isLoggedIn ? TagsService.getInstance() : null; // storiesRepository = isLoggedIn ? StoriesRepository.Companion.getInstance() : null; graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); setHasOptionsMenu(true); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentHashtagBinding.inflate(inflater, container, false); root = binding.getRoot(); hashtagDetailsBinding = binding.header; return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; binding.swipeRefreshLayout.setOnRefreshListener(this); init(); shouldRefresh = false; } @Override public void onRefresh() { binding.posts.refresh(); // fetchStories(); } @Override public void onResume() { super.onResume(); fragmentActivity.setToolbar(binding.toolbar, this); setTitle(); } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.topic_posts_menu, menu); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (item.getItemId() == R.id.layout) { showPostsLayoutPreferences(); return true; } return super.onOptionsItemSelected(item); } @Override public void onStop() { super.onStop(); fragmentActivity.resetToolbar(this); } private void init() { if (getArguments() == null) return; final HashTagFragmentArgs fragmentArgs = HashTagFragmentArgs.fromBundle(getArguments()); hashtag = fragmentArgs.getHashtag(); if (hashtag.charAt(0) == '#') hashtag = hashtag.substring(1); fetchHashtagModel(); } private void fetchHashtagModel() { binding.swipeRefreshLayout.setRefreshing(true); if (isLoggedIn) tagsService.fetch(hashtag, cb); else graphQLRepository.fetchTag(hashtag, CoroutineUtilsKt.getContinuation((hashtag1, throwable) -> { if (throwable != null) { cb.onFailure(throwable); return; } AppExecutors.INSTANCE.getMainThread().execute(() -> cb.onSuccess(hashtag1)); }, Dispatchers.getIO())); } private void setupPosts() { binding.posts.setViewModelStoreOwner(this) .setLifeCycleOwner(this) .setPostFetchService(new HashtagPostFetchService(hashtagModel, isLoggedIn)) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) .setFeedItemCallback(feedItemCallback) .setSelectionModeCallback(selectionModeCallback) .init(); // binding.posts.addOnScrollListener(new RecyclerView.OnScrollListener() { // @Override // public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { // super.onScrolled(recyclerView, dx, dy); // final boolean canScrollVertically = recyclerView.canScrollVertically(-1); // final MotionScene.Transition transition = root.getTransition(R.id.transition); // if (transition != null) { // transition.setEnable(!canScrollVertically); // } // } // }); } private void setHashtagDetails() { if (hashtagModel == null) { try { Toast.makeText(getContext(), R.string.error_loading_hashtag, Toast.LENGTH_SHORT).show(); binding.swipeRefreshLayout.setEnabled(false); } catch (Exception ignored) {} return; } setTitle(); setupPosts(); if (isLoggedIn) { hashtagDetailsBinding.btnFollowTag.setVisibility(View.VISIBLE); hashtagDetailsBinding.btnFollowTag.setText(hashtagModel.getFollowing() == FollowingType.FOLLOWING ? R.string.unfollow : R.string.follow); hashtagDetailsBinding.btnFollowTag.setChipIconResource(hashtagModel.getFollowing() == FollowingType.FOLLOWING ? R.drawable.ic_outline_person_add_disabled_24 : R.drawable.ic_outline_person_add_24); hashtagDetailsBinding.btnFollowTag.setOnClickListener(v -> { final String cookie = settingsHelper.getString(Constants.COOKIE); final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); final long userId = CookieUtils.getUserIdFromCookie(cookie); final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); if (csrfToken != null && userId != 0) { hashtagDetailsBinding.btnFollowTag.setClickable(false); tagsService.changeFollow( hashtagModel.getFollowing() == FollowingType.FOLLOWING ? "unfollow" : "follow", hashtag, csrfToken, userId, deviceUuid, new ServiceCallback() { @Override public void onSuccess(final Boolean result) { hashtagDetailsBinding.btnFollowTag.setClickable(true); if (!result) { Log.e(TAG, "onSuccess: result is false"); Snackbar.make(root, R.string.downloader_unknown_error, BaseTransientBottomBar.LENGTH_LONG) .show(); return; } hashtagDetailsBinding.btnFollowTag.setText(R.string.unfollow); hashtagDetailsBinding.btnFollowTag.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24); } @Override public void onFailure(@NonNull final Throwable t) { hashtagDetailsBinding.btnFollowTag.setClickable(true); Log.e(TAG, "onFailure: ", t); final String message = t.getMessage(); Snackbar.make( root, message != null ? message : getString(R.string.downloader_unknown_error), BaseTransientBottomBar.LENGTH_LONG) .show(); } }); } }); } else { hashtagDetailsBinding.btnFollowTag.setVisibility(View.GONE); } hashtagDetailsBinding.favChip.setVisibility(View.VISIBLE); final Context context = getContext(); if (context == null) return; final FavoriteRepository favoriteRepository = FavoriteRepository.Companion.getInstance(context); favoriteRepository.getFavorite( hashtag, FavoriteType.HASHTAG, CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null || favorite == null) { hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); hashtagDetailsBinding.favChip.setText(R.string.add_to_favorites); return; } favoriteRepository.insertOrUpdateFavorite( new Favorite( favorite.getId(), hashtag, FavoriteType.HASHTAG, hashtagModel.getName(), "res:/" + R.drawable.ic_hashtag, favorite.getDateAdded() ), CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "onSuccess: ", throwable1); return; } hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); hashtagDetailsBinding.favChip.setText(R.string.favorite_short); }), Dispatchers.getIO()) ); }), Dispatchers.getIO()) ); hashtagDetailsBinding.favChip.setOnClickListener(v -> favoriteRepository.getFavorite( hashtag, FavoriteType.HASHTAG, CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "setHashtagDetails: ", throwable); return; } if (favorite == null) { favoriteRepository.insertOrUpdateFavorite( new Favorite( 0, hashtag, FavoriteType.HASHTAG, hashtagModel.getName(), "res:/" + R.drawable.ic_hashtag, LocalDateTime.now() ), CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "onDataNotAvailable: ", throwable1); return; } hashtagDetailsBinding.favChip.setText(R.string.favorite_short); hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); showSnackbar(getString(R.string.added_to_favs)); }), Dispatchers.getIO()) ); return; } favoriteRepository.deleteFavorite( hashtag, FavoriteType.HASHTAG, CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "onSuccess: ", throwable1); return; } hashtagDetailsBinding.favChip.setText(R.string.add_to_favorites); hashtagDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); showSnackbar(getString(R.string.removed_from_favs)); }), Dispatchers.getIO()) ); }), Dispatchers.getIO()) ) ); hashtagDetailsBinding.mainHashtagImage.setImageURI("res:/" + R.drawable.ic_hashtag); final String postCount = String.valueOf(hashtagModel.getMediaCount()); final SpannableStringBuilder span = new SpannableStringBuilder(getResources().getQuantityString( R.plurals.main_posts_count_inline, hashtagModel.getMediaCount() > 2000000000L ? 2000000000 : Long.valueOf(hashtagModel.getMediaCount()).intValue(), postCount) ); span.setSpan(new RelativeSizeSpan(1.2f), 0, postCount.length(), 0); span.setSpan(new StyleSpan(Typeface.BOLD), 0, postCount.length(), 0); hashtagDetailsBinding.mainTagPostCount.setText(span); hashtagDetailsBinding.mainTagPostCount.setVisibility(View.VISIBLE); // hashtagDetailsBinding.mainHashtagImage.setOnClickListener(v -> { // if (!hasStories) return; // // show stories // final NavDirections action = HashTagFragmentDirections // .actionHashtagFragmentToStoryViewerFragment(StoryViewerOptions.forHashtag(hashtagModel.getName())); // NavHostFragment.findNavController(this).navigate(action); // }); } private void showSnackbar(final String message) { @SuppressLint("ShowToast") final Snackbar snackbar = Snackbar.make(root, message, BaseTransientBottomBar.LENGTH_LONG); snackbar.setAction(R.string.ok, v1 -> snackbar.dismiss()) .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) .setAnchorView(fragmentActivity.getBottomNavView()) .show(); } private void setTitle() { final ActionBar actionBar = fragmentActivity.getSupportActionBar(); if (actionBar != null) { actionBar.setTitle('#' + hashtag); } } private void updateSwipeRefreshState() { AppExecutors.INSTANCE.getMainThread().execute(() -> binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) ); } private void navigateToProfile(final String username) { try { final NavDirections action = HashTagFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "navigateToProfile: ", e); } } private void showPostsLayoutPreferences() { final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( Constants.PREF_HASHTAG_POSTS_LAYOUT, preferences -> { layoutPreferences = preferences; new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); }); fragment.show(getChildFragmentManager(), "posts_layout_preferences"); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/LikesViewerFragment.java ================================================ package awais.instagrabber.fragments; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import java.util.List; import awais.instagrabber.adapters.LikesAdapter; import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; import awais.instagrabber.databinding.FragmentLikesBinding; import awais.instagrabber.repositories.responses.GraphQLUserListFetchResponse; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.MediaRepository; import awais.instagrabber.webservices.ServiceCallback; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public final class LikesViewerFragment extends BottomSheetDialogFragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = LikesViewerFragment.class.getSimpleName(); private FragmentLikesBinding binding; private RecyclerLazyLoader lazyLoader; private MediaRepository mediaRepository; private GraphQLRepository graphQLRepository; private boolean isLoggedIn; private String postId, endCursor; private boolean isComment; private final ServiceCallback> cb = new ServiceCallback>() { @Override public void onSuccess(final List result) { final LikesAdapter likesAdapter = new LikesAdapter(result, v -> { final Object tag = v.getTag(); if (tag instanceof User) { User model = (User) tag; try { final NavDirections action = LikesViewerFragmentDirections.actionToProfile().setUsername(model.getUsername()); NavHostFragment.findNavController(LikesViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onSuccess: ", e); } } }); binding.rvLikes.setAdapter(likesAdapter); final Context context = getContext(); if (context == null) return; binding.rvLikes.setLayoutManager(new LinearLayoutManager(context)); binding.rvLikes.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); binding.swipeRefreshLayout.setRefreshing(false); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error", t); try { final Context context = getContext(); Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); } catch (Exception ignored) {} } }; private final ServiceCallback anonCb = new ServiceCallback() { @Override public void onSuccess(final GraphQLUserListFetchResponse result) { endCursor = result.getNextMaxId(); final LikesAdapter likesAdapter = new LikesAdapter(result.getItems(), v -> { final Object tag = v.getTag(); if (tag instanceof User) { User model = (User) tag; try { final NavDirections action = LikesViewerFragmentDirections.actionToProfile().setUsername(model.getUsername()); NavHostFragment.findNavController(LikesViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onSuccess: ", e); } } }); binding.rvLikes.setAdapter(likesAdapter); binding.swipeRefreshLayout.setRefreshing(false); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error", t); try { final Context context = getContext(); Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); } catch (Exception ignored) {} } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final String cookie = settingsHelper.getString(Constants.COOKIE); final long userId = CookieUtils.getUserIdFromCookie(cookie); isLoggedIn = !TextUtils.isEmpty(cookie) && userId != 0; // final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); if (csrfToken == null) return; mediaRepository = isLoggedIn ? MediaRepository.Companion.getInstance() : null; graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); // setHasOptionsMenu(true); } @NonNull @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = FragmentLikesBinding.inflate(getLayoutInflater()); binding.swipeRefreshLayout.setEnabled(false); binding.swipeRefreshLayout.setNestedScrollingEnabled(false); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { init(); } @Override public void onRefresh() { if (isComment && !isLoggedIn) { lazyLoader.resetState(); graphQLRepository.fetchCommentLikers( postId, null, CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { anonCb.onFailure(throwable); return; } anonCb.onSuccess(response); }), Dispatchers.getIO()) ); } else { mediaRepository.fetchLikes( postId, isComment, CoroutineUtilsKt.getContinuation((users, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { cb.onFailure(throwable); return; } //noinspection unchecked cb.onSuccess((List) users); }), Dispatchers.getIO()) ); } } private void init() { if (getArguments() == null) return; final LikesViewerFragmentArgs fragmentArgs = LikesViewerFragmentArgs.fromBundle(getArguments()); postId = fragmentArgs.getPostId(); isComment = fragmentArgs.getIsComment(); binding.swipeRefreshLayout.setOnRefreshListener(this); binding.swipeRefreshLayout.setRefreshing(true); if (isComment && !isLoggedIn) { final Context context = getContext(); if (context == null) return; final LinearLayoutManager layoutManager = new LinearLayoutManager(context); binding.rvLikes.setLayoutManager(layoutManager); binding.rvLikes.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.HORIZONTAL)); lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> { if (!TextUtils.isEmpty(endCursor)) { graphQLRepository.fetchCommentLikers( postId, endCursor, CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { anonCb.onFailure(throwable); return; } anonCb.onSuccess(response); }), Dispatchers.getIO()) ); } endCursor = null; }); binding.rvLikes.addOnScrollListener(lazyLoader); } onRefresh(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/LocationFragment.java ================================================ package awais.instagrabber.fragments; import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.fragment.app.Fragment; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.android.material.snackbar.BaseTransientBottomBar; import com.google.android.material.snackbar.Snackbar; import com.google.common.collect.ImmutableList; import java.time.LocalDateTime; import java.util.Set; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.asyncs.LocationPostFetchService; import awais.instagrabber.customviews.PrimaryActionModeCallback; import awais.instagrabber.databinding.FragmentLocationBinding; import awais.instagrabber.databinding.LayoutLocationDetailsBinding; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.db.repositories.FavoriteRepository; import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.LocationService; import awais.instagrabber.webservices.ServiceCallback; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class LocationFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = "LocationFragment"; private MainActivity fragmentActivity; private FragmentLocationBinding binding; private CoordinatorLayout root; private boolean shouldRefresh = true; private boolean opening = false; private long locationId; private Location locationModel; private ActionMode actionMode; private GraphQLRepository graphQLRepository; private LocationService locationService; private boolean isLoggedIn; private Set selectedFeedModels; private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_LOCATION_POSTS_LAYOUT); private LayoutLocationDetailsBinding locationDetailsBinding; private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { binding.posts.endSelection(); } }; private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { @Override public void onDestroy(final ActionMode mode) { binding.posts.endSelection(); } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { if (item.getItemId() == R.id.action_download) { if (LocationFragment.this.selectedFeedModels == null) return false; final Context context = getContext(); if (context == null) return false; DownloadUtils.download(context, ImmutableList.copyOf(LocationFragment.this.selectedFeedModels)); binding.posts.endSelection(); return true; } return false; } }); private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { @Override public void onPostClick(final Media feedModel) { openPostDialog(feedModel, -1); } @Override public void onSliderClick(final Media feedModel, final int position) { openPostDialog(feedModel, position); } @Override public void onCommentsClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; try { final NavDirections commentsAction = LocationFragmentDirections.actionToComments( feedModel.getCode(), feedModel.getPk(), user.getPk() ); NavHostFragment.findNavController(LocationFragment.this).navigate(commentsAction); } catch (Exception e) { Log.e(TAG, "onCommentsClick: ", e); } } @Override public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { final Context context = getContext(); if (context == null) return; DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); } @Override public void onHashtagClick(final String hashtag) { try { final NavDirections action = LocationFragmentDirections.actionToHashtag(hashtag); NavHostFragment.findNavController(LocationFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onLocationClick(final Media feedModel) { final Location location = feedModel.getLocation(); if (location == null) return; final NavDirections action = LocationFragmentDirections.actionToLocation(location.getPk()); NavHostFragment.findNavController(LocationFragment.this).navigate(action); } @Override public void onMentionClick(final String mention) { navigateToProfile(mention.trim()); } @Override public void onNameClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onProfilePicClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onURLClick(final String url) { Utils.openURL(getContext(), url); } @Override public void onEmailClick(final String emailId) { Utils.openEmailAddress(getContext(), emailId); } private void openPostDialog(@NonNull final Media feedModel, final int position) { if (opening) return; final User user = feedModel.getUser(); if (user == null) return; if (TextUtils.isEmpty(user.getUsername())) { opening = true; final String code = feedModel.getCode(); if (code == null) return; graphQLRepository.fetchPost( code, CoroutineUtilsKt.getContinuation((media, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { opening = false; if (throwable != null) { Log.e(TAG, "Error", throwable); return; } if (media == null) return; openPostDialog(media, position); })) ); return; } opening = true; try { final NavDirections action = LocationFragmentDirections.actionToPost(feedModel, position); NavHostFragment.findNavController(LocationFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "openPostDialog: ", e); } opening = false; } }; private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { @Override public void onSelectionStart() { if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } if (actionMode == null) { actionMode = fragmentActivity.startActionMode(multiSelectAction); } } @Override public void onSelectionChange(final Set selectedFeedModels) { final String title = getString(R.string.number_selected, selectedFeedModels.size()); if (actionMode != null) { actionMode.setTitle(title); } LocationFragment.this.selectedFeedModels = selectedFeedModels; } @Override public void onSelectionEnd() { if (onBackPressedCallback.isEnabled()) { onBackPressedCallback.setEnabled(false); onBackPressedCallback.remove(); } if (actionMode != null) { actionMode.finish(); actionMode = null; } } }; private final ServiceCallback cb = new ServiceCallback() { @Override public void onSuccess(final Location result) { locationModel = result; binding.swipeRefreshLayout.setRefreshing(false); setupLocationDetails(); } @Override public void onFailure(final Throwable t) { setupLocationDetails(); } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) requireActivity(); final String cookie = settingsHelper.getString(Constants.COOKIE); isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; locationService = isLoggedIn ? LocationService.getInstance() : null; // storiesRepository = StoriesRepository.Companion.getInstance(); graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); setHasOptionsMenu(true); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentLocationBinding.inflate(inflater, container, false); root = binding.getRoot(); locationDetailsBinding = binding.header; return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; binding.swipeRefreshLayout.setOnRefreshListener(this); init(); shouldRefresh = false; } @Override public void onRefresh() { binding.posts.refresh(); } @Override public void onResume() { super.onResume(); fragmentActivity.setToolbar(binding.toolbar, this); setTitle(); } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.topic_posts_menu, menu); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (item.getItemId() == R.id.layout) { showPostsLayoutPreferences(); return true; } return super.onOptionsItemSelected(item); } @Override public void onStop() { super.onStop(); fragmentActivity.resetToolbar(this); } private void init() { if (getArguments() == null) return; final LocationFragmentArgs fragmentArgs = LocationFragmentArgs.fromBundle(getArguments()); locationId = fragmentArgs.getLocationId(); locationDetailsBinding.favChip.setVisibility(View.GONE); locationDetailsBinding.btnMap.setVisibility(View.GONE); setTitle(); fetchLocationModel(); } private void setupPosts() { binding.posts.setViewModelStoreOwner(this) .setLifeCycleOwner(this) .setPostFetchService(new LocationPostFetchService(locationModel, isLoggedIn)) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) .setFeedItemCallback(feedItemCallback) .setSelectionModeCallback(selectionModeCallback) .init(); // binding.posts.addOnScrollListener(new RecyclerView.OnScrollListener() { // @Override // public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { // super.onScrolled(recyclerView, dx, dy); // final boolean canScrollVertically = recyclerView.canScrollVertically(-1); // final MotionScene.Transition transition = root.getTransition(R.id.transition); // if (transition != null) { // transition.setEnable(!canScrollVertically); // } // } // }); } private void fetchLocationModel() { binding.swipeRefreshLayout.setRefreshing(true); if (isLoggedIn) locationService.fetch(locationId, cb); else graphQLRepository.fetchLocation( locationId, CoroutineUtilsKt.getContinuation((location, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { cb.onFailure(throwable); return; } cb.onSuccess(location); })) ); } private void setupLocationDetails() { if (locationModel == null) { try { Toast.makeText(getContext(), R.string.error_loading_location, Toast.LENGTH_SHORT).show(); binding.swipeRefreshLayout.setEnabled(false); } catch (Exception ignored) {} return; } setTitle(); setupPosts(); // fetchStories(); final long locationId = locationModel.getPk(); // binding.swipeRefreshLayout.setRefreshing(true); locationDetailsBinding.mainLocationImage.setImageURI("res:/" + R.drawable.ic_location); // final String postCount = String.valueOf(locationModel.getChildCommentCount()); // final SpannableStringBuilder span = new SpannableStringBuilder(getResources().getQuantityString(R.plurals.main_posts_count_inline, // locationModel.getPostCount() > 2000000000L // ? 2000000000 // : locationModel.getPostCount().intValue(), // postCount)); // span.setSpan(new RelativeSizeSpan(1.2f), 0, postCount.length(), 0); // span.setSpan(new StyleSpan(Typeface.BOLD), 0, postCount.length(), 0); // locationDetailsBinding.mainLocPostCount.setText(span); // locationDetailsBinding.mainLocPostCount.setVisibility(View.VISIBLE); locationDetailsBinding.locationFullName.setText(locationModel.getName()); CharSequence biography = locationModel.getAddress() + "\n" + locationModel.getCity(); // binding.locationBiography.setCaptionIsExpandable(true); // binding.locationBiography.setCaptionIsExpanded(true); final Context context = getContext(); if (context == null) return; if (TextUtils.isEmpty(biography)) { locationDetailsBinding.locationBiography.setVisibility(View.GONE); } else { locationDetailsBinding.locationBiography.setVisibility(View.VISIBLE); locationDetailsBinding.locationBiography.setText(biography); // locationDetailsBinding.locationBiography.addOnHashtagListener(autoLinkItem -> { // final NavController navController = NavHostFragment.findNavController(this); // final Bundle bundle = new Bundle(); // final String originalText = autoLinkItem.getOriginalText().trim(); // bundle.putString(ARG_HASHTAG, originalText); // navController.navigate(R.id.action_global_hashTagFragment, bundle); // }); // locationDetailsBinding.locationBiography.addOnMentionClickListener(autoLinkItem -> { // final String originalText = autoLinkItem.getOriginalText().trim(); // navigateToProfile(originalText); // }); // locationDetailsBinding.locationBiography.addOnEmailClickListener(autoLinkItem -> Utils.openEmailAddress(context, // autoLinkItem.getOriginalText() // .trim())); // locationDetailsBinding.locationBiography // .addOnURLClickListener(autoLinkItem -> Utils.openURL(context, autoLinkItem.getOriginalText().trim())); locationDetailsBinding.locationBiography.setOnLongClickListener(v -> { Utils.copyText(context, biography); return true; }); } if (!locationModel.getGeo().startsWith("geo:0.0,0.0?z=17")) { locationDetailsBinding.btnMap.setVisibility(View.VISIBLE); locationDetailsBinding.btnMap.setOnClickListener(v -> { try { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse(locationModel.getGeo())); startActivity(intent); } catch (ActivityNotFoundException e) { Toast.makeText(context, R.string.no_external_map_app, Toast.LENGTH_LONG).show(); Log.e(TAG, "setupLocationDetails: ", e); } catch (Exception e) { Log.e(TAG, "setupLocationDetails: ", e); } }); } else { locationDetailsBinding.btnMap.setVisibility(View.GONE); locationDetailsBinding.btnMap.setOnClickListener(null); } final FavoriteRepository favoriteRepository = FavoriteRepository.Companion.getInstance(context); locationDetailsBinding.favChip.setVisibility(View.VISIBLE); favoriteRepository.getFavorite( String.valueOf(locationId), FavoriteType.LOCATION, CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null || favorite == null) { locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); locationDetailsBinding.favChip.setText(R.string.add_to_favorites); Log.e(TAG, "setupLocationDetails: ", throwable); return; } locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); locationDetailsBinding.favChip.setText(R.string.favorite_short); favoriteRepository.insertOrUpdateFavorite( new Favorite( favorite.getId(), String.valueOf(locationId), FavoriteType.LOCATION, locationModel.getName(), "res:/" + R.drawable.ic_location, favorite.getDateAdded() ), CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "onSuccess: ", throwable1); } }), Dispatchers.getIO()) ); }), Dispatchers.getIO()) ); locationDetailsBinding.favChip.setOnClickListener(v -> favoriteRepository.getFavorite( String.valueOf(locationId), FavoriteType.LOCATION, CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "setupLocationDetails: ", throwable); return; } if (favorite == null) { favoriteRepository.insertOrUpdateFavorite( new Favorite( 0, String.valueOf(locationId), FavoriteType.LOCATION, locationModel.getName(), "res:/" + R.drawable.ic_location, LocalDateTime.now() ), CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "onDataNotAvailable: ", throwable1); return; } locationDetailsBinding.favChip.setText(R.string.favorite_short); locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); showSnackbar(getString(R.string.added_to_favs)); }), Dispatchers.getIO()) ); return; } favoriteRepository.deleteFavorite( String.valueOf(locationId), FavoriteType.LOCATION, CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "onSuccess: ", throwable1); return; } locationDetailsBinding.favChip.setText(R.string.add_to_favorites); locationDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); showSnackbar(getString(R.string.removed_from_favs)); }), Dispatchers.getIO()) ); }), Dispatchers.getIO()) )); } private void showSnackbar(final String message) { @SuppressLint("ShowToast") final Snackbar snackbar = Snackbar.make(root, message, BaseTransientBottomBar.LENGTH_LONG); snackbar.setAction(R.string.ok, v1 -> snackbar.dismiss()) .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) .setAnchorView(fragmentActivity.getBottomNavView()) .show(); } private void setTitle() { final ActionBar actionBar = fragmentActivity.getSupportActionBar(); if (actionBar != null && locationModel != null) { actionBar.setTitle(locationModel.getName()); } } private void updateSwipeRefreshState() { AppExecutors.INSTANCE.getMainThread().execute(() -> binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching())); } private void navigateToProfile(final String username) { try { final NavDirections action = LocationFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "navigateToProfile: ", e); } } private void showPostsLayoutPreferences() { final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( Constants.PREF_LOCATION_POSTS_LAYOUT, preferences -> { layoutPreferences = preferences; new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); }); fragment.show(getChildFragmentManager(), "posts_layout_preferences"); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/NotificationsViewerFragment.java ================================================ package awais.instagrabber.fragments; import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; import android.text.SpannableString; import android.text.Spanned; import android.text.style.RelativeSizeSpan; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.NotificationManagerCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.NotificationsAdapter; import awais.instagrabber.adapters.NotificationsAdapter.OnNotificationClickListener; import awais.instagrabber.databinding.FragmentNotificationsViewerBinding; import awais.instagrabber.models.enums.NotificationType; import awais.instagrabber.repositories.requests.StoryViewerOptions; import awais.instagrabber.repositories.responses.notification.Notification; import awais.instagrabber.repositories.responses.notification.NotificationArgs; import awais.instagrabber.repositories.responses.notification.NotificationImage; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.NotificationViewModel; import awais.instagrabber.webservices.FriendshipRepository; import awais.instagrabber.webservices.MediaRepository; import awais.instagrabber.webservices.NewsService; import awais.instagrabber.webservices.ServiceCallback; import kotlinx.coroutines.Dispatchers; public final class NotificationsViewerFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = "NotificationsViewer"; private AppCompatActivity fragmentActivity; private FragmentNotificationsViewerBinding binding; private SwipeRefreshLayout root; private boolean shouldRefresh = true; private NotificationViewModel notificationViewModel; private FriendshipRepository friendshipRepository; private MediaRepository mediaRepository; private NewsService newsService; private String csrfToken, deviceUuid; private String type; private long targetId; private Context context; private long userId; private final ServiceCallback> cb = new ServiceCallback>() { @Override public void onSuccess(final List notificationModels) { binding.swipeRefreshLayout.setRefreshing(false); notificationViewModel.getList().postValue(notificationModels); } @Override public void onFailure(final Throwable t) { try { binding.swipeRefreshLayout.setRefreshing(false); Toast.makeText(getContext(), t.getMessage(), Toast.LENGTH_SHORT).show(); } catch (Throwable ignored) {} } }; private final OnNotificationClickListener clickListener = new OnNotificationClickListener() { @Override public void onProfileClick(final String username) { openProfile(username); } @Override public void onPreviewClick(final Notification model) { final NotificationImage notificationImage = model.getArgs().getMedia().get(0); final long mediaId = Long.parseLong(notificationImage.getId().split("_")[0]); if (model.getType() == NotificationType.RESPONDED_STORY) { final StoryViewerOptions options = StoryViewerOptions.forStory( mediaId, model.getArgs().getUsername() ); try { final NavDirections action = NotificationsViewerFragmentDirections.actionToStory(options); NavHostFragment.findNavController(NotificationsViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onPreviewClick: ", e); } } else { final AlertDialog alertDialog = new AlertDialog.Builder(context) .setCancelable(false) .setView(R.layout.dialog_opening_post) .create(); alertDialog.show(); mediaRepository.fetch( mediaId, CoroutineUtilsKt.getContinuation((media, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { alertDialog.dismiss(); Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); return; } try { final NavDirections action = NotificationsViewerFragmentDirections.actionToPost(media, 0); NavHostFragment.findNavController(NotificationsViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onSuccess: ", e); } finally { alertDialog.dismiss(); } }), Dispatchers.getIO()) ); } } @Override public void onNotificationClick(final Notification model) { if (model == null) return; final NotificationArgs args = model.getArgs(); final String username = args.getUsername(); if (model.getType() == NotificationType.FOLLOW || model.getType() == NotificationType.AYML) { openProfile(username); } else { final SpannableString title = new SpannableString(username + (TextUtils.isEmpty(args.getText()) ? "" : (":\n" + args.getText()))); title.setSpan(new RelativeSizeSpan(1.23f), 0, username.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); String[] commentDialogList; if (model.getType() == NotificationType.RESPONDED_STORY) { commentDialogList = new String[]{ getString(R.string.open_profile), getString(R.string.view_story) }; } else if (args.getMedia() != null) { commentDialogList = new String[]{ getString(R.string.open_profile), getString(R.string.view_post) }; } else if (model.getType() == NotificationType.REQUEST) { commentDialogList = new String[]{ getString(R.string.open_profile), getString(R.string.request_approve), getString(R.string.request_reject) }; } else commentDialogList = null; // shouldn't happen final Context context = getContext(); if (context == null) return; final DialogInterface.OnClickListener profileDialogListener = (dialog, which) -> { switch (which) { case 0: openProfile(username); break; case 1: if (model.getType() == NotificationType.REQUEST) { friendshipRepository.approve( csrfToken, userId, deviceUuid, args.getUserId(), CoroutineUtilsKt.getContinuation( (response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "approve: onFailure: ", throwable); return; } onRefresh(); }), Dispatchers.getIO() ) ); return; } clickListener.onPreviewClick(model); break; case 2: friendshipRepository.ignore( csrfToken, userId, deviceUuid, args.getUserId(), CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "approve: onFailure: ", throwable); return; } onRefresh(); }), Dispatchers.getIO()) ); break; } }; new AlertDialog.Builder(context) .setTitle(title) .setItems(commentDialogList, profileDialogListener) .setNegativeButton(R.string.cancel, null) .show(); } } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (AppCompatActivity) requireActivity(); context = getContext(); if (context == null) return; NotificationManagerCompat.from(context.getApplicationContext()).cancel(Constants.ACTIVITY_NOTIFICATION_ID); final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); if (TextUtils.isEmpty(cookie)) { Toast.makeText(context, R.string.activity_notloggedin, Toast.LENGTH_SHORT).show(); } userId = CookieUtils.getUserIdFromCookie(cookie); deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); friendshipRepository = FriendshipRepository.Companion.getInstance(); mediaRepository = MediaRepository.Companion.getInstance(); newsService = NewsService.getInstance(); } @NonNull @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentNotificationsViewerBinding.inflate(getLayoutInflater()); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; init(); shouldRefresh = false; } private void init() { final NotificationsViewerFragmentArgs fragmentArgs = NotificationsViewerFragmentArgs.fromBundle(getArguments()); type = fragmentArgs.getType(); targetId = fragmentArgs.getTargetId(); final Context context = getContext(); CookieUtils.setupCookies(Utils.settingsHelper.getString(Constants.COOKIE)); binding.swipeRefreshLayout.setOnRefreshListener(this); notificationViewModel = new ViewModelProvider(this).get(NotificationViewModel.class); final NotificationsAdapter adapter = new NotificationsAdapter(clickListener); binding.rvComments.setLayoutManager(new LinearLayoutManager(context)); binding.rvComments.setAdapter(adapter); notificationViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); onRefresh(); } @Override public void onRefresh() { binding.swipeRefreshLayout.setRefreshing(true); final ActionBar actionBar = fragmentActivity.getSupportActionBar(); switch (type) { case "notif": if (actionBar != null) actionBar.setTitle(R.string.action_notif); newsService.fetchAppInbox(true, cb); break; case "ayml": if (actionBar != null) actionBar.setTitle(R.string.action_ayml); newsService.fetchSuggestions(csrfToken, deviceUuid, cb); break; case "chaining": if (actionBar != null) actionBar.setTitle(R.string.action_ayml); newsService.fetchChaining(targetId, cb); break; } } private void openProfile(final String username) { try { final NavDirections action = NotificationsViewerFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "openProfile: ", e); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/PostViewV2Fragment.java ================================================ package awais.instagrabber.fragments; import android.content.Context; import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.Resources; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.view.ContextThemeWrapper; import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.TooltipCompat; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavBackStackEntry; import androidx.navigation.NavController; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.RecyclerView; import androidx.transition.TransitionManager; import androidx.viewpager2.widget.ViewPager2; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; import com.facebook.drawee.interfaces.DraweeController; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import com.google.android.exoplayer2.ui.StyledPlayerView; import com.google.android.material.appbar.CollapsingToolbarLayout; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.snackbar.BaseTransientBottomBar; import com.google.android.material.snackbar.Snackbar; import com.skydoves.balloon.ArrowOrientation; import com.skydoves.balloon.ArrowPositionRules; import com.skydoves.balloon.Balloon; import com.skydoves.balloon.BalloonAnimation; import com.skydoves.balloon.BalloonHighlightAnimation; import com.skydoves.balloon.BalloonSizeSpec; import com.skydoves.balloon.overlay.BalloonOverlayAnimation; import com.skydoves.balloon.overlay.BalloonOverlayCircle; import java.io.Serializable; import java.util.List; import java.util.Set; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.SliderCallbackAdapter; import awais.instagrabber.adapters.SliderItemsAdapter; import awais.instagrabber.adapters.viewholder.SliderVideoViewHolder; import awais.instagrabber.customviews.VerticalImageSpan; import awais.instagrabber.customviews.VideoPlayerCallbackAdapter; import awais.instagrabber.customviews.VideoPlayerViewHelper; import awais.instagrabber.customviews.drawee.AnimatedZoomableController; import awais.instagrabber.customviews.drawee.DoubleTapGestureListener; import awais.instagrabber.customviews.drawee.ZoomableController; import awais.instagrabber.customviews.drawee.ZoomableDraweeView; import awais.instagrabber.databinding.DialogPostViewBinding; import awais.instagrabber.databinding.LayoutPostViewBottomBinding; import awais.instagrabber.databinding.LayoutVideoPlayerWithThumbnailBinding; import awais.instagrabber.dialogs.EditTextDialogFragment; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.models.Resource; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Caption; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.MediaCandidate; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.NullSafePair; import awais.instagrabber.utils.NumberUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.PostViewV2ViewModel; import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_SHOWN_COUNT_TOOLTIP; public class PostViewV2Fragment extends Fragment implements EditTextDialogFragment.EditTextDialogFragmentCallback { private static final String TAG = "PostViewV2Fragment"; // private static final int DETAILS_HIDE_DELAY_MILLIS = 2000; public static final String ARG_MEDIA = "media"; public static final String ARG_SLIDER_POSITION = "position"; private DialogPostViewBinding binding; private Context context; private boolean detailsVisible = true; // private boolean video; private VideoPlayerViewHelper videoPlayerViewHelper; private SliderItemsAdapter sliderItemsAdapter; private int sliderPosition = -1; private PostViewV2ViewModel viewModel; private PopupMenu optionsPopup; private EditTextDialogFragment editTextDialogFragment; private boolean wasDeleted; private MutableLiveData backStackSavedStateCollectionLiveData; private MutableLiveData backStackSavedStateResultLiveData; private OnDeleteListener onDeleteListener; @Nullable private ViewPager2 sliderParent; private LayoutPostViewBottomBinding bottom; private View postView; private int originalHeight; private boolean isInFullScreenMode; private StyledPlayerView playerView; private int playerViewOriginalHeight; private Drawable originalRootBackground; private ColorStateList originalLikeColorStateList; private ColorStateList originalSaveColorStateList; private WindowInsetsControllerCompat controller; private final Observer backStackSavedStateObserver = result -> { if (result == null) return; if (result instanceof String) { final String collection = (String) result; handleSaveUnsaveResourceLiveData(viewModel.toggleSave(collection, viewModel.getMedia().getHasViewerSaved())); } else if ((result instanceof RankedRecipient)) { // Log.d(TAG, "result: " + result); final Context context = getContext(); if (context != null) { Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show(); } viewModel.shareDm((RankedRecipient) result, sliderPosition); } else if ((result instanceof Set)) { try { // Log.d(TAG, "result: " + result); final Context context = getContext(); if (context != null) { Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show(); } //noinspection unchecked viewModel.shareDm((Set) result, sliderPosition); } catch (Exception e) { Log.e(TAG, "share: ", e); } } // clear result backStackSavedStateCollectionLiveData.postValue(null); backStackSavedStateResultLiveData.postValue(null); }; public void setOnDeleteListener(final OnDeleteListener onDeleteListener) { if (onDeleteListener == null) return; this.onDeleteListener = onDeleteListener; } public interface OnDeleteListener { void onDelete(); } // default constructor for fragment manager public PostViewV2Fragment() {} @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); viewModel = new ViewModelProvider(this).get(PostViewV2ViewModel.class); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = DialogPostViewBinding.inflate(inflater, container, false); bottom = LayoutPostViewBottomBinding.bind(binding.getRoot()); final MainActivity activity = (MainActivity) getActivity(); if (activity == null) return null; controller = new WindowInsetsControllerCompat(activity.getWindow(), activity.getRootView()); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { // postponeEnterTransition(); init(); } @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); this.context = context; } @Override public void onPause() { super.onPause(); // wasPaused = true; if (Utils.settingsHelper.getBoolean(PreferenceKeys.PLAY_IN_BACKGROUND)) return; final Media media = viewModel.getMedia(); if (media.getType() == null) return; switch (media.getType()) { case MEDIA_TYPE_VIDEO: if (videoPlayerViewHelper != null) { videoPlayerViewHelper.pause(); } return; case MEDIA_TYPE_SLIDER: if (sliderItemsAdapter != null) { pauseSliderPlayer(); } default: } } @Override public void onResume() { super.onResume(); final NavController navController = NavHostFragment.findNavController(this); final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry(); if (backStackEntry != null) { backStackSavedStateCollectionLiveData = backStackEntry.getSavedStateHandle().getLiveData("collection"); backStackSavedStateCollectionLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); backStackSavedStateResultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); backStackSavedStateResultLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); } } @Override public void onDestroyView() { super.onDestroyView(); showSystemUI(); final Media media = viewModel.getMedia(); if (media.getType() == null) return; switch (media.getType()) { case MEDIA_TYPE_VIDEO: if (videoPlayerViewHelper != null) { videoPlayerViewHelper.releasePlayer(); } return; case MEDIA_TYPE_SLIDER: if (sliderItemsAdapter != null) { releaseAllSliderPlayers(); } default: } } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); final Media media = viewModel.getMedia(); if (media.getType() == MediaItemType.MEDIA_TYPE_SLIDER) { outState.putInt(ARG_SLIDER_POSITION, sliderPosition); } } @Override public void onPrimaryNavigationFragmentChanged(final boolean isPrimaryNavigationFragment) { if (!isPrimaryNavigationFragment) { final Media media = viewModel.getMedia(); switch (media.getType()) { case MEDIA_TYPE_VIDEO: if (videoPlayerViewHelper != null) { videoPlayerViewHelper.pause(); } return; case MEDIA_TYPE_SLIDER: if (sliderItemsAdapter != null) { pauseSliderPlayer(); } default: } } } private void init() { final Bundle arguments = getArguments(); if (arguments == null) { // dismiss(); return; } final Serializable feedModelSerializable = arguments.getSerializable(ARG_MEDIA); if (feedModelSerializable == null) { Log.e(TAG, "onCreate: feedModelSerializable is null"); // dismiss(); return; } if (!(feedModelSerializable instanceof Media)) { // dismiss(); return; } final Media media = (Media) feedModelSerializable; if (media.getType() == MediaItemType.MEDIA_TYPE_SLIDER && sliderPosition == -1) { sliderPosition = arguments.getInt(ARG_SLIDER_POSITION, 0); } viewModel.setMedia(media); // if (!wasPaused && (sharedProfilePicElement != null || sharedMainPostElement != null)) { // binding.getRoot().getBackground().mutate().setAlpha(0); // } // setProfilePicSharedElement(); // setupCaptionBottomSheet(); setupCommonActions(); setObservers(); } private void setObservers() { viewModel.getUser().observe(getViewLifecycleOwner(), user -> { if (user == null) { binding.profilePic.setVisibility(View.GONE); binding.title.setVisibility(View.GONE); binding.subtitle.setVisibility(View.GONE); return; } binding.profilePic.setVisibility(View.VISIBLE); binding.title.setVisibility(View.VISIBLE); binding.subtitle.setVisibility(View.VISIBLE); binding.getRoot().post(() -> setupProfilePic(user)); binding.getRoot().post(() -> setupTitles(user)); }); viewModel.getCaption().observe(getViewLifecycleOwner(), caption -> binding.getRoot().post(() -> setupCaption(caption))); viewModel.getLocation().observe(getViewLifecycleOwner(), location -> binding.getRoot().post(() -> setupLocation(location))); viewModel.getDate().observe(getViewLifecycleOwner(), date -> binding.getRoot().post(() -> { if (date == null) { bottom.date.setVisibility(View.GONE); return; } bottom.date.setVisibility(View.VISIBLE); bottom.date.setText(date); })); viewModel.getLikeCount().observe(getViewLifecycleOwner(), count -> { bottom.likesCount.setNumber(getSafeCount(count)); binding.getRoot().postDelayed(() -> bottom.likesCount.setAnimateChanges(true), 1000); if (count > 1000 && !Utils.settingsHelper.getBoolean(PREF_SHOWN_COUNT_TOOLTIP)) { binding.getRoot().postDelayed(this::showCountTooltip, 1000); } }); if (!viewModel.getMedia().getCommentsDisabled()) { viewModel.getCommentCount().observe(getViewLifecycleOwner(), count -> { bottom.commentsCount.setNumber(getSafeCount(count)); binding.getRoot().postDelayed(() -> bottom.commentsCount.setAnimateChanges(true), 1000); }); } viewModel.getViewCount().observe(getViewLifecycleOwner(), count -> { if (count == null) { bottom.viewsCount.setVisibility(View.GONE); return; } bottom.viewsCount.setVisibility(View.VISIBLE); final long safeCount = getSafeCount(count); final String viewString = getResources().getQuantityString(R.plurals.views_count, (int) safeCount, safeCount); bottom.viewsCount.setText(viewString); }); viewModel.getType().observe(getViewLifecycleOwner(), this::setupPostTypeLayout); viewModel.getLiked().observe(getViewLifecycleOwner(), this::setLikedResources); viewModel.getSaved().observe(getViewLifecycleOwner(), this::setSavedResources); viewModel.getOptions().observe(getViewLifecycleOwner(), options -> binding.getRoot().post(() -> { setupOptions(options != null && !options.isEmpty()); createOptionsPopupMenu(); })); } private void showCountTooltip() { final Context context = getContext(); if (context == null) return; final Rect rect = new Rect(); bottom.likesCount.getGlobalVisibleRect(rect); final Balloon balloon = new Balloon.Builder(context) .setArrowSize(8) .setArrowOrientation(ArrowOrientation.TOP) .setArrowPositionRules(ArrowPositionRules.ALIGN_ANCHOR) .setArrowPosition(0.5f) .setWidth(BalloonSizeSpec.WRAP) .setHeight(BalloonSizeSpec.WRAP) .setPadding(4) .setTextSize(16) .setAlpha(0.9f) .setBalloonAnimation(BalloonAnimation.ELASTIC) .setBalloonHighlightAnimation(BalloonHighlightAnimation.HEARTBEAT, 0) .setIsVisibleOverlay(true) .setOverlayColorResource(R.color.black_a50) .setOverlayShape(new BalloonOverlayCircle((float) Math.max( bottom.likesCount.getMeasuredWidth(), bottom.likesCount.getMeasuredHeight() ) / 2f)) .setBalloonOverlayAnimation(BalloonOverlayAnimation.FADE) .setLifecycleOwner(getViewLifecycleOwner()) .setTextResource(R.string.click_to_show_full) .setDismissWhenTouchOutside(false) .setDismissWhenOverlayClicked(false) .build(); balloon.showAlignBottom(bottom.likesCount); Utils.settingsHelper.putBoolean(PREF_SHOWN_COUNT_TOOLTIP, true); balloon.setOnBalloonOutsideTouchListener((view, motionEvent) -> { if (rect.contains((int) motionEvent.getRawX(), (int) motionEvent.getRawY())) { bottom.likesCount.setShowAbbreviation(false); } balloon.dismiss(); }); } @NonNull private Long getSafeCount(final Long count) { Long safeCount = count; if (count == null) { safeCount = 0L; } return safeCount; } private void setupCommonActions() { setupLike(); setupSave(); setupDownload(); setupComment(); setupShare(); } private void setupComment() { if (!viewModel.hasPk() || viewModel.getMedia().getCommentsDisabled()) { bottom.comment.setVisibility(View.GONE); // bottom.commentsCount.setVisibility(View.GONE); return; } bottom.comment.setVisibility(View.VISIBLE); bottom.comment.setOnClickListener(v -> { final Media media = viewModel.getMedia(); final User user = media.getUser(); if (user == null) return; final NavController navController = getNavController(); if (navController == null) return; try { final NavDirections action = PostViewV2FragmentDirections.actionToComments(media.getCode(), media.getPk(), user.getPk()); navController.navigate(action); } catch (Exception e) { Log.e(TAG, "setupComment: ", e); } }); TooltipCompat.setTooltipText(bottom.comment, getString(R.string.comment)); } private void setupDownload() { bottom.download.setOnClickListener(v -> DownloadUtils.showDownloadDialog(context, viewModel.getMedia(), sliderPosition, bottom.download)); TooltipCompat.setTooltipText(bottom.download, getString(R.string.action_download)); } private void setupLike() { originalLikeColorStateList = bottom.like.getIconTint(); final boolean likableMedia = viewModel.hasPk() /*&& viewModel.getMedia().isCommentLikesEnabled()*/; if (!likableMedia) { bottom.like.setVisibility(View.GONE); // bottom.likesCount.setVisibility(View.GONE); return; } if (!viewModel.isLoggedIn()) { bottom.like.setVisibility(View.GONE); return; } bottom.like.setOnClickListener(v -> { v.setEnabled(false); handleLikeUnlikeResourceLiveData(viewModel.toggleLike()); }); bottom.like.setOnLongClickListener(v -> { final NavController navController = getNavController(); if (navController != null && viewModel.isLoggedIn()) { try { final NavDirections action = PostViewV2FragmentDirections.actionToLikes(viewModel.getMedia().getPk(), false); navController.navigate(action); } catch (Exception e) { Log.e(TAG, "setupLike: ", e); } return true; } return true; }); } private void handleLikeUnlikeResourceLiveData(@NonNull final LiveData> resource) { resource.observe(getViewLifecycleOwner(), value -> { switch (value.status) { case SUCCESS: bottom.like.setEnabled(true); break; case ERROR: bottom.like.setEnabled(true); unsuccessfulLike(); break; case LOADING: bottom.like.setEnabled(false); break; } }); } private void unsuccessfulLike() { final int errorTextResId; final Media media = viewModel.getMedia(); if (!media.getHasLiked()) { Log.e(TAG, "like unsuccessful!"); errorTextResId = R.string.like_unsuccessful; } else { Log.e(TAG, "unlike unsuccessful!"); errorTextResId = R.string.unlike_unsuccessful; } final Snackbar snackbar = Snackbar.make(binding.getRoot(), errorTextResId, BaseTransientBottomBar.LENGTH_INDEFINITE); snackbar.setAction(R.string.ok, null); snackbar.show(); } private void setLikedResources(final boolean liked) { final int iconResource; final ColorStateList tintColorStateList; final Context context = getContext(); if (context == null) return; final Resources resources = context.getResources(); if (resources == null) return; if (liked) { iconResource = R.drawable.ic_like; tintColorStateList = ColorStateList.valueOf(resources.getColor(R.color.red_600)); } else { iconResource = R.drawable.ic_not_liked; tintColorStateList = originalLikeColorStateList != null ? originalLikeColorStateList : ColorStateList.valueOf(resources.getColor(R.color.white)); } bottom.like.setIconResource(iconResource); bottom.like.setIconTint(tintColorStateList); } private void setupSave() { originalSaveColorStateList = bottom.save.getIconTint(); if (!viewModel.isLoggedIn() || !viewModel.hasPk() || !viewModel.getMedia().getCanViewerSave()) { bottom.save.setVisibility(View.GONE); return; } bottom.save.setOnClickListener(v -> { bottom.save.setEnabled(false); handleSaveUnsaveResourceLiveData(viewModel.toggleSave()); }); bottom.save.setOnLongClickListener(v -> { try { final NavDirections action = PostViewV2FragmentDirections.actionToSavedCollections().setIsSaving(true); NavHostFragment.findNavController(this).navigate(action); return true; } catch (Exception e) { Log.e(TAG, "setupSave: ", e); } return false; }); } private void handleSaveUnsaveResourceLiveData(@NonNull final LiveData> resource) { resource.observe(getViewLifecycleOwner(), value -> { if (value == null) return; switch (value.status) { case SUCCESS: bottom.save.setEnabled(true); break; case ERROR: bottom.save.setEnabled(true); unsuccessfulSave(); break; case LOADING: bottom.save.setEnabled(false); break; } }); } private void unsuccessfulSave() { final int errorTextResId; final Media media = viewModel.getMedia(); if (!media.getHasViewerSaved()) { Log.e(TAG, "save unsuccessful!"); errorTextResId = R.string.save_unsuccessful; } else { Log.e(TAG, "save remove unsuccessful!"); errorTextResId = R.string.save_remove_unsuccessful; } final Snackbar snackbar = Snackbar.make(binding.getRoot(), errorTextResId, Snackbar.LENGTH_INDEFINITE); snackbar.setAction(R.string.ok, null); snackbar.show(); } private void setSavedResources(final boolean saved) { final int iconResource; final ColorStateList tintColorStateList; final Context context = getContext(); if (context == null) return; final Resources resources = context.getResources(); if (resources == null) return; if (saved) { iconResource = R.drawable.ic_bookmark; tintColorStateList = ColorStateList.valueOf(resources.getColor(R.color.blue_700)); } else { iconResource = R.drawable.ic_round_bookmark_border_24; tintColorStateList = originalSaveColorStateList != null ? originalSaveColorStateList : ColorStateList.valueOf(resources.getColor(R.color.white)); } bottom.save.setIconResource(iconResource); bottom.save.setIconTint(tintColorStateList); } private void setupProfilePic(final User user) { if (user == null) { binding.profilePic.setImageURI((String) null); return; } final String uri = user.getProfilePicUrl(); final DraweeController controller = Fresco .newDraweeControllerBuilder() .setUri(uri) .build(); binding.profilePic.setController(controller); binding.profilePic.setOnClickListener(v -> navigateToProfile("@" + user.getUsername())); } private void setupTitles(final User user) { if (user == null) { binding.title.setVisibility(View.GONE); binding.subtitle.setVisibility(View.GONE); return; } final String fullName = user.getFullName(); if (TextUtils.isEmpty(fullName)) { binding.subtitle.setVisibility(View.GONE); } else { binding.subtitle.setVisibility(View.VISIBLE); binding.subtitle.setText(fullName); } setUsername(user); binding.title.setOnClickListener(v -> navigateToProfile("@" + user.getUsername())); binding.subtitle.setOnClickListener(v -> navigateToProfile("@" + user.getUsername())); } private void setUsername(final User user) { final SpannableStringBuilder sb = new SpannableStringBuilder(user.getUsername()); final int drawableSize = Utils.convertDpToPx(24); if (user.isVerified()) { final Context context = getContext(); if (context == null) return; final Drawable verifiedDrawable = AppCompatResources.getDrawable(context, R.drawable.verified); VerticalImageSpan verifiedSpan = null; if (verifiedDrawable != null) { final Drawable drawable = verifiedDrawable.mutate(); drawable.setBounds(0, 0, drawableSize, drawableSize); verifiedSpan = new VerticalImageSpan(drawable); } try { if (verifiedSpan != null) { sb.append(" "); sb.setSpan(verifiedSpan, sb.length() - 1, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } catch (Exception e) { Log.e(TAG, "setUsername: ", e); } } binding.title.setText(sb); } private void setupCaption(final Caption caption) { if (caption == null || TextUtils.isEmpty(caption.getText())) { bottom.caption.setVisibility(View.GONE); bottom.translate.setVisibility(View.GONE); return; } final String postCaption = caption.getText(); bottom.caption.addOnHashtagListener(autoLinkItem -> { try { final String originalText = autoLinkItem.getOriginalText().trim(); final NavDirections action = PostViewV2FragmentDirections.actionToHashtag(originalText); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "setupCaption: ", e); } }); bottom.caption.addOnMentionClickListener(autoLinkItem -> { final String originalText = autoLinkItem.getOriginalText().trim(); navigateToProfile(originalText); }); bottom.caption.addOnEmailClickListener(autoLinkItem -> Utils.openEmailAddress(getContext(), autoLinkItem.getOriginalText().trim())); bottom.caption.addOnURLClickListener(autoLinkItem -> Utils.openURL(getContext(), autoLinkItem.getOriginalText().trim())); bottom.caption.setOnLongClickListener(v -> { final Context context = getContext(); if (context == null) return false; Utils.copyText(context, postCaption); return true; }); bottom.caption.setText(postCaption); bottom.translate.setOnClickListener(v -> handleTranslateCaptionResource(viewModel.translateCaption())); } private void handleTranslateCaptionResource(@NonNull final LiveData> data) { data.observe(getViewLifecycleOwner(), resource -> { if (resource == null) return; switch (resource.status) { case SUCCESS: bottom.translate.setVisibility(View.GONE); bottom.caption.setText(resource.data); break; case ERROR: bottom.translate.setEnabled(true); String message = resource.message; if (TextUtils.isEmpty(message)) { message = getString(R.string.downloader_unknown_error); } final Snackbar snackbar = Snackbar.make(binding.getRoot(), message, Snackbar.LENGTH_INDEFINITE); snackbar.setAction(R.string.ok, null); snackbar.show(); break; case LOADING: bottom.translate.setEnabled(false); break; } }); } private void setupLocation(final Location location) { if (location == null || !detailsVisible) { binding.location.setVisibility(View.GONE); return; } final String locationName = location.getName(); if (TextUtils.isEmpty(locationName)) return; binding.location.setText(locationName); binding.location.setVisibility(View.VISIBLE); binding.location.setOnClickListener(v -> { try { final NavController navController = getNavController(); if (navController == null) return; final NavDirections action = PostViewV2FragmentDirections.actionToLocation(location.getPk()); navController.navigate(action); } catch (Exception e) { Log.e(TAG, "setupLocation: ", e); } }); } private void setupShare() { if (!viewModel.hasPk()) { bottom.share.setVisibility(View.GONE); return; } bottom.share.setVisibility(View.VISIBLE); TooltipCompat.setTooltipText(bottom.share, getString(R.string.share)); bottom.share.setOnClickListener(v -> { final Media media = viewModel.getMedia(); final User profileModel = media.getUser(); if (profileModel == null) return; if (viewModel.isLoggedIn()) { final Context context = getContext(); if (context == null) return; final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, R.style.popupMenuStyle); final PopupMenu popupMenu = new PopupMenu(themeWrapper, bottom.share); final Menu menu = popupMenu.getMenu(); menu.add(0, R.id.share_dm, 0, R.string.share_via_dm); menu.add(0, R.id.share, 1, R.string.share_link); popupMenu.setOnMenuItemClickListener(item -> { final int itemId = item.getItemId(); if (itemId == R.id.share_dm) { if (profileModel.isPrivate()) Toast.makeText(context, R.string.share_private_post, Toast.LENGTH_SHORT).show(); final PostViewV2FragmentDirections.ActionToUserSearch actionGlobalUserSearch = PostViewV2FragmentDirections .actionToUserSearch() .setTitle(getString(R.string.share)) .setActionLabel(getString(R.string.send)) .setShowGroups(true) .setMultiple(true) .setSearchMode(UserSearchMode.RAVEN); final NavController navController = NavHostFragment.findNavController(PostViewV2Fragment.this); try { navController.navigate(actionGlobalUserSearch); } catch (Exception e) { Log.e(TAG, "setupShare: ", e); } return true; } else if (itemId == R.id.share) { shareLink(media, profileModel.isPrivate()); return true; } return false; }); popupMenu.show(); return; } shareLink(media, false); }); } private void shareLink(@NonNull final Media media, final boolean isPrivate) { final Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND); sharingIntent.setType("text/plain"); sharingIntent.putExtra(android.content.Intent.EXTRA_TITLE, getString(isPrivate ? R.string.share_private_post : R.string.share_public_post)); sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, "https://instagram.com/p/" + media.getCode()); startActivity(Intent.createChooser( sharingIntent, isPrivate ? getString(R.string.share_private_post) : getString(R.string.share_public_post) )); } private void setupPostTypeLayout(final MediaItemType type) { if (type == null) return; switch (type) { case MEDIA_TYPE_IMAGE: setupPostImage(); break; case MEDIA_TYPE_SLIDER: setupSlider(); break; case MEDIA_TYPE_VIDEO: setupVideo(); break; } } private void setupPostImage() { // binding.mediaCounter.setVisibility(View.GONE); final Context context = getContext(); if (context == null) return; final Resources resources = context.getResources(); if (resources == null) return; final Media media = viewModel.getMedia(); final String imageUrl = ResponseBodyUtils.getImageUrl(media); if (TextUtils.isEmpty(imageUrl)) return; final ZoomableDraweeView postImage = new ZoomableDraweeView(context); postView = postImage; final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(media.getOriginalHeight(), media.getOriginalWidth(), (int) (Utils.displayMetrics.heightPixels * 0.8), Utils.displayMetrics.widthPixels); originalHeight = widthHeight.second; final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, originalHeight); postImage.setLayoutParams(layoutParams); postImage.setHierarchy(new GenericDraweeHierarchyBuilder(resources) .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) .build()); postImage.setController(Fresco.newDraweeControllerBuilder() .setLowResImageRequest(ImageRequest.fromUri(ResponseBodyUtils.getThumbUrl(media))) .setImageRequest(ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageUrl)) .setLocalThumbnailPreviewsEnabled(true) .build()) .build()); final AnimatedZoomableController zoomableController = (AnimatedZoomableController) postImage.getZoomableController(); zoomableController.setMaxScaleFactor(3f); zoomableController.setGestureZoomEnabled(true); zoomableController.setEnabled(true); postImage.setZoomingEnabled(true); final DoubleTapGestureListener tapListener = new DoubleTapGestureListener(postImage) { @Override public boolean onSingleTapConfirmed(final MotionEvent e) { if (!isInFullScreenMode) { zoomableController.reset(); hideSystemUI(); } else { showSystemUI(); binding.getRoot().postDelayed(zoomableController::reset, 500); } return super.onSingleTapConfirmed(e); } }; postImage.setTapListener(tapListener); binding.postContainer.addView(postView); } private void setupSlider() { final Media media = viewModel.getMedia(); binding.mediaCounter.setVisibility(View.VISIBLE); final Context context = getContext(); if (context == null) return; sliderParent = new ViewPager2(context); final List carouselMedia = media.getCarouselMedia(); if (carouselMedia == null) return; final NullSafePair maxHW = carouselMedia .stream() .reduce(new NullSafePair<>(0, 0), (prev, m) -> { final int height = m.getOriginalHeight() > prev.first ? m.getOriginalHeight() : prev.first; final int width = m.getOriginalWidth() > prev.second ? m.getOriginalWidth() : prev.second; return new NullSafePair<>(height, width); }, (p1, p2) -> { final int height = p1.first > p2.first ? p1.first : p2.first; final int width = p1.second > p2.second ? p1.second : p2.second; return new NullSafePair<>(height, width); }); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(maxHW.first, maxHW.second, (int) (Utils.displayMetrics.heightPixels * 0.8), Utils.displayMetrics.widthPixels); originalHeight = widthHeight.second; final ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, originalHeight); sliderParent.setLayoutParams(layoutParams); postView = sliderParent; // binding.contentRoot.addView(sliderParent, 0); binding.postContainer.addView(postView); final boolean hasVideo = media.getCarouselMedia() .stream() .anyMatch(postChild -> postChild.getType() == MediaItemType.MEDIA_TYPE_VIDEO); if (hasVideo) { final View child = sliderParent.getChildAt(0); if (child instanceof RecyclerView) { ((RecyclerView) child).setItemViewCacheSize(media.getCarouselMedia().size()); ((RecyclerView) child).addRecyclerListener(holder -> { if (holder instanceof SliderVideoViewHolder) { ((SliderVideoViewHolder) holder).releasePlayer(); } }); } } sliderItemsAdapter = new SliderItemsAdapter(true, new SliderCallbackAdapter() { @Override public void onItemClicked(final int position, final Media media, final View view) { if (media == null || media.getType() != MediaItemType.MEDIA_TYPE_IMAGE || !(view instanceof ZoomableDraweeView)) { return; } final ZoomableController zoomableController = ((ZoomableDraweeView) view).getZoomableController(); if (!(zoomableController instanceof AnimatedZoomableController)) return; if (!isInFullScreenMode) { ((AnimatedZoomableController) zoomableController).reset(); hideSystemUI(); return; } showSystemUI(); binding.getRoot().postDelayed(((AnimatedZoomableController) zoomableController)::reset, 500); } @Override public void onPlayerPlay(final int position) { final FragmentActivity activity = getActivity(); if (activity == null) return; Utils.enabledKeepScreenOn(activity); // if (!detailsVisible || hasBeenToggled) return; // showPlayerControls(); } @Override public void onPlayerPause(final int position) { final FragmentActivity activity = getActivity(); if (activity == null) return; Utils.disableKeepScreenOn(activity); // if (detailsVisible || hasBeenToggled) return; // toggleDetails(); } @Override public void onPlayerRelease(final int position) { final FragmentActivity activity = getActivity(); if (activity == null) return; Utils.disableKeepScreenOn(activity); } @Override public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) { PostViewV2Fragment.this.playerView = playerView; if (isFullScreen) { hideSystemUI(); return; } showSystemUI(); } @Override public boolean isInFullScreen() { return isInFullScreenMode; } }); sliderParent.setAdapter(sliderItemsAdapter); if (sliderPosition >= 0 && sliderPosition < media.getCarouselMedia().size()) { sliderParent.setCurrentItem(sliderPosition); } sliderParent.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { int prevPosition = -1; @Override public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) { if (prevPosition != -1) { final View view = sliderParent.getChildAt(0); if (view instanceof RecyclerView) { pausePlayerAtPosition(prevPosition, (RecyclerView) view); pausePlayerAtPosition(position, (RecyclerView) view); } } if (positionOffset == 0) { prevPosition = position; } } @Override public void onPageSelected(final int position) { final int size = media.getCarouselMedia().size(); if (position < 0 || position >= size) return; sliderPosition = position; final String text = (position + 1) + "/" + size; binding.mediaCounter.setText(text); final Media childMedia = media.getCarouselMedia().get(position); // video = false; // if (childMedia.getType() == MediaItemType.MEDIA_TYPE_VIDEO) { // video = true; // viewModel.setViewCount(childMedia.getViewCount()); // return; // } // viewModel.setViewCount(null); } private void pausePlayerAtPosition(final int position, final RecyclerView view) { final RecyclerView.ViewHolder viewHolder = view.findViewHolderForAdapterPosition(position); if (viewHolder instanceof SliderVideoViewHolder) { ((SliderVideoViewHolder) viewHolder).pause(); } } }); final String text = "1/" + carouselMedia.size(); binding.mediaCounter.setText(text); sliderItemsAdapter.submitList(media.getCarouselMedia()); sliderParent.setCurrentItem(sliderPosition); } private void pauseSliderPlayer() { if (sliderParent == null) return; final int currentItem = sliderParent.getCurrentItem(); final View view = sliderParent.getChildAt(0); if (!(view instanceof RecyclerView)) return; final RecyclerView.ViewHolder viewHolder = ((RecyclerView) view).findViewHolderForAdapterPosition(currentItem); if (!(viewHolder instanceof SliderVideoViewHolder)) return; ((SliderVideoViewHolder) viewHolder).pause(); } private void releaseAllSliderPlayers() { if (sliderParent == null) return; final View view = sliderParent.getChildAt(0); if (!(view instanceof RecyclerView)) return; final int itemCount = sliderItemsAdapter.getItemCount(); for (int position = itemCount - 1; position >= 0; position--) { final RecyclerView.ViewHolder viewHolder = ((RecyclerView) view).findViewHolderForAdapterPosition(position); if (!(viewHolder instanceof SliderVideoViewHolder)) continue; ((SliderVideoViewHolder) viewHolder).releasePlayer(); } } private void setupVideo() { // video = true; final Media media = viewModel.getMedia(); binding.mediaCounter.setVisibility(View.GONE); final Context context = getContext(); if (context == null) return; final LayoutVideoPlayerWithThumbnailBinding videoPost = LayoutVideoPlayerWithThumbnailBinding .inflate(LayoutInflater.from(context), binding.contentRoot, false); final ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) videoPost.getRoot().getLayoutParams(); final NullSafePair widthHeight = NumberUtils.calculateWidthHeight(media.getOriginalHeight(), media.getOriginalWidth(), (int) (Utils.displayMetrics.heightPixels * 0.8), Utils.displayMetrics.widthPixels); layoutParams.width = ConstraintLayout.LayoutParams.MATCH_PARENT; originalHeight = widthHeight.second; layoutParams.height = originalHeight; postView = videoPost.getRoot(); binding.postContainer.addView(postView); // final GestureDetector gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { // @Override // public boolean onSingleTapConfirmed(final MotionEvent e) { // videoPost.playerView.performClick(); // return true; // } // }); // videoPost.playerView.setOnTouchListener((v, event) -> { // gestureDetector.onTouchEvent(event); // return true; // }); final float vol = Utils.settingsHelper.getBoolean(PreferenceKeys.MUTED_VIDEOS) ? 0f : 1f; final VideoPlayerViewHelper.VideoPlayerCallback videoPlayerCallback = new VideoPlayerCallbackAdapter() { @Override public void onThumbnailLoaded() { startPostponedEnterTransition(); } @Override public void onPlayerViewLoaded() { // binding.playerControls.getRoot().setVisibility(View.VISIBLE); final ViewGroup.LayoutParams layoutParams = videoPost.playerView.getLayoutParams(); final int requiredWidth = Utils.displayMetrics.widthPixels; final int resultingHeight = NumberUtils .getResultingHeight(requiredWidth, media.getOriginalHeight(), media.getOriginalWidth()); layoutParams.width = requiredWidth; layoutParams.height = resultingHeight; videoPost.playerView.requestLayout(); } @Override public void onPlay() { final FragmentActivity activity = getActivity(); if (activity == null) return; Utils.enabledKeepScreenOn(activity); // if (detailsVisible) { // new Handler().postDelayed(() -> toggleDetails(), DETAILS_HIDE_DELAY_MILLIS); // } } @Override public void onPause() { final FragmentActivity activity = getActivity(); if (activity == null) return; Utils.disableKeepScreenOn(activity); } @Override public void onRelease() { final FragmentActivity activity = getActivity(); if (activity == null) return; Utils.disableKeepScreenOn(activity); } @Override public void onFullScreenModeChanged(final boolean isFullScreen, final StyledPlayerView playerView) { PostViewV2Fragment.this.playerView = playerView; if (isFullScreen) { hideSystemUI(); return; } showSystemUI(); } }; final float aspectRatio = (float) media.getOriginalWidth() / media.getOriginalHeight(); String videoUrl = null; final List videoVersions = media.getVideoVersions(); if (videoVersions != null && !videoVersions.isEmpty()) { final MediaCandidate videoVersion = videoVersions.get(0); if (videoVersion != null) { videoUrl = videoVersion.getUrl(); } } if (videoUrl != null) { videoPlayerViewHelper = new VideoPlayerViewHelper( binding.getRoot().getContext(), videoPost, videoUrl, vol, aspectRatio, ResponseBodyUtils.getThumbUrl(media), true, videoPlayerCallback); } } private void setupOptions(final Boolean show) { if (!show) { binding.options.setVisibility(View.GONE); return; } binding.options.setVisibility(View.VISIBLE); binding.options.setOnClickListener(v -> { if (optionsPopup == null) return; optionsPopup.show(); }); } private void createOptionsPopupMenu() { if (optionsPopup == null) { final Context context = getContext(); if (context == null) return; final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, R.style.popupMenuStyle); optionsPopup = new PopupMenu(themeWrapper, binding.options); } else { optionsPopup.getMenu().clear(); } optionsPopup.getMenuInflater().inflate(R.menu.post_view_menu, optionsPopup.getMenu()); // final Menu menu = optionsPopup.getMenu(); // final int size = menu.size(); // for (int i = 0; i < size; i++) { // final MenuItem item = menu.getItem(i); // if (item == null) continue; // if (options.contains(item.getItemId())) continue; // menu.removeItem(item.getItemId()); // } optionsPopup.setOnMenuItemClickListener(item -> { int itemId = item.getItemId(); if (itemId == R.id.edit_caption) { showCaptionEditDialog(); return true; } if (itemId == R.id.delete) { item.setEnabled(false); final LiveData> resourceLiveData = viewModel.delete(); handleDeleteResource(resourceLiveData, item); } return true; }); } private void handleDeleteResource(final LiveData> resourceLiveData, final MenuItem item) { if (resourceLiveData == null) return; resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(final Resource resource) { try { switch (resource.status) { case SUCCESS: wasDeleted = true; if (onDeleteListener != null) { onDeleteListener.onDelete(); } break; case ERROR: if (item != null) { item.setEnabled(true); } final Snackbar snackbar = Snackbar.make(binding.getRoot(), R.string.delete_unsuccessful, Snackbar.LENGTH_INDEFINITE); snackbar.setAction(R.string.ok, null); snackbar.show(); break; case LOADING: if (item != null) { item.setEnabled(false); } break; } } finally { resourceLiveData.removeObserver(this); } } }); } private void showCaptionEditDialog() { final Caption caption = viewModel.getCaption().getValue(); final String captionText = caption != null ? caption.getText() : null; editTextDialogFragment = EditTextDialogFragment .newInstance(R.string.edit_caption, R.string.confirm, R.string.cancel, captionText); editTextDialogFragment.show(getChildFragmentManager(), "edit_caption"); } @Override public void onPositiveButtonClicked(final String caption) { handleEditCaptionResource(viewModel.updateCaption(caption)); if (editTextDialogFragment == null) return; editTextDialogFragment.dismiss(); editTextDialogFragment = null; } private void handleEditCaptionResource(final LiveData> updateCaption) { if (updateCaption == null) return; updateCaption.observe(getViewLifecycleOwner(), resource -> { final MenuItem item = optionsPopup.getMenu().findItem(R.id.edit_caption); switch (resource.status) { case SUCCESS: if (item != null) { item.setEnabled(true); } break; case ERROR: if (item != null) { item.setEnabled(true); } final Snackbar snackbar = Snackbar.make(binding.getRoot(), R.string.edit_unsuccessful, BaseTransientBottomBar.LENGTH_INDEFINITE); snackbar.setAction(R.string.ok, null); snackbar.show(); break; case LOADING: if (item != null) { item.setEnabled(false); } break; } }); } @Override public void onNegativeButtonClicked() { if (editTextDialogFragment == null) return; editTextDialogFragment.dismiss(); editTextDialogFragment = null; } private void toggleDetails() { // final boolean hasBeenToggled = true; final MainActivity activity = (MainActivity) getActivity(); if (activity == null) return; final Media media = viewModel.getMedia(); binding.getRoot().post(() -> { TransitionManager.beginDelayedTransition(binding.getRoot()); if (detailsVisible) { final Context context = getContext(); if (context == null) return; originalRootBackground = binding.getRoot().getBackground(); final Resources resources = context.getResources(); if (resources == null) return; final ColorDrawable colorDrawable = new ColorDrawable(resources.getColor(R.color.black)); binding.getRoot().setBackground(colorDrawable); if (postView != null) { // Make post match parent final int fullHeight = Utils.displayMetrics.heightPixels - Utils.getStatusBarHeight(context); postView.getLayoutParams().height = fullHeight; binding.postContainer.getLayoutParams().height = fullHeight; if (playerView != null) { playerViewOriginalHeight = playerView.getLayoutParams().height; playerView.getLayoutParams().height = fullHeight; } } final BottomNavigationView bottomNavView = activity.getBottomNavView(); bottomNavView.setVisibility(View.GONE); detailsVisible = false; if (media.getUser() != null) { binding.profilePic.setVisibility(View.GONE); binding.title.setVisibility(View.GONE); binding.subtitle.setVisibility(View.GONE); } if (media.getLocation() != null) { binding.location.setVisibility(View.GONE); } if (media.getCaption() != null && !TextUtils.isEmpty(media.getCaption().getText())) { bottom.caption.setVisibility(View.GONE); bottom.translate.setVisibility(View.GONE); } bottom.likesCount.setVisibility(View.GONE); bottom.commentsCount.setVisibility(View.GONE); bottom.date.setVisibility(View.GONE); bottom.comment.setVisibility(View.GONE); bottom.like.setVisibility(View.GONE); bottom.save.setVisibility(View.GONE); bottom.share.setVisibility(View.GONE); bottom.download.setVisibility(View.GONE); binding.mediaCounter.setVisibility(View.GONE); bottom.viewsCount.setVisibility(View.GONE); final List options = viewModel.getOptions().getValue(); if (options != null && !options.isEmpty()) { binding.options.setVisibility(View.GONE); } return; } if (originalRootBackground != null) { binding.getRoot().setBackground(originalRootBackground); } if (postView != null) { // Make post height back to original postView.getLayoutParams().height = originalHeight; binding.postContainer.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; if (playerView != null) { playerView.getLayoutParams().height = playerViewOriginalHeight; playerView = null; } } final BottomNavigationView bottomNavView = activity.getBottomNavView(); bottomNavView.setVisibility(View.VISIBLE); if (media.getUser() != null) { binding.profilePic.setVisibility(View.VISIBLE); binding.title.setVisibility(View.VISIBLE); binding.subtitle.setVisibility(View.VISIBLE); // binding.topBg.setVisibility(View.VISIBLE); } if (media.getLocation() != null) { binding.location.setVisibility(View.VISIBLE); } if (media.getCaption() != null && !TextUtils.isEmpty(media.getCaption().getText())) { bottom.caption.setVisibility(View.VISIBLE); bottom.translate.setVisibility(View.VISIBLE); } if (viewModel.hasPk()) { bottom.likesCount.setVisibility(View.VISIBLE); bottom.date.setVisibility(View.VISIBLE); // binding.captionParent.setVisibility(View.VISIBLE); // binding.captionToggle.setVisibility(View.VISIBLE); bottom.share.setVisibility(View.VISIBLE); } if (viewModel.hasPk() && !viewModel.getMedia().getCommentsDisabled()) { bottom.comment.setVisibility(View.VISIBLE); bottom.commentsCount.setVisibility(View.VISIBLE); } bottom.download.setVisibility(View.VISIBLE); final List options = viewModel.getOptions().getValue(); if (options != null && !options.isEmpty()) { binding.options.setVisibility(View.VISIBLE); } if (viewModel.isLoggedIn() && viewModel.hasPk()) { bottom.like.setVisibility(View.VISIBLE); bottom.save.setVisibility(View.VISIBLE); } // if (video) { if (media.getType() == MediaItemType.MEDIA_TYPE_VIDEO) { // binding.playerControlsToggle.setVisibility(View.VISIBLE); bottom.viewsCount.setVisibility(View.VISIBLE); } // if (wasControlsVisible) { // showPlayerControls(); // } if (media.getType() == MediaItemType.MEDIA_TYPE_SLIDER) { binding.mediaCounter.setVisibility(View.VISIBLE); } detailsVisible = true; }); } private void hideSystemUI() { if (detailsVisible) { toggleDetails(); } final MainActivity activity = (MainActivity) getActivity(); if (activity == null) return; final ActionBar actionBar = activity.getSupportActionBar(); if (actionBar != null) { actionBar.hide(); } final CollapsingToolbarLayout appbarLayout = activity.getCollapsingToolbarView(); appbarLayout.setVisibility(View.GONE); final Toolbar toolbar = activity.getToolbar(); toolbar.setVisibility(View.GONE); binding.getRoot().setPadding(binding.getRoot().getPaddingLeft(), binding.getRoot().getPaddingTop(), binding.getRoot().getPaddingRight(), 0); controller.hide(WindowInsetsCompat.Type.systemBars()); controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_SWIPE); isInFullScreenMode = true; } private void showSystemUI() { if (!detailsVisible) { toggleDetails(); } final MainActivity activity = (MainActivity) getActivity(); if (activity == null) return; final ActionBar actionBar = activity.getSupportActionBar(); if (actionBar != null) { actionBar.show(); } final CollapsingToolbarLayout appbarLayout = activity.getCollapsingToolbarView(); appbarLayout.setVisibility(View.VISIBLE); final Toolbar toolbar = activity.getToolbar(); toolbar.setVisibility(View.VISIBLE); final Context context = getContext(); if (context == null) return; binding.getRoot().setPadding(binding.getRoot().getPaddingLeft(), binding.getRoot().getPaddingTop(), binding.getRoot().getPaddingRight(), Utils.getActionBarHeight(context)); controller.show(WindowInsetsCompat.Type.systemBars()); WindowCompat.setDecorFitsSystemWindows(activity.getWindow(), false); isInFullScreenMode = false; } private void navigateToProfile(final String username) { final NavController navController = getNavController(); if (navController == null) return; final NavDirections actionToProfile = PostViewV2FragmentDirections.actionToProfile().setUsername(username); navController.navigate(actionToProfile); } @Nullable private NavController getNavController() { NavController navController = null; try { navController = NavHostFragment.findNavController(this); } catch (IllegalStateException e) { Log.e(TAG, "navigateToProfile", e); } return navController; } public boolean wasDeleted() { return wasDeleted; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/SavedCollectionsFragment.java ================================================ package awais.instagrabber.fragments; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.fragment.app.Fragment; import androidx.lifecycle.SavedStateHandle; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavBackStackEntry; import androidx.navigation.NavController; import androidx.navigation.NavDirections; import androidx.navigation.fragment.FragmentNavigator; import androidx.navigation.fragment.NavHostFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.SavedCollectionsAdapter; import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; import awais.instagrabber.databinding.FragmentSavedCollectionsBinding; import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.SavedCollectionsViewModel; import awais.instagrabber.webservices.ProfileRepository; import awais.instagrabber.webservices.ServiceCallback; import static awais.instagrabber.utils.Utils.settingsHelper; public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = SavedCollectionsFragment.class.getSimpleName(); public static boolean pleaseRefresh = false; private MainActivity fragmentActivity; private CoordinatorLayout root; private FragmentSavedCollectionsBinding binding; private SavedCollectionsViewModel savedCollectionsViewModel; private boolean shouldRefresh = true; private boolean isSaving; private ProfileRepository profileRepository; private SavedCollectionsAdapter adapter; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) requireActivity(); profileRepository = ProfileRepository.Companion.getInstance(); savedCollectionsViewModel = new ViewModelProvider(fragmentActivity).get(SavedCollectionsViewModel.class); setHasOptionsMenu(true); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentSavedCollectionsBinding.inflate(inflater, container, false); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { setupObservers(); if (!shouldRefresh) return; binding.swipeRefreshLayout.setOnRefreshListener(this); init(); shouldRefresh = false; } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.saved_collection_menu, menu); } @Override public void onResume() { super.onResume(); if (pleaseRefresh) onRefresh(); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (item.getItemId() == R.id.add) { final Context context = getContext(); final EditText input = new EditText(context); new AlertDialog.Builder(context) .setTitle(R.string.saved_create_collection) .setView(input) .setPositiveButton(R.string.confirm, (d, w) -> { final String cookie = settingsHelper.getString(Constants.COOKIE); profileRepository.createCollection( input.getText().toString(), settingsHelper.getString(Constants.DEVICE_UUID), CookieUtils.getUserIdFromCookie(cookie), CookieUtils.getCsrfTokenFromCookie(cookie), CoroutineUtilsKt.getContinuation((result, t) -> { if (t != null) { Log.e(TAG, "Error creating collection", t); Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); return; } onRefresh(); }) ); }) .setNegativeButton(R.string.cancel, null) .show(); return true; } return false; } private void init() { setupTopics(); fetchTopics(null); final SavedCollectionsFragmentArgs fragmentArgs = SavedCollectionsFragmentArgs.fromBundle(getArguments()); isSaving = fragmentArgs.getIsSaving(); } @Override public void onRefresh() { fetchTopics(null); } public void setupTopics() { binding.topicsRecyclerView.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(2))); adapter = new SavedCollectionsAdapter((topicCluster, root, cover, title, titleColor, backgroundColor) -> { final NavController navController = NavHostFragment.findNavController(this); if (isSaving) { setNavControllerResult(navController, topicCluster.getCollectionId()); navController.navigateUp(); } else { try { final FragmentNavigator.Extras.Builder builder = new FragmentNavigator.Extras.Builder() .addSharedElement(cover, "collection-" + topicCluster.getCollectionId()); final NavDirections action = SavedCollectionsFragmentDirections .actionToCollectionPosts(topicCluster, titleColor, backgroundColor); navController.navigate(action, builder.build()); } catch (Exception e) { Log.e(TAG, "setupTopics: ", e); } } }); binding.topicsRecyclerView.setAdapter(adapter); } private void setupObservers() { savedCollectionsViewModel.getList().observe(getViewLifecycleOwner(), list -> { if (adapter == null) return; adapter.submitList(list); }); } private void fetchTopics(final String maxId) { binding.swipeRefreshLayout.setRefreshing(true); profileRepository.fetchCollections(maxId, CoroutineUtilsKt.getContinuation((result, t) -> { if (t != null) { Log.e(TAG, "onFailure", t); binding.swipeRefreshLayout.setRefreshing(false); return; } if (result == null) return; savedCollectionsViewModel.getList().postValue(result.getItems()); binding.swipeRefreshLayout.setRefreshing(false); })); } private void setNavControllerResult(@NonNull final NavController navController, final String result) { final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); if (navBackStackEntry == null) return; final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); savedStateHandle.set("collection", result); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/SavedViewerFragment.java ================================================ package awais.instagrabber.fragments; import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.common.collect.ImmutableList; import java.util.Set; import awais.instagrabber.R; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.asyncs.SavedPostFetchService; import awais.instagrabber.customviews.PrimaryActionModeCallback; import awais.instagrabber.databinding.FragmentSavedBinding; import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; import awais.instagrabber.fragments.main.ProfileFragmentDirections; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.PostItemType; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import static awais.instagrabber.utils.Utils.settingsHelper; public final class SavedViewerFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = SavedViewerFragment.class.getSimpleName(); private FragmentSavedBinding binding; private String username; private long profileId; private ActionMode actionMode; private SwipeRefreshLayout root; private AppCompatActivity fragmentActivity; private boolean isLoggedIn, shouldRefresh = true; private PostItemType type; private Set selectedFeedModels; private PostsLayoutPreferences layoutPreferences; private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { binding.posts.endSelection(); } }; private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { @Override public void onDestroy(final ActionMode mode) { binding.posts.endSelection(); } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { if (item.getItemId() == R.id.action_download) { if (SavedViewerFragment.this.selectedFeedModels == null) return false; final Context context = getContext(); if (context == null) return false; DownloadUtils.download(context, ImmutableList.copyOf(SavedViewerFragment.this.selectedFeedModels)); binding.posts.endSelection(); } return false; } }); private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { @Override public void onPostClick(final Media feedModel) { openPostDialog(feedModel, -1); } @Override public void onSliderClick(final Media feedModel, final int position) { openPostDialog(feedModel, position); } @Override public void onCommentsClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; try { final NavDirections commentsAction = ProfileFragmentDirections.actionToComments( feedModel.getCode(), feedModel.getPk(), user.getPk() ); NavHostFragment.findNavController(SavedViewerFragment.this).navigate(commentsAction); } catch (Exception e) { Log.e(TAG, "onCommentsClick: ", e); } } @Override public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { final Context context = getContext(); if (context == null) return; DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); } @Override public void onHashtagClick(final String hashtag) { try { final NavDirections action = ProfileFragmentDirections.actionToHashtag(hashtag); NavHostFragment.findNavController(SavedViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onLocationClick(final Media feedModel) { final Location location = feedModel.getLocation(); if (location == null) return; try { final NavDirections action = ProfileFragmentDirections.actionToLocation(location.getPk()); NavHostFragment.findNavController(SavedViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onLocationClick: ", e); } } @Override public void onMentionClick(final String mention) { navigateToProfile(mention.trim()); } @Override public void onNameClick(final Media feedModel) { navigateToProfile("@" + feedModel.getUser().getUsername()); } @Override public void onProfilePicClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onURLClick(final String url) { Utils.openURL(getContext(), url); } @Override public void onEmailClick(final String emailId) { Utils.openEmailAddress(getContext(), emailId); } private void openPostDialog(final Media feedModel, final int position) { try { final NavDirections action = SavedViewerFragmentDirections.actionToPost(feedModel, position); NavHostFragment.findNavController(SavedViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "openPostDialog: ", e); } } }; private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { @Override public void onSelectionStart() { if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } if (actionMode == null) { actionMode = fragmentActivity.startActionMode(multiSelectAction); } } @Override public void onSelectionChange(final Set selectedFeedModels) { final String title = getString(R.string.number_selected, selectedFeedModels.size()); if (actionMode != null) { actionMode.setTitle(title); } SavedViewerFragment.this.selectedFeedModels = selectedFeedModels; } @Override public void onSelectionEnd() { if (onBackPressedCallback.isEnabled()) { onBackPressedCallback.setEnabled(false); onBackPressedCallback.remove(); } if (actionMode != null) { actionMode.finish(); actionMode = null; } } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (AppCompatActivity) getActivity(); setHasOptionsMenu(true); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { final String cookie = settingsHelper.getString(Constants.COOKIE); isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; if (root != null) { shouldRefresh = false; return root; } binding = FragmentSavedBinding.inflate(getLayoutInflater(), container, false); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; binding.swipeRefreshLayout.setOnRefreshListener(this); init(); shouldRefresh = false; } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.saved_viewer_menu, menu); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (item.getItemId() == R.id.layout) { showPostsLayoutPreferences(); return true; } return super.onOptionsItemSelected(item); } @Override public void onResume() { super.onResume(); setTitle(); } @Override public void onRefresh() { binding.posts.refresh(); } private void init() { final Bundle arguments = getArguments(); if (arguments == null) return; final SavedViewerFragmentArgs fragmentArgs = SavedViewerFragmentArgs.fromBundle(arguments); username = fragmentArgs.getUsername(); profileId = fragmentArgs.getProfileId(); type = fragmentArgs.getType(); layoutPreferences = Utils.getPostsLayoutPreferences(getPostsLayoutPreferenceKey()); setupPosts(); } private void setupPosts() { binding.posts.setViewModelStoreOwner(this) .setLifeCycleOwner(this) .setPostFetchService(new SavedPostFetchService(profileId, type, isLoggedIn, null)) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) .setFeedItemCallback(feedItemCallback) .setSelectionModeCallback(selectionModeCallback) .init(); binding.swipeRefreshLayout.setRefreshing(true); } @NonNull private String getPostsLayoutPreferenceKey() { switch (type) { case LIKED: return Constants.PREF_LIKED_POSTS_LAYOUT; case TAGGED: return Constants.PREF_TAGGED_POSTS_LAYOUT; case SAVED: default: return Constants.PREF_SAVED_POSTS_LAYOUT; } } private void setTitle() { final ActionBar actionBar = fragmentActivity.getSupportActionBar(); if (actionBar == null) return; final int titleRes; switch (type) { case LIKED: titleRes = R.string.liked; break; case TAGGED: titleRes = R.string.tagged; break; default: case SAVED: titleRes = R.string.saved; break; } actionBar.setTitle(titleRes); actionBar.setSubtitle(username); } private void updateSwipeRefreshState() { AppExecutors.INSTANCE.getMainThread().execute(() -> binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) ); } private void navigateToProfile(final String username) { try { final NavDirections action = SavedViewerFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "navigateToProfile: ", e); } } private void showPostsLayoutPreferences() { final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( getPostsLayoutPreferenceKey(), preferences -> { layoutPreferences = preferences; new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); }); fragment.show(getChildFragmentManager(), "posts_layout_preferences"); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/StoryListViewerFragment.java ================================================ package awais.instagrabber.fragments; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.SearchView; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.common.collect.Iterables; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import awais.instagrabber.R; import awais.instagrabber.adapters.FeedStoriesListAdapter; import awais.instagrabber.adapters.FeedStoriesListAdapter.OnFeedStoryClickListener; import awais.instagrabber.adapters.HighlightStoriesListAdapter; import awais.instagrabber.adapters.HighlightStoriesListAdapter.OnHighlightStoryClickListener; import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; import awais.instagrabber.databinding.FragmentStoryListViewerBinding; import awais.instagrabber.repositories.requests.StoryViewerOptions; import awais.instagrabber.repositories.responses.stories.ArchiveResponse; import awais.instagrabber.repositories.responses.stories.Story; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.viewmodels.ArchivesViewModel; import awais.instagrabber.viewmodels.FeedStoriesViewModel; import awais.instagrabber.webservices.ServiceCallback; import awais.instagrabber.webservices.StoriesRepository; import kotlinx.coroutines.Dispatchers; public final class StoryListViewerFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = "StoryListViewerFragment"; private AppCompatActivity fragmentActivity; private FragmentStoryListViewerBinding binding; private SwipeRefreshLayout root; private boolean shouldRefresh = true; private boolean firstRefresh = true; private FeedStoriesViewModel feedStoriesViewModel; private ArchivesViewModel archivesViewModel; private StoriesRepository storiesRepository; private Context context; private String type; private String endCursor = null; private FeedStoriesListAdapter adapter; private final OnFeedStoryClickListener clickListener = new OnFeedStoryClickListener() { @Override public void onFeedStoryClick(final Story model) { if (model == null) return; final List feedStoryModels = feedStoriesViewModel.getList().getValue(); if (feedStoryModels == null) return; final int position = Iterables.indexOf(feedStoryModels, feedStoryModel -> feedStoryModel != null && Objects.equals(feedStoryModel.getId(), model.getId())); try { final NavDirections action = StoryListViewerFragmentDirections.actionToStory(StoryViewerOptions.forFeedStoryPosition(position)); NavHostFragment.findNavController(StoryListViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onFeedStoryClick: ", e); } } @Override public void onProfileClick(final String username) { openProfile(username); } }; private final OnHighlightStoryClickListener archiveClickListener = new OnHighlightStoryClickListener() { @Override public void onHighlightClick(final Story model, final int position) { if (model == null) return; try { final NavDirections action = StoryListViewerFragmentDirections.actionToStory(StoryViewerOptions.forStoryArchive(position)); NavHostFragment.findNavController(StoryListViewerFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHighlightClick: ", e); } } @Override public void onProfileClick(final String username) { openProfile(username); } }; private final ServiceCallback cb = new ServiceCallback() { @Override public void onSuccess(final ArchiveResponse result) { binding.swipeRefreshLayout.setRefreshing(false); if (result == null) { try { final Context context = getContext(); Toast.makeText(context, R.string.empty_list, Toast.LENGTH_SHORT).show(); } catch (Exception ignored) {} } else { endCursor = result.getMaxId(); final List models = archivesViewModel.getList().getValue(); final List modelsCopy = models == null ? new ArrayList<>() : new ArrayList<>(models); if (result.getItems() != null) modelsCopy.addAll(result.getItems()); archivesViewModel.getList().postValue(modelsCopy); } } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error", t); try { final Context context = getContext(); Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); } catch (Exception ignored) {} } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (AppCompatActivity) requireActivity(); context = getContext(); if (context == null) return; final Bundle args = getArguments(); if (args == null) return; final StoryListViewerFragmentArgs fragmentArgs = StoryListViewerFragmentArgs.fromBundle(args); type = fragmentArgs.getType(); setHasOptionsMenu(type.equals("feed")); storiesRepository = StoriesRepository.Companion.getInstance(); } @NonNull @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentStoryListViewerBinding.inflate(getLayoutInflater()); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; init(); shouldRefresh = false; } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, final MenuInflater inflater) { inflater.inflate(R.menu.search, menu); final MenuItem menuSearch = menu.findItem(R.id.action_search); final SearchView searchView = (SearchView) menuSearch.getActionView(); searchView.setQueryHint(getResources().getString(R.string.action_search)); searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(final String query) { return false; } @Override public boolean onQueryTextChange(final String query) { if (adapter != null) { adapter.getFilter().filter(query); } return true; } }); } @Override public void onResume() { super.onResume(); final ActionBar actionBar = fragmentActivity.getSupportActionBar(); if (actionBar != null) actionBar.setTitle(type.equals("feed") ? R.string.feed_stories : R.string.action_archive); } @Override public void onDestroy() { if (archivesViewModel != null) archivesViewModel.getList().postValue(null); super.onDestroy(); } private void init() { final Context context = getContext(); binding.swipeRefreshLayout.setOnRefreshListener(this); final LinearLayoutManager layoutManager = new LinearLayoutManager(context); final ActionBar actionBar = fragmentActivity.getSupportActionBar(); if (type.equals("feed")) { if (actionBar != null) actionBar.setTitle(R.string.feed_stories); feedStoriesViewModel = new ViewModelProvider(fragmentActivity).get(FeedStoriesViewModel.class); adapter = new FeedStoriesListAdapter(clickListener); binding.rvStories.setLayoutManager(layoutManager); binding.rvStories.setAdapter(adapter); feedStoriesViewModel.getList().observe(getViewLifecycleOwner(), list -> { if (list == null) { adapter.submitList(Collections.emptyList()); return; } adapter.submitList(list); }); } else { if (actionBar != null) actionBar.setTitle(R.string.action_archive); final RecyclerLazyLoader lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> { if (!TextUtils.isEmpty(endCursor)) onRefresh(); endCursor = null; }); binding.rvStories.addOnScrollListener(lazyLoader); archivesViewModel = new ViewModelProvider(fragmentActivity).get(ArchivesViewModel.class); final HighlightStoriesListAdapter adapter = new HighlightStoriesListAdapter(archiveClickListener); binding.rvStories.setLayoutManager(layoutManager); binding.rvStories.setAdapter(adapter); archivesViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); } onRefresh(); } @Override public void onRefresh() { binding.swipeRefreshLayout.setRefreshing(true); if (type.equals("feed") && firstRefresh) { binding.swipeRefreshLayout.setRefreshing(false); final List value = feedStoriesViewModel.getList().getValue(); if (value != null) { adapter.submitList(value); } firstRefresh = false; } else if (type.equals("feed")) { storiesRepository.getFeedStories( CoroutineUtilsKt.getContinuation((feedStoryModels, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "failed", throwable); Toast.makeText(context, throwable.getMessage(), Toast.LENGTH_SHORT).show(); return; } //noinspection unchecked feedStoriesViewModel.getList().postValue((List) feedStoryModels); //noinspection unchecked adapter.submitList((List) feedStoryModels); binding.swipeRefreshLayout.setRefreshing(false); }), Dispatchers.getIO()) ); } else if (type.equals("archive")) { storiesRepository.fetchArchive( endCursor, CoroutineUtilsKt.getContinuation((archiveFetchResponse, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { cb.onFailure(throwable); return; } cb.onSuccess(archiveFetchResponse); }), Dispatchers.getIO()) ); } } private void openProfile(final String username) { try { final NavDirections action = StoryListViewerFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "openProfile: ", e); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.kt ================================================ package awais.instagrabber.fragments import android.annotation.SuppressLint import android.content.DialogInterface.OnClickListener import android.graphics.drawable.Animatable import android.net.Uri import android.os.Bundle import android.os.Handler import android.util.Log import android.view.* import android.view.GestureDetector.SimpleOnGestureListener import android.widget.* import android.widget.SeekBar.OnSeekBarChangeListener import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.TooltipCompat import androidx.core.view.GestureDetectorCompat import androidx.fragment.app.Fragment import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.recyclerview.widget.LinearLayoutManager import awais.instagrabber.BuildConfig import awais.instagrabber.R import awais.instagrabber.adapters.StoriesAdapter import awais.instagrabber.customviews.helpers.SwipeGestureListener import awais.instagrabber.databinding.FragmentStoryViewerBinding import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.interfaces.SwipeEvent import awais.instagrabber.models.Resource import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.models.enums.StoryPaginationType import awais.instagrabber.repositories.requests.StoryViewerOptions import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.repositories.responses.stories.* import awais.instagrabber.utils.DownloadUtils.download import awais.instagrabber.utils.ResponseBodyUtils import awais.instagrabber.utils.Utils import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.viewmodels.ArchivesViewModel import awais.instagrabber.viewmodels.FeedStoriesViewModel import awais.instagrabber.viewmodels.StoryFragmentViewModel import awais.instagrabber.webservices.MediaRepository import awais.instagrabber.webservices.StoriesRepository import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.drawee.controller.BaseControllerListener import com.facebook.drawee.interfaces.DraweeController import com.facebook.imagepipeline.image.ImageInfo import com.facebook.imagepipeline.request.ImageRequestBuilder import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.source.* import com.google.android.exoplayer2.source.dash.DashMediaSource import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory import com.google.android.material.textfield.TextInputEditText import java.io.IOException import java.text.NumberFormat import java.util.* class StoryViewerFragment : Fragment() { private val TAG = "StoryViewerFragment" private var root: View? = null private var currentStoryUsername: String? = null private var storiesAdapter: StoriesAdapter? = null private var swipeEvent: SwipeEvent? = null private var gestureDetector: GestureDetectorCompat? = null private val storiesRepository: StoriesRepository? = null private val mediaRepository: MediaRepository? = null private var menuProfile: MenuItem? = null private var profileVisible: Boolean = false private var player: SimpleExoPlayer? = null private var shouldRefresh = true private var currentFeedStoryIndex = 0 private var sliderValue = 0.0 private var options: StoryViewerOptions? = null private var listViewModel: ViewModel? = null private var backStackSavedStateResultLiveData: MutableLiveData? = null private lateinit var fragmentActivity: AppCompatActivity private lateinit var storiesViewModel: StoryFragmentViewModel private lateinit var binding: FragmentStoryViewerBinding @Suppress("UNCHECKED_CAST") private val backStackSavedStateObserver = Observer { result -> if (result == null) return@Observer if ((result is RankedRecipient)) { if (context != null) { Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() } storiesViewModel.shareDm(result) } else if ((result is Set<*>)) { try { if (context != null) { Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() } storiesViewModel.shareDm(result as Set) } catch (e: Exception) { Log.e(TAG, "share: ", e) } } // clear result backStackSavedStateResultLiveData?.postValue(null) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) fragmentActivity = requireActivity() as AppCompatActivity storiesViewModel = ViewModelProvider(this).get(StoryFragmentViewModel::class.java) setHasOptionsMenu(true) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { if (root != null) { shouldRefresh = false return root } binding = FragmentStoryViewerBinding.inflate(inflater, container, false) root = binding.root return root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (!shouldRefresh) return init() shouldRefresh = false } override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.story_menu, menu) menuProfile = menu.findItem(R.id.action_profile) menuProfile!!.isVisible = profileVisible } override fun onOptionsItemSelected(item: MenuItem): Boolean { val itemId = item.itemId if (itemId == R.id.action_profile) { val username = storiesViewModel.getCurrentStory().value?.user?.username openProfile(Pair(username, FavoriteType.USER)) return true } return false } override fun onPause() { super.onPause() player?.pause() ?: return } override fun onResume() { super.onResume() setHasOptionsMenu(true) try { val backStackEntry = NavHostFragment.findNavController(this).currentBackStackEntry if (backStackEntry != null) { backStackSavedStateResultLiveData = backStackEntry.savedStateHandle.getLiveData("result") backStackSavedStateResultLiveData?.observe(viewLifecycleOwner, backStackSavedStateObserver) } } catch (e: Exception) { Log.e(TAG, "onResume: ", e) } val actionBar = fragmentActivity.supportActionBar ?: return actionBar.title = storiesViewModel.getTitle().value actionBar.subtitle = storiesViewModel.getDate().value } override fun onDestroy() { releasePlayer() val actionBar = fragmentActivity.supportActionBar actionBar?.subtitle = null super.onDestroy() } private fun init() { val args = arguments ?: return val fragmentArgs = StoryViewerFragmentArgs.fromBundle(args) options = fragmentArgs.options currentFeedStoryIndex = options!!.currentFeedStoryIndex val type = options!!.type if (currentFeedStoryIndex >= 0) { listViewModel = when (type) { StoryViewerOptions.Type.STORY_ARCHIVE -> ViewModelProvider(fragmentActivity).get(ArchivesViewModel::class.java) StoryViewerOptions.Type.FEED_STORY_POSITION -> ViewModelProvider(fragmentActivity).get(FeedStoriesViewModel::class.java) else -> null } } setupButtons() setupStories() } private fun setupStories() { setupListeners() val context = context ?: return binding.storiesList.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) storiesAdapter = StoriesAdapter { _, position -> storiesViewModel.setMedia(position) } binding.storiesList.adapter = storiesAdapter storiesViewModel.getCurrentStory().observe(fragmentActivity, { if (it?.items != null && it.items.size > 1) { val storyMedias = it.items.toMutableList() val newItem = storyMedias.get(0) newItem.isCurrentSlide = true storyMedias.set(0, newItem) storiesAdapter!!.submitList(storyMedias) storiesViewModel.setMedia(0) binding.listToggle.isEnabled = true binding.storiesList.visibility = if (Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_STORY_SHOW_LIST)) View.VISIBLE else View.GONE } else { if (it?.items != null) storiesViewModel.setMedia(0) binding.listToggle.isEnabled = false binding.storiesList.visibility = View.GONE } }) storiesViewModel.getDate().observe(fragmentActivity, { val actionBar = fragmentActivity.supportActionBar if (actionBar != null && it != null) actionBar.subtitle = it }) storiesViewModel.getTitle().observe(fragmentActivity, { val actionBar = fragmentActivity.supportActionBar if (actionBar != null && it != null) actionBar.title = it }) storiesViewModel.getCurrentMedia().observe(fragmentActivity, { refreshStory(it) }) storiesViewModel.getCurrentIndex().observe(fragmentActivity, { storiesAdapter!!.paginate(it) }) storiesViewModel.getOptions().observe(fragmentActivity, { binding.stickers.isEnabled = it.first.size > 0 }) } private fun setupButtons() { binding.btnDownload.setOnClickListener({ _ -> downloadStory() }) binding.btnForward.setOnClickListener({ _ -> storiesViewModel.skip(false) }) binding.btnBackward.setOnClickListener({ _ -> storiesViewModel.skip(true) }) binding.btnShare.setOnClickListener({ _ -> shareStoryViaDm() }) binding.btnReply.setOnClickListener({ _ -> createReplyDialog(null) }) binding.stickers.setOnClickListener({ _ -> showStickerMenu() }) binding.listToggle.setOnClickListener({ _ -> binding.storiesList.visibility = if (binding.storiesList.visibility == View.GONE) View.VISIBLE else View.GONE }) TooltipCompat.setTooltipText(binding.btnDownload, getString(R.string.action_download)) TooltipCompat.setTooltipText(binding.btnShare, getString(R.string.share)) TooltipCompat.setTooltipText(binding.btnReply, getString(R.string.reply_story)) TooltipCompat.setTooltipText(binding.stickers, getString(R.string.story_stickers)) TooltipCompat.setTooltipText(binding.listToggle, getString(R.string.story_list)) } @SuppressLint("ClickableViewAccessibility") private fun setupListeners() { if (currentFeedStoryIndex >= 0) { val type = options!!.type when (type) { StoryViewerOptions.Type.HIGHLIGHT -> { storiesViewModel.fetchHighlights(options!!.id) storiesViewModel.highlights.observe(fragmentActivity) { setupMultipage(it) } } StoryViewerOptions.Type.FEED_STORY_POSITION -> { val feedStoriesViewModel = listViewModel as FeedStoriesViewModel? setupMultipage(feedStoriesViewModel!!.list.value) } StoryViewerOptions.Type.STORY_ARCHIVE -> { val archivesViewModel = listViewModel as ArchivesViewModel? setupMultipage(archivesViewModel!!.list.value) } StoryViewerOptions.Type.USER -> { resetView() } } } val context = context ?: return swipeEvent = SwipeEvent { isRightSwipe: Boolean -> storiesViewModel.paginate(isRightSwipe) } gestureDetector = GestureDetectorCompat(context, SwipeGestureListener(swipeEvent)) binding.playerView.setOnTouchListener { _, event -> gestureDetector!!.onTouchEvent(event) } val simpleOnGestureListener: SimpleOnGestureListener = object : SimpleOnGestureListener() { override fun onFling( e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { val diffX = e2.x - e1.x try { if (Math.abs(diffX) > Math.abs(e2.y - e1.y) && Math.abs(diffX) > SwipeGestureListener.SWIPE_THRESHOLD && Math.abs( velocityX ) > SwipeGestureListener.SWIPE_VELOCITY_THRESHOLD ) { storiesViewModel.paginate(diffX > 0) return true } } catch (e: Exception) { if (BuildConfig.DEBUG) Log.e(TAG, "Error", e) } return false } } binding.imageViewer.setTapListener(simpleOnGestureListener) } private fun setupMultipage(models: List?) { if (models == null) return storiesViewModel.getPagination().observe(fragmentActivity, { when (it) { StoryPaginationType.FORWARD -> { if (currentFeedStoryIndex == models.size - 1) Toast.makeText( context, R.string.no_more_stories, Toast.LENGTH_SHORT ).show() else paginateStories(false, currentFeedStoryIndex == models.size - 2) } StoryPaginationType.BACKWARD -> { if (currentFeedStoryIndex == 0) Toast.makeText( context, R.string.no_more_stories, Toast.LENGTH_SHORT ).show() else paginateStories(true, false) } StoryPaginationType.ERROR -> { Toast.makeText( context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT ).show() } } }) if (!models.isEmpty()) { binding.btnBackward.isEnabled = currentFeedStoryIndex != 0 binding.btnForward.isEnabled = currentFeedStoryIndex != models.size - 1 resetView() } } private fun resetView() { val context = context ?: return if (menuProfile != null) menuProfile!!.isVisible = false binding.imageViewer.controller = null releasePlayer() val type = options!!.type var fetchOptions: StoryViewerOptions? = null when (type) { StoryViewerOptions.Type.HIGHLIGHT -> { val models = storiesViewModel.highlights.value if (models == null || models.isEmpty() || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) { Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show() return } fetchOptions = StoryViewerOptions.forHighlight(0L, models[currentFeedStoryIndex].id) } StoryViewerOptions.Type.FEED_STORY_POSITION -> { val feedStoriesViewModel = listViewModel as FeedStoriesViewModel? val models = feedStoriesViewModel!!.list.value if (models == null || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) return val userStory = models[currentFeedStoryIndex] currentStoryUsername = userStory.user!!.username fetchOptions = StoryViewerOptions.forUser(userStory.user.pk, currentStoryUsername) val live = userStory.broadcast if (live != null) { storiesViewModel.setStory(userStory) refreshLive(live) return } } StoryViewerOptions.Type.STORY_ARCHIVE -> { val archivesViewModel = listViewModel as ArchivesViewModel? val models = archivesViewModel!!.list.value if (models == null || models.isEmpty() || currentFeedStoryIndex >= models.size || currentFeedStoryIndex < 0) { Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT) .show() return } val (id, _, _, _, _, _, _, _, _, title) = models[currentFeedStoryIndex] currentStoryUsername = title fetchOptions = StoryViewerOptions.forStoryArchive(id) } StoryViewerOptions.Type.USER -> { currentStoryUsername = options!!.name fetchOptions = StoryViewerOptions.forUser(options!!.id, currentStoryUsername) } } if (type == StoryViewerOptions.Type.STORY) { storiesViewModel.fetchSingleMedia(options!!.id) return } storiesViewModel.fetchStory(fetchOptions).observe(viewLifecycleOwner, { if (it.status == Resource.Status.ERROR) { Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT).show() } }) } @Synchronized private fun refreshLive(live: Broadcast) { binding.btnDownload.isEnabled = false binding.stickers.isEnabled = false binding.listToggle.isEnabled = false binding.btnShare.isEnabled = false binding.btnReply.isEnabled = false releasePlayer() setupLive(live.dashPlaybackUrl ?: live.dashAbrPlaybackUrl ?: return) } @Synchronized private fun refreshStory(currentStory: StoryMedia) { val itemType = currentStory.type val url = if (itemType === MediaItemType.MEDIA_TYPE_IMAGE) ResponseBodyUtils.getImageUrl(currentStory) else ResponseBodyUtils.getVideoUrl(currentStory) releasePlayer() profileVisible = currentStory.user?.username != null if (menuProfile != null) menuProfile!!.isVisible = profileVisible binding.btnDownload.isEnabled = false binding.btnShare.isEnabled = currentStory.canReshare binding.btnReply.isEnabled = currentStory.canReply if (itemType === MediaItemType.MEDIA_TYPE_VIDEO) setupVideo(url) else setupImage(url) if (options!!.type == StoryViewerOptions.Type.FEED_STORY_POSITION && Utils.settingsHelper.getBoolean(PreferenceKeys.MARK_AS_SEEN)) { val feedStoriesViewModel = listViewModel as FeedStoriesViewModel? storiesViewModel.markAsSeen(currentStory).observe(viewLifecycleOwner) { m -> if (m.status == Resource.Status.SUCCESS && m.data != null) { val liveModels: MutableLiveData> = feedStoriesViewModel!!.list val models = liveModels.value val modelsCopy: MutableList = models!!.toMutableList() modelsCopy.set(currentFeedStoryIndex, m.data) liveModels.postValue(modelsCopy) } } } } private fun downloadStory() { val context = context ?: return val currentStory = storiesViewModel.getMedia().value if (currentStory == null) { Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show() return } download(context, currentStory) } private fun setupImage(url: String) { binding.progressView.visibility = View.VISIBLE binding.playerView.visibility = View.GONE binding.imageViewer.visibility = View.VISIBLE val requestBuilder = ImageRequestBuilder.newBuilderWithSource(Uri.parse(url)) .setLocalThumbnailPreviewsEnabled(true) .setProgressiveRenderingEnabled(true) .build() val controller: DraweeController = Fresco.newDraweeControllerBuilder() .setImageRequest(requestBuilder) .setOldController(binding.imageViewer.controller) .setControllerListener(object : BaseControllerListener() { override fun onFailure(id: String, throwable: Throwable) { binding.btnDownload.isEnabled = false binding.progressView.visibility = View.GONE } override fun onFinalImageSet( id: String, imageInfo: ImageInfo?, animatable: Animatable? ) { binding.btnDownload.isEnabled = true binding.progressView.visibility = View.GONE } }) .build() binding.imageViewer.controller = controller } private fun setupVideo(url: String) { binding.playerView.visibility = View.VISIBLE binding.progressView.visibility = View.GONE binding.imageViewer.visibility = View.GONE binding.imageViewer.controller = null val context = context ?: return player = SimpleExoPlayer.Builder(context).build() binding.playerView.player = player player!!.playWhenReady = Utils.settingsHelper.getBoolean(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES) val uri = Uri.parse(url) val mediaItem = MediaItem.fromUri(uri) val mediaSource = ProgressiveMediaSource.Factory(DefaultDataSourceFactory(context, "instagram")) .createMediaSource(mediaItem) mediaSource.addEventListener(Handler(), object : MediaSourceEventListener { override fun onLoadCompleted( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData ) { binding.btnDownload.isEnabled = true binding.progressView.visibility = View.GONE } override fun onLoadStarted( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData ) { binding.btnDownload.isEnabled = true binding.progressView.visibility = View.VISIBLE } override fun onLoadCanceled( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData ) { binding.progressView.visibility = View.GONE } override fun onLoadError( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData, error: IOException, wasCanceled: Boolean ) { binding.btnDownload.isEnabled = false binding.progressView.visibility = View.GONE } }) player!!.setMediaSource(mediaSource) player!!.prepare() binding.playerView.setOnClickListener { _ -> if (player != null) { if (player!!.playbackState == Player.STATE_ENDED) player!!.seekTo(0) player!!.playWhenReady = player!!.playbackState == Player.STATE_ENDED || !player!!.isPlaying } } } private fun setupLive(url: String) { binding.playerView.visibility = View.VISIBLE binding.progressView.visibility = View.GONE binding.imageViewer.visibility = View.GONE binding.imageViewer.controller = null val context = context ?: return player = SimpleExoPlayer.Builder(context).build() binding.playerView.player = player player!!.playWhenReady = Utils.settingsHelper.getBoolean(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES) val uri = Uri.parse(url) val mediaItem = MediaItem.fromUri(uri) val mediaSource = DashMediaSource.Factory(DefaultDataSourceFactory(context, "instagram")) .createMediaSource(mediaItem) mediaSource.addEventListener(Handler(), object : MediaSourceEventListener { override fun onLoadCompleted( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData ) { binding.progressView.visibility = View.GONE } override fun onLoadStarted( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData ) { binding.progressView.visibility = View.VISIBLE } override fun onLoadCanceled( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData ) { binding.progressView.visibility = View.GONE } override fun onLoadError( windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData, error: IOException, wasCanceled: Boolean ) { binding.progressView.visibility = View.GONE } }) player!!.setMediaSource(mediaSource) player!!.prepare() binding.playerView.setOnClickListener { _ -> if (player != null) { if (player!!.playbackState == Player.STATE_ENDED) player!!.seekTo(0) player!!.playWhenReady = player!!.playbackState == Player.STATE_ENDED || !player!!.isPlaying } } } private fun openProfile(data: Pair) { val navController: NavController = NavHostFragment.findNavController(this) val bundle = Bundle() if (data.first == null) { // toast return } val actionBar = fragmentActivity.supportActionBar if (actionBar != null) { actionBar.title = null actionBar.subtitle = null } val action = when (data.second) { FavoriteType.USER -> { StoryViewerFragmentDirections.actionToProfile().apply { this.username = data.first!! } } FavoriteType.HASHTAG -> { StoryViewerFragmentDirections.actionToHashtag(data.first!!) } FavoriteType.LOCATION -> { StoryViewerFragmentDirections.actionToLocation(data.first!!.toLong()) } else -> null } navController.navigate(action!!) } private fun releasePlayer() { if (player == null) return try { player!!.stop(true) } catch (ignored: Exception) { } try { player!!.release() } catch (ignored: Exception) { } player = null } private fun paginateStories( backward: Boolean, last: Boolean ) { binding.btnBackward.isEnabled = currentFeedStoryIndex != 1 || !backward binding.btnForward.isEnabled = !last currentFeedStoryIndex = if (backward) currentFeedStoryIndex - 1 else currentFeedStoryIndex + 1 resetView() } private fun createChoiceDialog( title: String?, tallies: List, onClickListener: OnClickListener, viewerVote: Int?, correctAnswer: Int? ) { val context = context ?: return val choices = tallies.map { (if (viewerVote == tallies.indexOf(it)) "√ " else "") + (if (correctAnswer == tallies.indexOf(it)) "*** " else "") + it.text + " (" + it.count + ")" } val builder = AlertDialog.Builder(context) if (title != null) builder.setTitle(title) if (viewerVote != null) builder.setMessage(R.string.story_quizzed) builder.setPositiveButton(if (viewerVote == null) R.string.cancel else R.string.ok, null) val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, choices.toTypedArray()) builder.setAdapter(adapter, onClickListener) builder.show() } private fun createMentionDialog() { val context = context ?: return val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, storiesViewModel.getMentionTexts()) val builder = AlertDialog.Builder(context) .setPositiveButton(R.string.ok, null) .setAdapter(adapter, { _, w -> val data = storiesViewModel.getMention(w) if (data != null) openProfile(Pair(data.second, data.third)) }) builder.show() } private fun createSliderDialog() { val slider = storiesViewModel.getSlider().value ?: return val context = context ?: return val percentage: NumberFormat = NumberFormat.getPercentInstance() percentage.maximumFractionDigits = 2 val sliderView = LinearLayout(context) sliderView.layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ) sliderView.orientation = LinearLayout.VERTICAL val tv = TextView(context) tv.gravity = Gravity.CENTER_HORIZONTAL val input = SeekBar(context) val avg: Double = slider.sliderVoteAverage ?: 0.5 input.progress = (avg * 100).toInt() var onClickListener: OnClickListener? = null if (slider.viewerVote == null && slider.viewerCanVote == true) { input.isEnabled = true input.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { sliderValue = progress / 100.0 tv.text = percentage.format(sliderValue) } override fun onStartTrackingTouch(seekBar: SeekBar) {} override fun onStopTrackingTouch(seekBar: SeekBar) {} }) onClickListener = OnClickListener { _, _ -> storiesViewModel.answerSlider(sliderValue) } } else { input.isEnabled = false tv.text = getString(R.string.slider_answer, percentage.format(slider.viewerVote)) } sliderView.addView(input) sliderView.addView(tv) val builder = AlertDialog.Builder(context) .setTitle(if (slider.question.isNullOrEmpty()) slider.emoji else slider.question) .setMessage( resources.getQuantityString(R.plurals.slider_info, slider.sliderVoteCount ?: 0, slider.sliderVoteCount ?: 0, percentage.format(avg))) .setView(sliderView) .setPositiveButton(R.string.ok, onClickListener) builder.show() } private fun createReplyDialog(question: String?) { val context = context ?: return val input = TextInputEditText(context) input.setHint(R.string.reply_hint) val builder = AlertDialog.Builder(context) .setTitle(question ?: context.getString(R.string.reply_story)) .setView(input) val onClickListener = OnClickListener{ _, _ -> val result = if (question != null) storiesViewModel.answerQuestion(input.text.toString()) else storiesViewModel.reply(input.text.toString()) if (result == null) { Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT) .show() } else result.observe(viewLifecycleOwner, { when (it.status) { Resource.Status.SUCCESS -> { Toast.makeText(context, R.string.answered_story, Toast.LENGTH_SHORT) .show() } Resource.Status.ERROR -> { Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT) .show() } Resource.Status.LOADING -> { Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() } } }) } builder.setPositiveButton(R.string.confirm, onClickListener) builder.show() } private fun shareStoryViaDm() { val story = storiesViewModel.getCurrentStory().value ?: return val context = context if (story.user?.isPrivate == true && context != null) { Toast.makeText(context, R.string.share_private_post, Toast.LENGTH_SHORT).show() } val actionBar = fragmentActivity.supportActionBar if (actionBar != null) actionBar.subtitle = null val actionGlobalUserSearch = StoryViewerFragmentDirections.actionToUserSearch().apply { title = getString(R.string.share) actionLabel = getString(R.string.send) showGroups = true multiple = true searchMode = UserSearchMode.RAVEN } try { val navController = NavHostFragment.findNavController(this@StoryViewerFragment) navController.navigate(actionGlobalUserSearch) } catch (e: Exception) { Log.e(TAG, "shareStoryViaDm: ", e) } } private fun showStickerMenu() { val data = storiesViewModel.getOptions().value if (data == null) return val themeWrapper = ContextThemeWrapper(context, R.style.popupMenuStyle) val popupMenu = PopupMenu(themeWrapper, binding.stickers) val menu = popupMenu.menu data.first.map { if (it.second != 0) menu.add(0, it.first, 0, it.second) if (it.first == R.id.swipeUp) menu.add(0, R.id.swipeUp, 0, data.second) if (it.first == R.id.spotify) menu.add(0, R.id.spotify, 0, data.third) } popupMenu.setOnMenuItemClickListener { item: MenuItem -> val itemId = item.itemId if (itemId == R.id.spotify) openExternalLink(storiesViewModel.getAppAttribution()) else if (itemId == R.id.swipeUp) openExternalLink(storiesViewModel.getSwipeUp()) else if (itemId == R.id.mentions) createMentionDialog() else if (itemId == R.id.slider) createSliderDialog() else if (itemId == R.id.question) { val question = storiesViewModel.getQuestion().value if (question != null) createReplyDialog(question.question) } else if (itemId == R.id.quiz) { val quiz = storiesViewModel.getQuiz().value if (quiz != null) createChoiceDialog( quiz.question, quiz.tallies, { _, w -> storiesViewModel.answerQuiz(w) }, quiz.viewerAnswer, quiz.correctAnswer ) } else if (itemId == R.id.poll) { val poll = storiesViewModel.getPoll().value if (poll != null) createChoiceDialog( poll.question, poll.tallies, { _, w -> storiesViewModel.answerPoll(w) }, poll.viewerVote, null ) } else if (itemId == R.id.viewStoryPost) { storiesViewModel.getLinkedPost().observe(viewLifecycleOwner, { if (it == null) Toast.makeText(context, "Error: LiveData is null", Toast.LENGTH_SHORT).show() else when (it.status) { Resource.Status.SUCCESS -> { if (it.data != null) { val actionBar = fragmentActivity.supportActionBar if (actionBar != null) { actionBar.title = null actionBar.subtitle = null } val navController = NavHostFragment.findNavController(this@StoryViewerFragment) val bundle = Bundle() bundle.putSerializable(PostViewV2Fragment.ARG_MEDIA, it.data) try { navController.navigate(StoryViewerFragmentDirections.actionToPost(it.data, 0)) } catch (e: Exception) { Log.e(TAG, "openPostDialog: ", e) } } } Resource.Status.ERROR -> { Toast.makeText(context, "Error: " + it.message, Toast.LENGTH_SHORT) .show() } Resource.Status.LOADING -> { Toast.makeText(context, R.string.opening_post, Toast.LENGTH_SHORT) .show() } } }) } false } popupMenu.show() } private fun openExternalLink(url: String?) { val context = context ?: return if (url == null) return AlertDialog.Builder(context) .setTitle(R.string.swipe_up_confirmation) .setMessage(url).setPositiveButton(R.string.yes, { _, _ -> Utils.openURL(context, url) }) .setNegativeButton(R.string.no, null) .show() } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/UserSearchFragment.kt ================================================ package awais.instagrabber.fragments import android.os.Bundle import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewGroup.OnHierarchyChangeListener import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import awais.instagrabber.activities.MainActivity import awais.instagrabber.adapters.UserSearchResultsAdapter import awais.instagrabber.customviews.helpers.TextWatcherAdapter import awais.instagrabber.databinding.FragmentUserSearchBinding import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.utils.Utils import awais.instagrabber.utils.extensions.trimAll import awais.instagrabber.utils.measure import awais.instagrabber.viewmodels.UserSearchViewModel import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar class UserSearchFragment : Fragment() { private lateinit var binding: FragmentUserSearchBinding private var resultsAdapter: UserSearchResultsAdapter? = null private var paddingOffset = 0 private var actionLabel: String? = null private var title: String? = null private var multiple = false private val viewModel: UserSearchViewModel by viewModels() private val windowWidth = Utils.displayMetrics.widthPixels private val minInputWidth = Utils.convertDpToPx(50f) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentUserSearchBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { paddingOffset = with(binding) { search.paddingStart + search.paddingEnd + group.paddingStart + group.paddingEnd + group.chipSpacingHorizontal } init() } override fun onDestroyView() { super.onDestroyView() viewModel.cleanup() } private fun init() { val arguments = arguments if (arguments != null) { val fragmentArgs = UserSearchFragmentArgs.fromBundle(arguments) actionLabel = fragmentArgs.actionLabel title = fragmentArgs.title multiple = fragmentArgs.multiple viewModel.setHideThreadIds(fragmentArgs.hideThreadIds) viewModel.setHideUserIds(fragmentArgs.hideUserIds) viewModel.setSearchMode(fragmentArgs.searchMode) viewModel.setShowGroups(fragmentArgs.showGroups) } setupTitles() setupInput() setupResults() setupObservers() // show cached results viewModel.showCachedResults() } private fun setupTitles() { if (!actionLabel.isNullOrBlank()) { binding.done.text = actionLabel } if (title.isNullOrBlank()) return (activity as MainActivity?)?.supportActionBar?.title = title } private fun setupResults() { val context = context ?: return binding.results.layoutManager = LinearLayoutManager(context) resultsAdapter = UserSearchResultsAdapter(multiple) { _: Int, recipient: RankedRecipient, selected: Boolean -> if (!multiple) { val navController = NavHostFragment.findNavController(this) if (!setResult(navController, recipient)) return@UserSearchResultsAdapter navController.navigateUp() return@UserSearchResultsAdapter } viewModel.setSelectedRecipient(recipient, !selected) resultsAdapter?.setSelectedRecipient(recipient, !selected) if (!selected) { createChip(recipient) return@UserSearchResultsAdapter } val chip = findChip(recipient) ?: return@UserSearchResultsAdapter removeChipFromGroup(chip) } binding.results.adapter = resultsAdapter binding.done.setOnClickListener { val navController = NavHostFragment.findNavController(this) if (!setResult(navController, viewModel.selectedRecipients)) return@setOnClickListener navController.navigateUp() } } private fun setResult(navController: NavController, rankedRecipient: RankedRecipient): Boolean { navController.previousBackStackEntry?.savedStateHandle?.set("result", rankedRecipient) ?: return false return true } private fun setResult(navController: NavController, rankedRecipients: Set): Boolean { navController.previousBackStackEntry?.savedStateHandle?.set("result", rankedRecipients) ?: return false return true } private fun setupInput() { binding.search.addTextChangedListener(object : TextWatcherAdapter() { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { viewModel.search(s.toString().trimAll()) } }) binding.search.setOnKeyListener { _: View?, _: Int, event: KeyEvent? -> if (event != null && event.action == KeyEvent.ACTION_DOWN && event.keyCode == KeyEvent.KEYCODE_DEL) { val chip = lastChip ?: return@setOnKeyListener false removeChip(chip) } false } binding.group.setOnHierarchyChangeListener(object : OnHierarchyChangeListener { override fun onChildViewAdded(parent: View, child: View) {} override fun onChildViewRemoved(parent: View, child: View) { binding.group.post { TransitionManager.beginDelayedTransition(binding.root) calculateInputWidth(0) } } }) } private fun setupObservers() { viewModel.recipients.observe(viewLifecycleOwner) { if (it == null) return@observe when (it.status) { Resource.Status.SUCCESS -> if (it.data != null) { resultsAdapter?.submitList(it.data) } Resource.Status.ERROR -> { if (it.message != null) { Snackbar.make(binding.root, it.message, Snackbar.LENGTH_LONG).show() } if (it.resId != 0) { Snackbar.make(binding.root, it.resId, Snackbar.LENGTH_LONG).show() } if (it.data != null) { resultsAdapter?.submitList(it.data) } } Resource.Status.LOADING -> if (it.data != null) { resultsAdapter?.submitList(it.data) } } } viewModel.showAction().observe(viewLifecycleOwner) { binding.done.visibility = if (it) View.VISIBLE else View.GONE } } private fun createChip(recipient: RankedRecipient) { val context = context ?: return val chip = Chip(context).apply { tag = recipient text = getRecipientText(recipient) isCloseIconVisible = true setOnCloseIconClickListener { removeChip(this) } } binding.group.post { val measure = measure(chip, binding.group) TransitionManager.beginDelayedTransition(binding.root) calculateInputWidth(if (measure.second != null) measure.second else 0) binding.group.addView(chip, binding.group.childCount - 1) } } private fun getRecipientText(recipient: RankedRecipient?): String? = when { recipient == null -> null recipient.user != null -> recipient.user.fullName recipient.thread != null -> recipient.thread.threadTitle else -> null } private fun removeChip(chip: View) { val recipient = chip.tag as RankedRecipient viewModel.setSelectedRecipient(recipient, false) resultsAdapter?.setSelectedRecipient(recipient, false) removeChipFromGroup(chip) } private fun findChip(recipient: RankedRecipient?): View? { if (recipient == null || recipient.user == null && recipient.thread == null) return null val isUser = recipient.user != null val childCount = binding.group.childCount if (childCount == 0) return null for (i in childCount - 1 downTo 0) { val child = binding.group.getChildAt(i) ?: continue val tempTag = child.tag ?: continue val tag = tempTag as RankedRecipient if (isUser && tag.user == null || !isUser && tag.thread == null) continue if (isUser && tag.user?.pk == recipient.user?.pk || !isUser && tag.thread?.threadId == recipient.thread?.threadId) { return child } } return null } private fun removeChipFromGroup(chip: View) { binding.group.post { TransitionManager.beginDelayedTransition(binding.root) binding.group.removeView(chip) } } private fun calculateInputWidth(newChipWidth: Int) { var lastRight = lastChip?.right ?: 0 val remainingSpaceInRow = windowWidth - lastRight if (remainingSpaceInRow < newChipWidth) { // next chip will go to the next row, so assume no chips present lastRight = 0 } val newRight = lastRight + newChipWidth val newInputWidth = windowWidth - newRight - paddingOffset binding.search.layoutParams.width = if (newInputWidth < minInputWidth) windowWidth else newInputWidth binding.search.requestLayout() } private val lastChip: View? get() { val childCount = binding.group.childCount if (childCount == 0) return null for (i in childCount - 1 downTo 0) { val child = binding.group.getChildAt(i) if (child is Chip) { return child } } return null } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/UserSearchMode.kt ================================================ package awais.instagrabber.fragments enum class UserSearchMode(val mode: String) { USER_SEARCH("user_name"), RAVEN("raven"), RESHARE("reshare"); } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/comments/CommentsViewerFragment.java ================================================ package awais.instagrabber.fragments.comments; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.text.SpannableString; import android.text.Spanned; import android.text.style.RelativeSizeSpan; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.snackbar.Snackbar; import java.util.Collections; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.CommentsAdapter; import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; import awais.instagrabber.databinding.FragmentCommentsBinding; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.models.Comment; import awais.instagrabber.models.Resource; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.AppStateViewModel; import awais.instagrabber.viewmodels.CommentsViewerViewModel; public final class CommentsViewerFragment extends BottomSheetDialogFragment { private static final String TAG = CommentsViewerFragment.class.getSimpleName(); private CommentsViewerViewModel viewModel; private CommentsAdapter commentsAdapter; private FragmentCommentsBinding binding; private ConstraintLayout root; private boolean shouldRefresh = true; private AppStateViewModel appStateViewModel; private boolean showingReplies; @Override public void onStart() { super.onStart(); final Dialog dialog = getDialog(); if (dialog == null) return; final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialog; final View bottomSheetInternal = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); if (bottomSheetInternal == null) return; bottomSheetInternal.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; bottomSheetInternal.requestLayout(); final BottomSheetBehavior behavior = BottomSheetBehavior.from(bottomSheetInternal); behavior.setState(BottomSheetBehavior.STATE_EXPANDED); behavior.setSkipCollapsed(true); } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final FragmentActivity activity = getActivity(); if (activity == null) return; viewModel = new ViewModelProvider(this).get(CommentsViewerViewModel.class); appStateViewModel = new ViewModelProvider(activity).get(AppStateViewModel.class); } @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { return new BottomSheetDialog(requireContext(), getTheme()) { @Override public void onBackPressed() { if (showingReplies) { getChildFragmentManager().popBackStack(); showingReplies = false; return; } super.onBackPressed(); } }; } @NonNull @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentCommentsBinding.inflate(getLayoutInflater()); binding.swipeRefreshLayout.setEnabled(false); binding.swipeRefreshLayout.setNestedScrollingEnabled(false); root = binding.getRoot(); appStateViewModel.getCurrentUserLiveData().observe(getViewLifecycleOwner(), userResource -> { if (userResource == null || userResource.status == Resource.Status.LOADING) return; viewModel.setCurrentUser(userResource.data); if (userResource.data == null) { viewModel.fetchComments(); return; } viewModel.getCurrentUserId().observe(getViewLifecycleOwner(), new Observer() { @Override public void onChanged(final Long i) { if (i != 0L) { viewModel.fetchComments(); viewModel.getCurrentUserId().removeObserver(this); } } }); }); if (getArguments() == null) return root; final CommentsViewerFragmentArgs args = CommentsViewerFragmentArgs.fromBundle(getArguments()); viewModel.setPostDetails(args.getShortCode(), args.getPostId(), args.getPostUserId()); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; shouldRefresh = false; init(); } private void init() { setupToolbar(); setupList(); setupObservers(); } private void setupObservers() { viewModel.getCurrentUserId().observe(getViewLifecycleOwner(), currentUserId -> { long userId = 0; if (currentUserId != null) { userId = currentUserId; } setupAdapter(userId); if (userId == 0) return; Helper.setupCommentInput(binding.commentField, binding.commentText, false, text -> { final LiveData> resourceLiveData = viewModel.comment(text, false); resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(final Resource objectResource) { if (objectResource == null) return; final Context context = getContext(); if (context == null) return; Helper.handleCommentResource( context, objectResource.status, objectResource.message, resourceLiveData, this, binding.commentField, binding.commentText, binding.comments); } }); return null; }); }); viewModel.getRootList().observe(getViewLifecycleOwner(), listResource -> { if (listResource == null) return; switch (listResource.status) { case SUCCESS: binding.swipeRefreshLayout.setRefreshing(false); if (commentsAdapter != null) { commentsAdapter.submitList(listResource.data); } break; case ERROR: binding.swipeRefreshLayout.setRefreshing(false); if (!TextUtils.isEmpty(listResource.message)) { Snackbar.make(binding.getRoot(), listResource.message, Snackbar.LENGTH_LONG).show(); } break; case LOADING: binding.swipeRefreshLayout.setRefreshing(true); break; } }); viewModel.getRootCommentsCount().observe(getViewLifecycleOwner(), count -> { if (count == null || count == 0) { binding.toolbar.setTitle(R.string.title_comments); return; } final String titleComments = getString(R.string.title_comments); final String countString = String.valueOf(count); final SpannableString titleWithCount = new SpannableString(String.format("%s %s", titleComments, countString)); titleWithCount.setSpan(new RelativeSizeSpan(0.8f), titleWithCount.length() - countString.length(), titleWithCount.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); binding.toolbar.setTitle(titleWithCount); }); } private void setupToolbar() { binding.toolbar.setTitle(R.string.title_comments); } private void setupAdapter(final long currentUserId) { final Context context = getContext(); if (context == null) return; commentsAdapter = new CommentsAdapter(currentUserId, false, Helper.getCommentCallback( context, getViewLifecycleOwner(), getNavController(), viewModel, (comment, focusInput) -> { if (comment == null) return null; final boolean disableTransition = Utils.settingsHelper.getBoolean(PreferenceKeys.PREF_DISABLE_SCREEN_TRANSITIONS); final RepliesFragment repliesFragment = RepliesFragment.newInstance(comment, focusInput != null && focusInput); final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); if (!disableTransition) { transaction.setCustomAnimations(R.anim.slide_left, R.anim.slide_right, 0, R.anim.slide_right); } transaction.add(R.id.replies_container_view, repliesFragment) .addToBackStack(RepliesFragment.TAG) .commit(); showingReplies = true; return null; })); final Resource> listResource = viewModel.getRootList().getValue(); binding.comments.setAdapter(commentsAdapter); commentsAdapter.submitList(listResource != null ? listResource.data : Collections.emptyList()); } private void setupList() { final Context context = getContext(); if (context == null) return; final LinearLayoutManager layoutManager = new LinearLayoutManager(context); final RecyclerLazyLoader lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> viewModel.fetchComments()); Helper.setupList(context, binding.comments, layoutManager, lazyLoader); } @Nullable private NavController getNavController() { NavController navController = null; try { navController = NavHostFragment.findNavController(this); } catch (IllegalStateException e) { Log.e(TAG, "navigateToProfile", e); } return navController; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/comments/Helper.java ================================================ package awais.instagrabber.fragments.comments; import android.content.Context; import android.graphics.drawable.Drawable; import android.text.Editable; import android.util.Log; import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.navigation.NavController; import androidx.navigation.NavDirections; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.internal.CheckableImageButton; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import java.util.function.BiFunction; import java.util.function.Function; import awais.instagrabber.R; import awais.instagrabber.adapters.CommentsAdapter.CommentCallback; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.models.Comment; import awais.instagrabber.models.Resource; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.CommentsViewerViewModel; import awais.instagrabber.webservices.ServiceCallback; public final class Helper { private static final String TAG = Helper.class.getSimpleName(); public static void setupList(@NonNull final Context context, @NonNull final RecyclerView list, @NonNull final RecyclerView.LayoutManager layoutManager, @NonNull final RecyclerView.OnScrollListener lazyLoader) { list.setLayoutManager(layoutManager); final DividerItemDecoration itemDecoration = new DividerItemDecoration(context, LinearLayoutManager.VERTICAL); final Drawable drawable = ContextCompat.getDrawable(context, R.drawable.pref_list_divider_material); if (drawable != null) { itemDecoration.setDrawable(drawable); } list.addItemDecoration(itemDecoration); list.addOnScrollListener(lazyLoader); } @NonNull public static CommentCallback getCommentCallback(@NonNull final Context context, final LifecycleOwner lifecycleOwner, final NavController navController, @NonNull final CommentsViewerViewModel viewModel, final BiFunction onRepliesClick) { return new CommentCallback() { @Override public void onClick(final Comment comment) { // onCommentClick(comment); if (onRepliesClick == null) return; onRepliesClick.apply(comment, false); } @Override public void onHashtagClick(final String hashtag) { try { if (navController == null) return; navController.navigate(CommentsViewerFragmentDirections.actionToHashtag(hashtag)); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onMentionClick(final String mention) { openProfile(navController, mention); } @Override public void onURLClick(final String url) { Utils.openURL(context, url); } @Override public void onEmailClick(final String emailAddress) { Utils.openEmailAddress(context, emailAddress); } @Override public void onLikeClick(final Comment comment, final boolean liked, final boolean isReply) { if (comment == null) return; final LiveData> resourceLiveData = viewModel.likeComment(comment, liked, isReply); resourceLiveData.observe(lifecycleOwner, new Observer>() { @Override public void onChanged(final Resource objectResource) { if (objectResource == null) return; switch (objectResource.status) { case SUCCESS: resourceLiveData.removeObserver(this); break; case LOADING: break; case ERROR: if (objectResource.message != null) { Toast.makeText(context, objectResource.message, Toast.LENGTH_LONG).show(); } resourceLiveData.removeObserver(this); } } }); } @Override public void onRepliesClick(final Comment comment) { // viewModel.showReplies(comment); if (onRepliesClick == null) return; onRepliesClick.apply(comment, true); } @Override public void onViewLikes(final Comment comment) { try { if (navController == null) return; final NavDirections actionToLikes = CommentsViewerFragmentDirections.actionToLikes(comment.getPk(), true); navController.navigate(actionToLikes); } catch (Exception e) { Log.e(TAG, "onViewLikes: ", e); } } @Override public void onTranslate(final Comment comment) { if (comment == null) return; viewModel.translate(comment, new ServiceCallback() { @Override public void onSuccess(final String result) { if (TextUtils.isEmpty(result)) { Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); return; } final String username = comment.getUser().getUsername(); new MaterialAlertDialogBuilder(context) .setTitle(username) .setMessage(result) .setPositiveButton(R.string.ok, null) .show(); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error translating comment", t); Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show(); } }); } @Override public void onDelete(final Comment comment, final boolean isReply) { if (comment == null) return; final LiveData> resourceLiveData = viewModel.deleteComment(comment, isReply); resourceLiveData.observe(lifecycleOwner, new Observer>() { @Override public void onChanged(final Resource objectResource) { if (objectResource == null) return; switch (objectResource.status) { case SUCCESS: resourceLiveData.removeObserver(this); break; case ERROR: if (objectResource.message != null) { Toast.makeText(context, objectResource.message, Toast.LENGTH_LONG).show(); } resourceLiveData.removeObserver(this); break; case LOADING: break; } } }); } }; } private static void openProfile(final NavController navController, @NonNull final String username) { try { if (navController == null) return; final NavDirections action = CommentsViewerFragmentDirections.actionToProfile().setUsername(username); navController.navigate(action); } catch (Exception e) { Log.e(TAG, "openProfile: ", e); } } public static void setupCommentInput(@NonNull final TextInputLayout commentField, @NonNull final TextInputEditText commentText, final boolean isReplyFragment, @NonNull final Function commentFunction) { // commentField.setStartIconVisible(false); commentField.setVisibility(View.VISIBLE); commentField.setEndIconVisible(false); if (isReplyFragment) { commentField.setHint(R.string.reply_hint); } commentText.addTextChangedListener(new TextWatcherAdapter() { @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { final boolean isEmpty = TextUtils.isEmpty(s); commentField.setStartIconVisible(!isEmpty); commentField.setEndIconVisible(!isEmpty); commentField.setCounterEnabled(s != null && s.length() > 2000); // show the counter when user approaches the limit } }); // commentField.setStartIconOnClickListener(v -> { // // commentsAdapter.clearSelection(); // commentText.setText(""); // }); commentField.setEndIconOnClickListener(v -> { final Editable text = commentText.getText(); if (TextUtils.isEmpty(text)) return; commentFunction.apply(text.toString().trim()); }); } public static void handleCommentResource(@NonNull final Context context, @NonNull final Resource.Status status, final String message, @NonNull final LiveData> resourceLiveData, @NonNull final Observer> observer, @NonNull final TextInputLayout commentField, @NonNull final TextInputEditText commentText, @NonNull final RecyclerView comments) { CheckableImageButton endIcon = null; try { endIcon = (CheckableImageButton) commentField.findViewById(com.google.android.material.R.id.text_input_end_icon); } catch (Exception e) { Log.e(TAG, "setupObservers: ", e); } CheckableImageButton startIcon = null; try { startIcon = (CheckableImageButton) commentField.findViewById(com.google.android.material.R.id.text_input_start_icon); } catch (Exception e) { Log.e(TAG, "setupObservers: ", e); } switch (status) { case SUCCESS: resourceLiveData.removeObserver(observer); comments.postDelayed(() -> comments.scrollToPosition(0), 500); if (startIcon != null) { startIcon.setEnabled(true); } if (endIcon != null) { endIcon.setEnabled(true); } commentText.setText(""); break; case LOADING: commentText.setEnabled(false); if (startIcon != null) { startIcon.setEnabled(false); } if (endIcon != null) { endIcon.setEnabled(false); } break; case ERROR: if (message != null && context != null) { Toast.makeText(context, message, Toast.LENGTH_LONG).show(); } if (startIcon != null) { startIcon.setEnabled(true); } if (endIcon != null) { endIcon.setEnabled(true); } resourceLiveData.removeObserver(observer); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/comments/RepliesFragment.java ================================================ package awais.instagrabber.fragments.comments; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import com.google.android.material.snackbar.Snackbar; import java.io.Serializable; import java.util.Collections; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.adapters.CommentsAdapter; import awais.instagrabber.customviews.helpers.RecyclerLazyLoader; import awais.instagrabber.databinding.FragmentCommentsBinding; import awais.instagrabber.models.Comment; import awais.instagrabber.models.Resource; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.CommentsViewerViewModel; public class RepliesFragment extends Fragment { public static final String TAG = RepliesFragment.class.getSimpleName(); private static final String ARG_PARENT = "parent"; private static final String ARG_FOCUS_INPUT = "focus"; private FragmentCommentsBinding binding; private CommentsViewerViewModel viewModel; private CommentsAdapter commentsAdapter; @NonNull public static RepliesFragment newInstance(@NonNull final Comment parent, final boolean focusInput) { final Bundle args = new Bundle(); args.putSerializable(ARG_PARENT, parent); args.putBoolean(ARG_FOCUS_INPUT, focusInput); final RepliesFragment fragment = new RepliesFragment(); fragment.setArguments(args); return fragment; } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final Fragment parentFragment = getParentFragment(); if (parentFragment == null) return; viewModel = new ViewModelProvider(parentFragment).get(CommentsViewerViewModel.class); final Bundle bundle = getArguments(); if (bundle == null) return; final Serializable serializable = bundle.getSerializable(ARG_PARENT); if (!(serializable instanceof Comment)) return; viewModel.showReplies((Comment) serializable); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = FragmentCommentsBinding.inflate(inflater, container, false); binding.swipeRefreshLayout.setEnabled(false); binding.swipeRefreshLayout.setNestedScrollingEnabled(false); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { setupToolbar(); } @Override public Animation onCreateAnimation(int transit, boolean enter, int nextAnim) { if (!enter) { return super.onCreateAnimation(transit, false, nextAnim); } if (nextAnim == 0) { setupList(); setupObservers(); return super.onCreateAnimation(transit, true, nextAnim); } final Animation animation = AnimationUtils.loadAnimation(getContext(), nextAnim); animation.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) {} @Override public void onAnimationEnd(Animation animation) { setupList(); setupObservers(); } @Override public void onAnimationRepeat(Animation animation) {} }); return animation; } @Override public void onDestroyView() { super.onDestroyView(); binding = null; } @Override public void onDestroy() { super.onDestroy(); if (viewModel != null) { viewModel.clearReplies(); } } private void setupObservers() { if (viewModel == null) return; viewModel.getCurrentUserId().observe(getViewLifecycleOwner(), currentUserId -> { long userId = 0; if (currentUserId != null) { userId = currentUserId; } setupAdapter(userId); if (userId == 0) return; Helper.setupCommentInput(binding.commentField, binding.commentText, true, text -> { final LiveData> resourceLiveData = viewModel.comment(text, true); resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(final Resource objectResource) { if (objectResource == null) return; final Context context = getContext(); if (context == null) return; Helper.handleCommentResource(context, objectResource.status, objectResource.message, resourceLiveData, this, binding.commentField, binding.commentText, binding.comments); } }); return null; }); final Bundle bundle = getArguments(); if (bundle == null) return; final boolean focusInput = bundle.getBoolean(ARG_FOCUS_INPUT); if (focusInput && viewModel.getRepliesParent() != null) { viewModel.getRepliesParent().getUser(); binding.commentText.setText(String.format("@%s ", viewModel.getRepliesParent().getUser().getUsername())); Utils.showKeyboard(binding.commentText); } }); viewModel.getReplyList().observe(getViewLifecycleOwner(), listResource -> { if (listResource == null) return; switch (listResource.status) { case SUCCESS: binding.swipeRefreshLayout.setRefreshing(false); if (commentsAdapter != null) { commentsAdapter.submitList(listResource.data); } break; case ERROR: binding.swipeRefreshLayout.setRefreshing(false); final String message = listResource.message; if (!TextUtils.isEmpty(message)) { Snackbar.make(binding.getRoot(), message, Snackbar.LENGTH_LONG).show(); } break; case LOADING: binding.swipeRefreshLayout.setRefreshing(true); break; } }); } private void setupToolbar() { binding.toolbar.setTitle(R.string.title_replies); binding.toolbar.setNavigationIcon(R.drawable.ic_round_arrow_back_24); binding.toolbar.setNavigationOnClickListener(v -> { final FragmentManager fragmentManager = getParentFragmentManager(); fragmentManager.popBackStack(); }); } private void setupAdapter(final long currentUserId) { if (viewModel == null) return; final Context context = getContext(); if (context == null) return; commentsAdapter = new CommentsAdapter( currentUserId, true, Helper.getCommentCallback( context, getViewLifecycleOwner(), getNavController(), viewModel, (comment, focusInput) -> { viewModel.setReplyTo(comment); binding.commentText.setText(String.format("@%s ", comment.getUser().getUsername())); if (focusInput) Utils.showKeyboard(binding.commentText); return null; } ) ); binding.comments.setAdapter(commentsAdapter); final Resource> listResource = viewModel.getReplyList().getValue(); commentsAdapter.submitList(listResource != null ? listResource.data : Collections.emptyList()); } private void setupList() { final Context context = getContext(); if (context == null) return; final LinearLayoutManager layoutManager = new LinearLayoutManager(context); final RecyclerLazyLoader lazyLoader = new RecyclerLazyLoader(layoutManager, (page, totalItemsCount) -> { if (viewModel != null) viewModel.fetchReplies(); }); Helper.setupList(context, binding.comments, layoutManager, lazyLoader); } @Nullable private NavController getNavController() { NavController navController = null; try { navController = NavHostFragment.findNavController(this); } catch (IllegalStateException e) { Log.e(TAG, "navigateToProfile", e); } return navController; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageInboxFragment.kt ================================================ package awais.instagrabber.fragments.directmessages import android.annotation.SuppressLint import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Log import android.view.* import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import awais.instagrabber.R import awais.instagrabber.activities.MainActivity import awais.instagrabber.adapters.DirectMessageInboxAdapter import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge import awais.instagrabber.databinding.FragmentDirectMessagesInboxBinding import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.directmessages.DirectInbox import awais.instagrabber.repositories.responses.directmessages.DirectThread import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.viewmodels.DirectInboxViewModel import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils import com.google.android.material.internal.ToolbarUtils import com.google.android.material.snackbar.Snackbar class DirectMessageInboxFragment : Fragment(), OnRefreshListener { private val viewModel: DirectInboxViewModel by activityViewModels() private lateinit var fragmentActivity: MainActivity private lateinit var binding: FragmentDirectMessagesInboxBinding private lateinit var lazyLoader: RecyclerLazyLoaderAtEdge private var scrollToTop = false private var navigating = false private var pendingRequestsMenuItem: MenuItem? = null private var pendingRequestTotalBadgeDrawable: BadgeDrawable? = null private var isPendingRequestTotalBadgeAttached = false private var inboxAdapter: DirectMessageInboxAdapter? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) fragmentActivity = requireActivity() as MainActivity setHasOptionsMenu(true) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { binding = FragmentDirectMessagesInboxBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { init() } override fun onRefresh() { lazyLoader.resetState() scrollToTop = true viewModel.refresh() } @SuppressLint("UnsafeExperimentalUsageError", "UnsafeOptInUsageError", "RestrictedApi") override fun onPause() { super.onPause() isPendingRequestTotalBadgeAttached = false pendingRequestsMenuItem?.let { val menuItemView = ToolbarUtils.getActionMenuItemView(fragmentActivity.getToolbar(), it.itemId) if (menuItemView != null) { BadgeUtils.detachBadgeDrawable(pendingRequestTotalBadgeDrawable, fragmentActivity.getToolbar(), it.itemId) pendingRequestTotalBadgeDrawable = null } } } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.dm_inbox_menu, menu) pendingRequestsMenuItem = menu.findItem(R.id.pending_requests) pendingRequestsMenuItem?.isVisible = isPendingRequestTotalBadgeAttached } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.pending_requests) { try { val directions = DirectMessageInboxFragmentDirections.actionToPendingInbox() findNavController().navigate(directions) } catch (e: Exception) { Log.e(TAG, "onOptionsItemSelected: ", e) } return true } return super.onOptionsItemSelected(item) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) init() } private fun setupObservers() { viewModel.threads.observe(viewLifecycleOwner, { list: List -> inboxAdapter?.submitList(list) { if (!scrollToTop) return@submitList binding.inboxList.post { binding.inboxList.smoothScrollToPosition(0) } scrollToTop = false } }) viewModel.inbox.observe(viewLifecycleOwner, { inboxResource: Resource? -> if (inboxResource == null) return@observe when (inboxResource.status) { Resource.Status.SUCCESS -> binding.swipeRefreshLayout.isRefreshing = false Resource.Status.ERROR -> { if (inboxResource.message != null) { Snackbar.make(binding.root, inboxResource.message, Snackbar.LENGTH_LONG).show() } if (inboxResource.resId != 0) { Snackbar.make(binding.root, inboxResource.resId, Snackbar.LENGTH_LONG).show() } binding.swipeRefreshLayout.isRefreshing = false } Resource.Status.LOADING -> binding.swipeRefreshLayout.isRefreshing = true } }) viewModel.pendingRequestsTotal.observe(viewLifecycleOwner, { count: Int? -> attachPendingRequestsBadge(count) }) } @SuppressLint("UnsafeExperimentalUsageError", "UnsafeOptInUsageError", "RestrictedApi") private fun attachPendingRequestsBadge(count: Int?) { val pendingRequestsMenuItem1 = pendingRequestsMenuItem if (pendingRequestsMenuItem1 == null) { val handler = Handler(Looper.getMainLooper()) handler.postDelayed({ attachPendingRequestsBadge(count) }, 500) return } if (pendingRequestTotalBadgeDrawable == null) { val context = context ?: return pendingRequestTotalBadgeDrawable = BadgeDrawable.create(context) } if (count == null || count == 0) { val menuItemView = ToolbarUtils.getActionMenuItemView( fragmentActivity.getToolbar(), pendingRequestsMenuItem1.itemId ) if (menuItemView != null) { BadgeUtils.detachBadgeDrawable(pendingRequestTotalBadgeDrawable, fragmentActivity.getToolbar(), pendingRequestsMenuItem1.itemId) } isPendingRequestTotalBadgeAttached = false pendingRequestTotalBadgeDrawable?.number = 0 pendingRequestsMenuItem1.isVisible = false return } pendingRequestsMenuItem1.isVisible = true if (pendingRequestTotalBadgeDrawable?.number == count) return pendingRequestTotalBadgeDrawable?.number = count if (!isPendingRequestTotalBadgeAttached) { pendingRequestTotalBadgeDrawable?.let { BadgeUtils.attachBadgeDrawable(it, fragmentActivity.getToolbar(), pendingRequestsMenuItem1.itemId) isPendingRequestTotalBadgeAttached = true } } } private fun init() { val context = context ?: return setupObservers() binding.swipeRefreshLayout.setOnRefreshListener(this) binding.inboxList.setHasFixedSize(true) binding.inboxList.setItemViewCacheSize(20) val layoutManager = LinearLayoutManager(context) binding.inboxList.layoutManager = layoutManager inboxAdapter = DirectMessageInboxAdapter { thread -> val threadId = thread.threadId val threadTitle = thread.threadTitle if (navigating || threadId.isNullOrBlank() || threadTitle.isNullOrBlank()) return@DirectMessageInboxAdapter navigating = true if (isAdded) { try { val directions = DirectMessageInboxFragmentDirections.actionToThread(threadId, threadTitle) findNavController().navigate(directions) } catch (e: Exception) { Log.e(TAG, "init: ", e) } } navigating = false }.also { it.setHasStableIds(true) } binding.inboxList.adapter = inboxAdapter lazyLoader = RecyclerLazyLoaderAtEdge(layoutManager) { viewModel.fetchInbox() }.also { binding.inboxList.addOnScrollListener(it) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt ================================================ package awais.instagrabber.fragments.directmessages import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.CompoundButton import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import awais.instagrabber.R import awais.instagrabber.activities.MainActivity import awais.instagrabber.adapters.DirectPendingUsersAdapter import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUser import awais.instagrabber.adapters.DirectPendingUsersAdapter.PendingUserCallback import awais.instagrabber.adapters.DirectUsersAdapter import awais.instagrabber.customviews.helpers.TextWatcherAdapter import awais.instagrabber.databinding.FragmentDirectMessagesSettingsBinding import awais.instagrabber.dialogs.ConfirmDialogFragment import awais.instagrabber.dialogs.ConfirmDialogFragment.ConfirmDialogFragmentCallback import awais.instagrabber.dialogs.MultiOptionDialogFragment import awais.instagrabber.dialogs.MultiOptionDialogFragment.MultiOptionDialogSingleCallback import awais.instagrabber.fragments.UserSearchMode import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.DirectThreadParticipantRequestsResponse import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.utils.Utils import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.viewmodels.AppStateViewModel import awais.instagrabber.viewmodels.DirectSettingsViewModel import awais.instagrabber.viewmodels.factories.DirectSettingsViewModelFactory import com.google.android.material.snackbar.Snackbar import java.util.* class DirectMessageSettingsFragment : Fragment(), ConfirmDialogFragmentCallback { private lateinit var viewModel: DirectSettingsViewModel private lateinit var binding: FragmentDirectMessagesSettingsBinding private var usersAdapter: DirectUsersAdapter? = null private var isPendingRequestsSetupDone = false private var pendingUsersAdapter: DirectPendingUsersAdapter? = null private var approvalRequiredUsers: Set? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val arguments = arguments ?: return val args = DirectMessageSettingsFragmentArgs.fromBundle(arguments) val fragmentActivity = requireActivity() as MainActivity val appStateViewModel: AppStateViewModel by activityViewModels() val currentUser = appStateViewModel.currentUser?.data ?: return val viewModelFactory = DirectSettingsViewModelFactory( fragmentActivity.application, args.threadId, args.pending, currentUser ) viewModel = ViewModelProvider(this, viewModelFactory).get(DirectSettingsViewModel::class.java) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { binding = FragmentDirectMessagesSettingsBinding.inflate(inflater, container, false) // currentlyRunning = new DirectMessageInboxThreadFetcher(threadId, null, null, fetchListener).execute(); return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { init() setupObservers() } override fun onDestroyView() { super.onDestroyView() isPendingRequestsSetupDone = false } private fun setupObservers() { viewModel.inputMode.observe(viewLifecycleOwner, { inputMode: Int? -> if (inputMode == null || inputMode == 0) return@observe if (inputMode == 1) { binding.groupSettings.visibility = View.GONE binding.pendingMembersGroup.visibility = View.GONE binding.approvalRequired.visibility = View.GONE binding.approvalRequiredLabel.visibility = View.GONE binding.muteMessagesLabel.visibility = View.GONE binding.muteMessages.visibility = View.GONE } }) // Need to observe, so that getValue is correct viewModel.getUsers().observe(viewLifecycleOwner, { }) viewModel.getLeftUsers().observe(viewLifecycleOwner, { }) viewModel.getUsersAndLeftUsers().observe(viewLifecycleOwner, { usersAdapter?.submitUsers(it.first, it.second) }) viewModel.getTitle().observe(viewLifecycleOwner, { binding.titleEdit.setText(it) }) viewModel.getAdminUserIds().observe(viewLifecycleOwner, { usersAdapter?.setAdminUserIds(it) }) viewModel.isMuted().observe(viewLifecycleOwner, { binding.muteMessages.isChecked = it }) viewModel.isPending().observe(viewLifecycleOwner, { binding.muteMessages.visibility = if (it) View.GONE else View.VISIBLE }) viewModel.isViewerAdmin().observe(viewLifecycleOwner, { setApprovalRelatedUI(it) }) viewModel.getApprovalRequiredToJoin().observe(viewLifecycleOwner, { binding.approvalRequired.isChecked = it }) viewModel.getPendingRequests().observe(viewLifecycleOwner, { setPendingRequests(it) }) viewModel.isGroup().observe(viewLifecycleOwner, { isGroup: Boolean -> setupSettings(isGroup) }) val navController = NavHostFragment.findNavController(this) val backStackEntry = navController.currentBackStackEntry if (backStackEntry != null) { val resultLiveData = backStackEntry.savedStateHandle.getLiveData("result") resultLiveData.observe(viewLifecycleOwner, { result: Any? -> if (result == null) return@observe if (result is RankedRecipient) { val user = getUser(result) // Log.d(TAG, "result: " + user); if (user != null) { addMembers(setOf(user)) } } else if (result is Set<*>) { try { @Suppress("UNCHECKED_CAST") val recipients = result as Set val users: Set = recipients.asSequence() .filterNotNull() .map { getUser(it) } .filterNotNull() .toSet() // Log.d(TAG, "result: " + users); addMembers(users) } catch (e: Exception) { Log.e(TAG, "search users result: ", e) Snackbar.make(binding.root, e.message ?: "", Snackbar.LENGTH_LONG).show() } } }) } } private fun addMembers(users: Set) { val approvalRequired = viewModel.getApprovalRequiredToJoin().value var isViewerAdmin = viewModel.isViewerAdmin().value if (isViewerAdmin == null) { isViewerAdmin = false } if (!isViewerAdmin && approvalRequired != null && approvalRequired) { approvalRequiredUsers = users val confirmDialogFragment = ConfirmDialogFragment.newInstance( APPROVAL_REQUIRED_REQUEST_CODE, R.string.admin_approval_required, R.string.admin_approval_required_description, R.string.ok, R.string.cancel, 0 ) confirmDialogFragment.show(childFragmentManager, "approval_required_dialog") return } val detailsChangeResourceLiveData = viewModel.addMembers(users) observeDetailsChange(detailsChangeResourceLiveData) } private fun getUser(recipient: RankedRecipient): User? { var user: User? = null if (recipient.user != null) { user = recipient.user } else if (recipient.thread != null && !recipient.thread.isGroup) { user = recipient.thread.users?.get(0) } return user } private fun init() { // setupSettings(); setupMembers() } private fun setupSettings(isGroup: Boolean) { binding.groupSettings.visibility = if (isGroup) View.VISIBLE else View.GONE binding.muteMessagesLabel.setOnClickListener { binding.muteMessages.toggle() } binding.muteMessages.setOnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean -> val resourceLiveData = if (isChecked) viewModel.mute() else viewModel.unmute() handleSwitchChangeResource(resourceLiveData, buttonView) } if (!isGroup) return binding.titleEdit.addTextChangedListener(object : TextWatcherAdapter() { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { if (s.toString().trim { it <= ' ' } == viewModel.getTitle().value) { binding.titleEditInputLayout.suffixText = null return } binding.titleEditInputLayout.suffixText = getString(R.string.save) } }) binding.titleEditInputLayout.suffixTextView.setOnClickListener { val text = binding.titleEdit.text ?: return@setOnClickListener val newTitle = text.toString().trim { it <= ' ' } if (newTitle == viewModel.getTitle().value) return@setOnClickListener observeDetailsChange(viewModel.updateTitle(newTitle)) } binding.addMembers.setOnClickListener { if (!isAdded) return@setOnClickListener try { val navController = findNavController() if (navController.currentDestination?.id != R.id.directMessagesSettingsFragment) return@setOnClickListener val users = viewModel.getUsers().value ?: return@setOnClickListener val currentUserIds = users.asSequence().map(User::pk).sorted().toList().toLongArray() val actionGlobalUserSearch = DirectMessageSettingsFragmentDirections.actionToUserSearch().apply { title = getString(R.string.add_members) actionLabel = getString(R.string.add) hideUserIds = currentUserIds searchMode = UserSearchMode.RAVEN multiple = true } navController.navigate(actionGlobalUserSearch) } catch (e: Exception) { Log.e(TAG, "setupSettings: ", e) } } binding.muteMentionsLabel.setOnClickListener { binding.muteMentions.toggle() } binding.muteMentions.setOnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean -> val resourceLiveData = if (isChecked) viewModel.muteMentions() else viewModel.unmuteMentions() handleSwitchChangeResource(resourceLiveData, buttonView) } binding.leave.setOnClickListener { val confirmDialogFragment = ConfirmDialogFragment.newInstance( LEAVE_THREAD_REQUEST_CODE, R.string.dms_action_leave_question, 0, R.string.yes, R.string.no, 0 ) confirmDialogFragment.show(childFragmentManager, "leave_thread_confirmation_dialog") } var isViewerAdmin = viewModel.isViewerAdmin().value if (isViewerAdmin == null) isViewerAdmin = false if (isViewerAdmin) { binding.end.visibility = View.VISIBLE binding.end.setOnClickListener { val confirmDialogFragment = ConfirmDialogFragment.newInstance( END_THREAD_REQUEST_CODE, R.string.dms_action_end_question, R.string.dms_action_end_description, R.string.yes, R.string.no, 0 ) confirmDialogFragment.show(childFragmentManager, "end_thread_confirmation_dialog") } } else { binding.end.visibility = View.GONE } } private fun setApprovalRelatedUI(isViewerAdmin: Boolean) { if (!isViewerAdmin) { binding.pendingMembersGroup.visibility = View.GONE binding.approvalRequired.visibility = View.GONE binding.approvalRequiredLabel.visibility = View.GONE return } binding.approvalRequired.visibility = View.VISIBLE binding.approvalRequiredLabel.visibility = View.VISIBLE binding.approvalRequiredLabel.setOnClickListener { binding.approvalRequired.toggle() } binding.approvalRequired.setOnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean -> val resourceLiveData = if (isChecked) viewModel.approvalRequired() else viewModel.approvalNotRequired() handleSwitchChangeResource(resourceLiveData, buttonView) } } private fun handleSwitchChangeResource(resourceLiveData: LiveData>, buttonView: CompoundButton) { resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> if (resource == null) return@observe when (resource.status) { Resource.Status.SUCCESS -> buttonView.isEnabled = true Resource.Status.ERROR -> { buttonView.isEnabled = true buttonView.isChecked = !buttonView.isChecked if (resource.message != null) { Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() } if (resource.resId != 0) { Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() } } Resource.Status.LOADING -> buttonView.isEnabled = false } }) } private fun setupMembers() { val context = context ?: return binding.users.layoutManager = LinearLayoutManager(context) val inviter = viewModel.getInviter().value usersAdapter = DirectUsersAdapter( inviter?.pk ?: -1, { _: Int, user: User, _: Boolean -> if (user.username.isBlank() && !user.interopMessagingUserFbid.isNullOrBlank()) { Utils.openURL(context, "https://facebook.com/" + user.interopMessagingUserFbid) return@DirectUsersAdapter } if (user.username.isBlank()) return@DirectUsersAdapter try { val directions = DirectMessageSettingsFragmentDirections.actionToProfile().apply { this.username = user.username } findNavController().navigate(directions) } catch (e: Exception) { Log.e(TAG, "setupMembers: ", e) } }, { _: Int, user: User? -> val options = viewModel.createUserOptions(user) if (options.isEmpty()) return@DirectUsersAdapter true val fragment = MultiOptionDialogFragment.newInstance(0, -1, options) fragment.setSingleCallback(object : MultiOptionDialogSingleCallback { override fun onSelect(requestCode: Int, action: String?) { if (action == null) return val resourceLiveData = viewModel.doAction(user, action) if (resourceLiveData != null) { observeDetailsChange(resourceLiveData) } } override fun onCancel(requestCode: Int) {} }) val fragmentManager = childFragmentManager fragment.show(fragmentManager, "actions") true } ) binding.users.adapter = usersAdapter } private fun setPendingRequests(requests: DirectThreadParticipantRequestsResponse?) { val nullOrEmpty: Boolean = requests?.users?.isNullOrEmpty() ?: true if (nullOrEmpty) { binding.pendingMembersGroup.visibility = View.GONE return } if (!isPendingRequestsSetupDone) { val context = context ?: return binding.pendingMembers.layoutManager = LinearLayoutManager(context) pendingUsersAdapter = DirectPendingUsersAdapter(object : PendingUserCallback { override fun onClick(position: Int, pendingUser: PendingUser) { try { val directions = DirectMessageSettingsFragmentDirections.actionToProfile().apply { this.username = pendingUser.user.username } findNavController().navigate(directions) } catch (e: Exception) { Log.e(TAG, "onClick: ", e) } } override fun onApprove(position: Int, pendingUser: PendingUser) { val resourceLiveData = viewModel.approveUsers(listOf(pendingUser.user)) observeApprovalChange(resourceLiveData, position, pendingUser) } override fun onDeny(position: Int, pendingUser: PendingUser) { val resourceLiveData = viewModel.denyUsers(listOf(pendingUser.user)) observeApprovalChange(resourceLiveData, position, pendingUser) } }) binding.pendingMembers.adapter = pendingUsersAdapter binding.pendingMembersGroup.visibility = View.VISIBLE isPendingRequestsSetupDone = true } pendingUsersAdapter?.submitPendingRequests(requests) } private fun observeDetailsChange(resourceLiveData: LiveData>) { resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> if (resource == null) return@observe when (resource.status) { Resource.Status.SUCCESS, Resource.Status.LOADING, -> { } Resource.Status.ERROR -> { if (resource.message != null) { Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() } if (resource.resId != 0) { Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() } } } }) } private fun observeApprovalChange( detailsChangeResourceLiveData: LiveData>, position: Int, pendingUser: PendingUser, ) { detailsChangeResourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> if (resource == null) return@observe when (resource.status) { Resource.Status.SUCCESS -> { } Resource.Status.LOADING -> pendingUser.isInProgress = true Resource.Status.ERROR -> { pendingUser.isInProgress = false if (resource.message != null) { Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() } if (resource.resId != 0) { Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() } } } pendingUsersAdapter?.notifyItemChanged(position) }) } override fun onPositiveButtonClicked(requestCode: Int) { if (requestCode == APPROVAL_REQUIRED_REQUEST_CODE) { approvalRequiredUsers?.let { val detailsChangeResourceLiveData = viewModel.addMembers(it) observeDetailsChange(detailsChangeResourceLiveData) } return } if (requestCode == LEAVE_THREAD_REQUEST_CODE) { val resourceLiveData = viewModel.leave() resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> if (resource == null) return@observe when (resource.status) { Resource.Status.SUCCESS -> { val directions = DirectMessageSettingsFragmentDirections.actionToInbox() NavHostFragment.findNavController(this).navigate(directions) } Resource.Status.ERROR -> { binding.leave.isEnabled = true if (resource.message != null) { Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() } if (resource.resId != 0) { Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() } } Resource.Status.LOADING -> binding.leave.isEnabled = false } }) return } if (requestCode == END_THREAD_REQUEST_CODE) { val resourceLiveData = viewModel.end() resourceLiveData.observe(viewLifecycleOwner, { resource: Resource? -> if (resource == null) return@observe when (resource.status) { Resource.Status.SUCCESS -> { } Resource.Status.ERROR -> { binding.end.isEnabled = true if (resource.message != null) { Snackbar.make(binding.root, resource.message, Snackbar.LENGTH_LONG).show() } if (resource.resId != 0) { Snackbar.make(binding.root, resource.resId, Snackbar.LENGTH_LONG).show() } } Resource.Status.LOADING -> binding.end.isEnabled = false } }) } } override fun onNegativeButtonClicked(requestCode: Int) { if (requestCode == APPROVAL_REQUIRED_REQUEST_CODE) { approvalRequiredUsers = null } } override fun onNeutralButtonClicked(requestCode: Int) {} companion object { private const val APPROVAL_REQUIRED_REQUEST_CODE = 200 private const val LEAVE_THREAD_REQUEST_CODE = 201 private const val END_THREAD_REQUEST_CODE = 202 } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageThreadFragment.java ================================================ package awais.instagrabber.fragments.directmessages; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.text.Editable; import android.util.Log; import android.util.Pair; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.view.menu.ActionMenuItemView; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsAnimationCompat; import androidx.core.view.WindowInsetsAnimationControlListenerCompat; import androidx.core.view.WindowInsetsAnimationControllerCompat; import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavBackStackEntry; import androidx.navigation.NavController; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SimpleItemAnimator; import androidx.transition.TransitionManager; import androidx.vectordrawable.graphics.drawable.Animatable2Compat; import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; import com.google.android.material.badge.BadgeDrawable; import com.google.android.material.badge.BadgeUtils; import com.google.android.material.internal.ToolbarUtils; import com.google.android.material.snackbar.Snackbar; import com.google.common.collect.ImmutableList; import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.function.Function; import awais.instagrabber.R; import awais.instagrabber.activities.CameraActivity; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.DirectItemsAdapter; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemCallback; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemLongClickListener; import awais.instagrabber.adapters.DirectItemsAdapter.DirectItemOrHeader; import awais.instagrabber.adapters.DirectReactionsAdapter; import awais.instagrabber.adapters.viewholder.directmessages.DirectItemViewHolder; import awais.instagrabber.animations.CubicBezierInterpolator; import awais.instagrabber.customviews.InsetsAnimationLinearLayout; import awais.instagrabber.customviews.KeyNotifyingEmojiEditText; import awais.instagrabber.customviews.RecordView; import awais.instagrabber.customviews.Tooltip; import awais.instagrabber.customviews.emoji.Emoji; import awais.instagrabber.customviews.emoji.EmojiBottomSheetDialog; import awais.instagrabber.customviews.emoji.EmojiPicker; import awais.instagrabber.customviews.helpers.ControlFocusInsetsAnimationCallback; import awais.instagrabber.customviews.helpers.EmojiPickerInsetsAnimationCallback; import awais.instagrabber.customviews.helpers.HeaderItemDecoration; import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge; import awais.instagrabber.customviews.helpers.SimpleImeAnimationController; import awais.instagrabber.customviews.helpers.SwipeAndRestoreItemTouchHelperCallback; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.customviews.helpers.TranslateDeferringInsetsAnimationCallback; import awais.instagrabber.databinding.FragmentDirectMessagesThreadBinding; import awais.instagrabber.dialogs.DirectItemReactionDialogFragment; import awais.instagrabber.dialogs.GifPickerBottomDialogFragment; import awais.instagrabber.fragments.UserSearchMode; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.models.Resource; import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.requests.StoryViewerOptions; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemEmojiReaction; import awais.instagrabber.repositories.responses.directmessages.DirectItemLink; import awais.instagrabber.repositories.responses.directmessages.DirectItemReactions; import awais.instagrabber.repositories.responses.directmessages.DirectItemReelShare; import awais.instagrabber.repositories.responses.directmessages.DirectItemStoryShare; import awais.instagrabber.repositories.responses.directmessages.DirectItemVisualMedia; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.DMUtils; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.PermissionUtils; import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.AppStateViewModel; import awais.instagrabber.viewmodels.DirectThreadViewModel; import awais.instagrabber.viewmodels.factories.DirectThreadViewModelFactory; public class DirectMessageThreadFragment extends Fragment implements DirectReactionsAdapter.OnReactionClickListener, EmojiPicker.OnEmojiClickListener { private static final String TAG = DirectMessageThreadFragment.class.getSimpleName(); private static final int AUDIO_RECORD_PERM_REQUEST_CODE = 1000; private static final int CAMERA_REQUEST_CODE = 200; private static final int FILE_PICKER_REQUEST_CODE = 500; private static final String TRANSLATION_Y = "translationY"; private DirectItemsAdapter itemsAdapter; private MainActivity fragmentActivity; private DirectThreadViewModel viewModel; private InsetsAnimationLinearLayout root; private boolean shouldRefresh = true; private List itemOrHeaders; private FragmentDirectMessagesThreadBinding binding; private Tooltip tooltip; private float initialSendX; private ActionBar actionBar; private AppStateViewModel appStateViewModel; private Runnable prevTitleRunnable; private AnimatorSet animatorSet; private boolean isRecording; private DirectItemReactionDialogFragment reactionDialogFragment; private DirectItem itemToForward; private MutableLiveData backStackSavedStateResultLiveData; private int prevLength; private BadgeDrawable pendingRequestCountBadgeDrawable; private boolean isPendingRequestCountBadgeAttached = false; private ItemTouchHelper itemTouchHelper; private LiveData pendingLiveData; private LiveData threadLiveData; private LiveData inputModeLiveData; private LiveData threadTitleLiveData; private LiveData> fetchingLiveData; private LiveData> itemsLiveData; private LiveData replyToItemLiveData; private LiveData pendingRequestsCountLiveData; private LiveData> usersLiveData; private boolean autoMarkAsSeen = false; private MenuItem markAsSeenMenuItem; private DirectItem addReactionItem; private TranslateDeferringInsetsAnimationCallback inputHolderAnimationCallback; private TranslateDeferringInsetsAnimationCallback chatsAnimationCallback; private EmojiPickerInsetsAnimationCallback emojiPickerAnimationCallback; private boolean hasKbOpenedOnce; private boolean wasToggled; private SwipeAndRestoreItemTouchHelperCallback touchHelperCallback; private final AppExecutors appExecutors = AppExecutors.INSTANCE; private final Animatable2Compat.AnimationCallback micToSendAnimationCallback = new Animatable2Compat.AnimationCallback() { @Override public void onAnimationEnd(final Drawable drawable) { AnimatedVectorDrawableCompat.unregisterAnimationCallback(drawable, this); setSendToMicIcon(); } }; private final Animatable2Compat.AnimationCallback sendToMicAnimationCallback = new Animatable2Compat.AnimationCallback() { @Override public void onAnimationEnd(final Drawable drawable) { AnimatedVectorDrawableCompat.unregisterAnimationCallback(drawable, this); setMicToSendIcon(); } }; private final DirectItemCallback directItemCallback = new DirectItemCallback() { @Override public void onHashtagClick(final String hashtag) { try { final NavDirections action = DirectMessageThreadFragmentDirections.actionToHashtag(hashtag); NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onMentionClick(final String mention) { navigateToUser(mention); } @Override public void onLocationClick(final long locationId) { try { final NavDirections action = DirectMessageThreadFragmentDirections.actionToLocation(locationId); NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onLocationClick: ", e); } } @Override public void onURLClick(final String url) { final Context context = getContext(); if (context == null) return; Utils.openURL(context, url); } @Override public void onEmailClick(final String email) { final Context context = getContext(); if (context == null) return; Utils.openEmailAddress(context, email); } @Override public void onMediaClick(final Media media, final int index) { if (media.isReelMedia()) { try { final String pk = media.getPk(); if (pk == null) return; final long mediaId = Long.parseLong(pk); final User user = media.getUser(); if (user == null) return; final String username = user.getUsername(); final NavDirections action = DirectMessageThreadFragmentDirections.actionToStory(StoryViewerOptions.forStory(mediaId, username)); NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onMediaClick (story): ", e); } return; } try { final NavDirections actionToPost = DirectMessageThreadFragmentDirections.actionToPost(media, index); NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(actionToPost); } catch (Exception e) { Log.e(TAG, "openPostDialog: ", e); } } @Override public void onStoryClick(final DirectItemStoryShare storyShare) { try { final String pk = storyShare.getReelId(); if (pk == null) return; final long mediaId = Long.parseLong(pk); final Media media = storyShare.getMedia(); if (media == null) return; final User user = media.getUser(); if (user == null) return; final String username = user.getUsername(); final NavDirections action = DirectMessageThreadFragmentDirections.actionToStory(StoryViewerOptions.forUser(mediaId, username)); NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onStoryClick: ", e); } } @Override public void onReaction(final DirectItem item, final Emoji emoji) { if (item == null || emoji == null) return; final LiveData> resourceLiveData = viewModel.sendReaction(item, emoji); resourceLiveData.observe(getViewLifecycleOwner(), directItemResource -> handleSentMessage(resourceLiveData)); } @Override public void onReactionClick(final DirectItem item, final int position) { showReactionsDialog(item); } @Override public void onOptionSelect(final DirectItem item, final int itemId, final Function cb) { if (itemId == R.id.unsend) { handleSentMessage(viewModel.unsend(item)); return; } if (itemId == R.id.forward) { itemToForward = item; final NavDirections actionGlobalUserSearch = DirectMessageThreadFragmentDirections .actionToUserSearch() .setTitle(getString(R.string.forward)) .setActionLabel(getString(R.string.send)) .setShowGroups(true) .setMultiple(true) .setSearchMode(UserSearchMode.RAVEN); NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(actionGlobalUserSearch); } if (itemId == R.id.download) { downloadItem(item); return; } // otherwise call callback if present if (cb != null) { cb.apply(item); } } @Override public void onAddReactionListener(final DirectItem item) { if (item == null) return; addReactionItem = item; final EmojiBottomSheetDialog emojiBottomSheetDialog = EmojiBottomSheetDialog.newInstance(); emojiBottomSheetDialog.show(getChildFragmentManager(), EmojiBottomSheetDialog.TAG); } }; private final DirectItemLongClickListener directItemLongClickListener = position -> { // viewModel.setSelectedPosition(position); }; private final Observer backStackSavedStateObserver = result -> { if (result == null) return; if (result instanceof Uri) { final Uri uri = (Uri) result; handleSentMessage(viewModel.sendUri(uri)); } else if ((result instanceof RankedRecipient)) { // Log.d(TAG, "result: " + result); if (itemToForward != null) { viewModel.forward((RankedRecipient) result, itemToForward); } } else if ((result instanceof Set)) { try { // Log.d(TAG, "result: " + result); if (itemToForward != null) { //noinspection unchecked viewModel.forward((Set) result, itemToForward); } } catch (Exception e) { Log.e(TAG, "forward result: ", e); } } // clear result backStackSavedStateResultLiveData.postValue(null); }; private final MutableLiveData inputLength = new MutableLiveData<>(0); private final MutableLiveData emojiPickerVisible = new MutableLiveData<>(false); private final MutableLiveData kbVisible = new MutableLiveData<>(false); private final OnBackPressedCallback onEmojiPickerBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { emojiPickerVisible.postValue(false); } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) requireActivity(); appStateViewModel = new ViewModelProvider(fragmentActivity).get(AppStateViewModel.class); autoMarkAsSeen = Utils.settingsHelper.getBoolean(PreferenceKeys.DM_MARK_AS_SEEN); final Bundle arguments = getArguments(); if (arguments == null) return; final DirectMessageThreadFragmentArgs fragmentArgs = DirectMessageThreadFragmentArgs.fromBundle(arguments); final Resource currentUserResource = appStateViewModel.getCurrentUser(); if (currentUserResource == null) return; final User currentUser = currentUserResource.data; if (currentUser == null) return; final DirectThreadViewModelFactory viewModelFactory = new DirectThreadViewModelFactory( fragmentActivity.getApplication(), fragmentArgs.getThreadId(), fragmentArgs.getPending(), currentUser ); viewModel = new ViewModelProvider(this, viewModelFactory).get(DirectThreadViewModel.class); setHasOptionsMenu(true); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentDirectMessagesThreadBinding.inflate(inflater, container, false); binding.send.setRecordView(binding.recordView); root = binding.getRoot(); final Context context = getContext(); if (context == null) { return root; } tooltip = new Tooltip(context, root, getResources().getColor(R.color.grey_400), getResources().getColor(R.color.black)); // todo check has camera and remove view return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { // WindowCompat.setDecorFitsSystemWindows(fragmentActivity.getWindow(), false); if (!shouldRefresh) return; init(); binding.send.post(() -> initialSendX = binding.send.getX()); shouldRefresh = false; } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.dm_thread_menu, menu); markAsSeenMenuItem = menu.findItem(R.id.mark_as_seen); if (markAsSeenMenuItem != null) { if (autoMarkAsSeen) { markAsSeenMenuItem.setVisible(false); } else { markAsSeenMenuItem.setEnabled(false); } } } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { final int itemId = item.getItemId(); if (itemId == R.id.info) { final Boolean pending = viewModel.isPending().getValue(); final NavDirections directions = DirectMessageThreadFragmentDirections .actionToSettings(viewModel.getThreadId(), null) .setPending(pending != null && pending); NavHostFragment.findNavController(this).navigate(directions); return true; } if (itemId == R.id.mark_as_seen) { handleMarkAsSeen(item); return true; } if (itemId == R.id.refresh && viewModel != null) { viewModel.refreshChats(); return true; } return super.onOptionsItemSelected(item); } private void handleMarkAsSeen(@NonNull final MenuItem item) { final LiveData> resourceLiveData = viewModel.markAsSeen(); resourceLiveData.observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(final Resource resource) { try { if (resource == null) return; final Context context = getContext(); if (context == null) return; switch (resource.status) { case SUCCESS: Toast.makeText(context, R.string.marked_as_seen, Toast.LENGTH_SHORT).show(); case LOADING: item.setEnabled(false); break; case ERROR: item.setEnabled(true); if (resource.message != null) { Snackbar.make(context, binding.getRoot(), resource.message, Snackbar.LENGTH_LONG).show(); return; } if (resource.resId != 0) { Snackbar.make(binding.getRoot(), resource.resId, Snackbar.LENGTH_LONG).show(); return; } break; } } finally { resourceLiveData.removeObserver(this); } } }); } @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == FILE_PICKER_REQUEST_CODE && resultCode == Activity.RESULT_OK) { if (data == null || data.getData() == null) { Log.w(TAG, "data is null!"); return; } final Context context = getContext(); if (context == null) { Log.w(TAG, "conetxt is null!"); return; } final Uri uri = data.getData(); final String mimeType = Utils.getMimeType(uri, context.getContentResolver()); if (mimeType != null && mimeType.startsWith("image")) { navigateToImageEditFragment(uri); return; } handleSentMessage(viewModel.sendUri(uri)); } if (requestCode == CAMERA_REQUEST_CODE && resultCode == Activity.RESULT_OK) { if (data == null || data.getData() == null) { Log.w(TAG, "data is null!"); return; } final Uri uri = data.getData(); navigateToImageEditFragment(uri); } } @Override public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); final Context context = getContext(); if (context == null) return; if (requestCode == AUDIO_RECORD_PERM_REQUEST_CODE) { if (PermissionUtils.hasAudioRecordPerms(context)) { Toast.makeText(context, "You can send voice messages now!", Toast.LENGTH_LONG).show(); return; } Toast.makeText(context, "Require RECORD_AUDIO permission", Toast.LENGTH_LONG).show(); } } @Override public void onPause() { if (isRecording) { binding.recordView.cancelRecording(binding.send); } emojiPickerVisible.postValue(false); kbVisible.postValue(false); binding.inputHolder.setTranslationY(0); binding.chats.setTranslationY(0); binding.emojiPicker.setTranslationY(0); removeObservers(); super.onPause(); } @Override public void onResume() { super.onResume(); if (initialSendX != 0) { binding.send.setX(initialSendX); } binding.send.stopScale(); final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedDispatcher.addCallback(onEmojiPickerBackPressedCallback); setupBackStackResultObserver(); setObservers(); // attachPendingRequestsBadge(viewModel.getPendingRequestsCount().getValue()); } @Override public void onDestroyView() { super.onDestroyView(); cleanup(); } @Override public void onDestroy() { viewModel.deleteThreadIfRequired(); super.onDestroy(); } @SuppressLint("UnsafeOptInUsageError") private void cleanup() { if (prevTitleRunnable != null) { appExecutors.getMainThread().cancel(prevTitleRunnable); } for (int childCount = binding.chats.getChildCount(), i = 0; i < childCount; ++i) { final RecyclerView.ViewHolder holder = binding.chats.getChildViewHolder(binding.chats.getChildAt(i)); if (holder == null) continue; if (holder instanceof DirectItemViewHolder) { ((DirectItemViewHolder) holder).cleanup(); } } isPendingRequestCountBadgeAttached = false; if (pendingRequestCountBadgeDrawable != null) { @SuppressLint("RestrictedApi") final ActionMenuItemView menuItemView = ToolbarUtils .getActionMenuItemView(fragmentActivity.getToolbar(), R.id.info); if (menuItemView != null) { BadgeUtils.detachBadgeDrawable(pendingRequestCountBadgeDrawable, fragmentActivity.getToolbar(), R.id.info); } pendingRequestCountBadgeDrawable = null; } } private void init() { final Context context = getContext(); if (context == null) return; if (getArguments() == null) return; actionBar = fragmentActivity.getSupportActionBar(); setupList(); } private void setupList() { final Context context = getContext(); if (context == null) return; binding.chats.setItemViewCacheSize(20); final LinearLayoutManager layoutManager = new LinearLayoutManager(context); layoutManager.setReverseLayout(true); // layoutManager.setStackFromEnd(false); // binding.messageList.addItemDecoration(new VerticalSpaceItemDecoration(3)); final RecyclerView.ItemAnimator animator = binding.chats.getItemAnimator(); if (animator instanceof SimpleItemAnimator) { final SimpleItemAnimator itemAnimator = (SimpleItemAnimator) animator; itemAnimator.setSupportsChangeAnimations(false); } binding.chats.setLayoutManager(layoutManager); binding.chats.addOnScrollListener(new RecyclerLazyLoaderAtEdge(layoutManager, true, page -> viewModel.fetchChats())); final HeaderItemDecoration headerItemDecoration = new HeaderItemDecoration(binding.chats, itemPosition -> { if (itemOrHeaders == null || itemOrHeaders.isEmpty()) return false; try { final DirectItemOrHeader itemOrHeader = itemOrHeaders.get(itemPosition); return itemOrHeader.isHeader(); } catch (IndexOutOfBoundsException e) { return false; } }); binding.chats.addItemDecoration(headerItemDecoration); } private void setObservers() { if (viewModel == null) return; threadLiveData = viewModel.getThread(); // if (threadLiveData == null) { // final NavController navController = NavHostFragment.findNavController(this); // navController.navigateUp(); // return; // } pendingLiveData = viewModel.isPending(); pendingLiveData.observe(getViewLifecycleOwner(), isPending -> { if (isPending == null) { hideInput(); return; } if (isPending) { showPendingOptions(); return; } hidePendingOptions(); final Integer inputMode = viewModel.getInputMode().getValue(); if (inputMode != null && inputMode == 1) return; showInput(); }); inputModeLiveData = viewModel.getInputMode(); inputModeLiveData.observe(getViewLifecycleOwner(), inputMode -> { final Boolean isPending = viewModel.isPending().getValue(); if (isPending != null && isPending || inputMode == null) return; setupInput(inputMode); if (inputMode == 0) { setupTouchHelper(); return; } if (inputMode == 1) { hideInput(); } }); threadTitleLiveData = viewModel.getThreadTitle(); threadTitleLiveData.observe(getViewLifecycleOwner(), this::setTitle); fetchingLiveData = viewModel.isFetching(); fetchingLiveData.observe(getViewLifecycleOwner(), fetchingResource -> { if (fetchingResource == null) return; switch (fetchingResource.status) { case SUCCESS: case ERROR: setTitle(viewModel.getThreadTitle().getValue()); if (fetchingResource.message != null) { Snackbar.make(binding.getRoot(), fetchingResource.message, Snackbar.LENGTH_LONG).show(); } if (fetchingResource.resId != 0) { Snackbar.make(binding.getRoot(), fetchingResource.resId, Snackbar.LENGTH_LONG).show(); } break; case LOADING: setTitle(getString(R.string.dms_thread_updating)); break; } }); // final ItemsAdapterDataMerger itemsAdapterDataMerger = new ItemsAdapterDataMerger(appStateViewModel.getCurrentUser(), viewModel.getThread()); // itemsAdapterDataMerger.observe(getViewLifecycleOwner(), userThreadPair -> { // viewModel.setCurrentUser(userThreadPair.first); // setupItemsAdapter(userThreadPair.first, userThreadPair.second); // }); threadLiveData.observe(getViewLifecycleOwner(), this::setupItemsAdapter); itemsLiveData = viewModel.getItems(); itemsLiveData.observe(getViewLifecycleOwner(), this::submitItemsToAdapter); replyToItemLiveData = viewModel.getReplyToItem(); replyToItemLiveData.observe(getViewLifecycleOwner(), item -> { if (item == null) { if (binding.input.length() == 0) { showExtraInputOption(true); } binding.getRoot().post(() -> { TransitionManager.beginDelayedTransition(binding.getRoot()); binding.replyBg.setVisibility(View.GONE); binding.replyInfo.setVisibility(View.GONE); binding.replyPreviewImage.setVisibility(View.GONE); binding.replyCancel.setVisibility(View.GONE); binding.replyPreviewText.setVisibility(View.GONE); }); return; } showExtraInputOption(false); binding.getRoot().postDelayed(() -> { binding.replyBg.setVisibility(View.VISIBLE); binding.replyInfo.setVisibility(View.VISIBLE); binding.replyPreviewImage.setVisibility(View.VISIBLE); binding.replyCancel.setVisibility(View.VISIBLE); binding.replyPreviewText.setVisibility(View.VISIBLE); if (item.getUserId() == viewModel.getViewerId()) { binding.replyInfo.setText(R.string.replying_to_yourself); } else { final User user = viewModel.getUser(item.getUserId()); if (user != null) { binding.replyInfo.setText(getString(R.string.replying_to_user, user.getFullName())); } else { binding.replyInfo.setVisibility(View.GONE); } } final String previewText = getDirectItemPreviewText(item); binding.replyPreviewText.setText(TextUtils.isEmpty(previewText) ? getString(R.string.message) : previewText); final String previewImageUrl = getDirectItemPreviewImageUrl(item); if (TextUtils.isEmpty(previewImageUrl)) { binding.replyPreviewImage.setVisibility(View.GONE); } else { binding.replyPreviewImage.setImageURI(previewImageUrl); } binding.replyCancel.setOnClickListener(v -> viewModel.setReplyToItem(null)); }, 200); }); inputLength.observe(getViewLifecycleOwner(), length -> { if (length == null) return; final boolean hasReplyToItem = viewModel.getReplyToItem().getValue() != null; if (hasReplyToItem) { prevLength = length; return; } if ((prevLength == 0 && length != 0) || (prevLength != 0 && length == 0)) { showExtraInputOption(length == 0); } prevLength = length; }); pendingRequestsCountLiveData = viewModel.getPendingRequestsCount(); pendingRequestsCountLiveData.observe(getViewLifecycleOwner(), this::attachPendingRequestsBadge); usersLiveData = viewModel.getUsers(); usersLiveData.observe(getViewLifecycleOwner(), users -> { if (users == null || users.isEmpty()) return; final User user = users.get(0); binding.acceptPendingRequestQuestion.setText(getString(R.string.accept_request_from_user, user.getUsername(), user.getFullName())); }); } private void setupTouchHelper() { final Context context = getContext(); if (context == null) return; touchHelperCallback = new SwipeAndRestoreItemTouchHelperCallback( context, (adapterPosition, viewHolder) -> { if (itemsAdapter == null) return; final DirectItemOrHeader directItemOrHeader = itemsAdapter.getList().get(adapterPosition); if (directItemOrHeader.isHeader()) return; viewModel.setReplyToItem(directItemOrHeader.item); } ); itemTouchHelper = new ItemTouchHelper(touchHelperCallback); itemTouchHelper.attachToRecyclerView(binding.chats); } private void removeObservers() { pendingLiveData.removeObservers(getViewLifecycleOwner()); inputModeLiveData.removeObservers(getViewLifecycleOwner()); threadTitleLiveData.removeObservers(getViewLifecycleOwner()); fetchingLiveData.removeObservers(getViewLifecycleOwner()); threadLiveData.removeObservers(getViewLifecycleOwner()); itemsLiveData.removeObservers(getViewLifecycleOwner()); replyToItemLiveData.removeObservers(getViewLifecycleOwner()); inputLength.removeObservers(getViewLifecycleOwner()); pendingRequestsCountLiveData.removeObservers(getViewLifecycleOwner()); usersLiveData.removeObservers(getViewLifecycleOwner()); } private void hidePendingOptions() { binding.acceptPendingRequestQuestion.setVisibility(View.GONE); binding.decline.setVisibility(View.GONE); binding.accept.setVisibility(View.GONE); } private void showPendingOptions() { binding.acceptPendingRequestQuestion.setVisibility(View.VISIBLE); binding.decline.setVisibility(View.VISIBLE); binding.accept.setVisibility(View.VISIBLE); binding.accept.setOnClickListener(v -> { final LiveData> resourceLiveData = viewModel.acceptRequest(); handlePendingChangeResource(resourceLiveData, false); }); binding.decline.setOnClickListener(v -> { final LiveData> resourceLiveData = viewModel.declineRequest(); handlePendingChangeResource(resourceLiveData, true); }); } private void handlePendingChangeResource(final LiveData> resourceLiveData, final boolean isDecline) { resourceLiveData.observe(getViewLifecycleOwner(), resource -> { if (resource == null) return; final Resource.Status status = resource.status; switch (status) { case SUCCESS: resourceLiveData.removeObservers(getViewLifecycleOwner()); if (isDecline) { removeObservers(); viewModel.removeThread(); final NavController navController = NavHostFragment.findNavController(this); navController.navigateUp(); return; } removeObservers(); viewModel.moveFromPending(); setObservers(); break; case LOADING: break; case ERROR: if (resource.message != null) { Snackbar.make(binding.getRoot(), resource.message, Snackbar.LENGTH_LONG).show(); } if (resource.resId != 0) { Snackbar.make(binding.getRoot(), resource.resId, Snackbar.LENGTH_LONG).show(); } resourceLiveData.removeObservers(getViewLifecycleOwner()); break; } }); } private void hideInput() { binding.emojiToggle.setVisibility(View.GONE); binding.gif.setVisibility(View.GONE); binding.camera.setVisibility(View.GONE); binding.gallery.setVisibility(View.GONE); binding.input.setVisibility(View.GONE); binding.inputBg.setVisibility(View.GONE); binding.recordView.setVisibility(View.GONE); binding.send.setVisibility(View.GONE); if (itemTouchHelper != null) { itemTouchHelper.attachToRecyclerView(null); } } private void showInput() { binding.emojiToggle.setVisibility(View.VISIBLE); binding.gif.setVisibility(View.VISIBLE); binding.camera.setVisibility(View.VISIBLE); binding.gallery.setVisibility(View.VISIBLE); binding.input.setVisibility(View.VISIBLE); binding.inputBg.setVisibility(View.VISIBLE); binding.recordView.setVisibility(View.VISIBLE); binding.send.setVisibility(View.VISIBLE); if (itemTouchHelper != null) { itemTouchHelper.attachToRecyclerView(binding.chats); } } @SuppressLint("UnsafeOptInUsageError") private void attachPendingRequestsBadge(@Nullable final Integer count) { if (pendingRequestCountBadgeDrawable == null) { final Context context = getContext(); if (context == null) return; pendingRequestCountBadgeDrawable = BadgeDrawable.create(context); } if (count == null || count == 0) { @SuppressLint("RestrictedApi") final ActionMenuItemView menuItemView = ToolbarUtils .getActionMenuItemView(fragmentActivity.getToolbar(), R.id.info); if (menuItemView != null) { BadgeUtils.detachBadgeDrawable(pendingRequestCountBadgeDrawable, fragmentActivity.getToolbar(), R.id.info); } isPendingRequestCountBadgeAttached = false; pendingRequestCountBadgeDrawable.setNumber(0); return; } if (pendingRequestCountBadgeDrawable.getNumber() == count) return; pendingRequestCountBadgeDrawable.setNumber(count); if (!isPendingRequestCountBadgeAttached) { BadgeUtils.attachBadgeDrawable(pendingRequestCountBadgeDrawable, fragmentActivity.getToolbar(), R.id.info); isPendingRequestCountBadgeAttached = true; } } private void showExtraInputOption(final boolean show) { if (show) { if (!binding.send.isListenForRecord()) { binding.send.setListenForRecord(true); startIconAnimation(); } binding.gif.setVisibility(View.VISIBLE); binding.camera.setVisibility(View.VISIBLE); binding.gallery.setVisibility(View.VISIBLE); return; } if (binding.send.isListenForRecord()) { binding.send.setListenForRecord(false); startIconAnimation(); } binding.gif.setVisibility(View.GONE); binding.camera.setVisibility(View.GONE); binding.gallery.setVisibility(View.GONE); } private String getDirectItemPreviewText(@NonNull final DirectItem item) { final DirectItemType itemType = item.getItemType(); if (itemType == null) return ""; switch (itemType) { case TEXT: return item.getText(); case LINK: final DirectItemLink link = item.getLink(); if (link == null) return ""; return link.getText(); case MEDIA: { final Media media = item.getMedia(); if (media == null) return ""; return getMediaPreviewTextString(media); } case RAVEN_MEDIA: { final DirectItemVisualMedia visualMedia = item.getVisualMedia(); if (visualMedia == null) return ""; final Media media = visualMedia.getMedia(); if (media == null) return ""; return getMediaPreviewTextString(media); } case VOICE_MEDIA: return getString(R.string.voice_message); case MEDIA_SHARE: return getString(R.string.post); case REEL_SHARE: final DirectItemReelShare reelShare = item.getReelShare(); if (reelShare == null) return ""; return reelShare.getText(); } return ""; } @NonNull private String getMediaPreviewTextString(@NonNull final Media media) { final MediaItemType mediaType = media.getType(); if (mediaType == null) return ""; switch (mediaType) { case MEDIA_TYPE_IMAGE: return getString(R.string.photo); case MEDIA_TYPE_VIDEO: return getString(R.string.video); default: return ""; } } @Nullable private String getDirectItemPreviewImageUrl(@NonNull final DirectItem item) { final DirectItemType itemType = item.getItemType(); if (itemType == null) return null; switch (itemType) { case TEXT: case LINK: case VOICE_MEDIA: case REEL_SHARE: return null; case MEDIA: { final Media media = item.getMedia(); return ResponseBodyUtils.getThumbUrl(media); } case RAVEN_MEDIA: { final DirectItemVisualMedia visualMedia = item.getVisualMedia(); if (visualMedia == null) return null; final Media media = visualMedia.getMedia(); return ResponseBodyUtils.getThumbUrl(media); } case MEDIA_SHARE: { final Media media = item.getMediaShare(); return ResponseBodyUtils.getThumbUrl(media); } } return null; } private void setupBackStackResultObserver() { final NavController navController = NavHostFragment.findNavController(this); final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry(); if (backStackEntry != null) { backStackSavedStateResultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); backStackSavedStateResultLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); } } private void submitItemsToAdapter(final List items) { binding.chats.post(() -> { if (autoMarkAsSeen) { viewModel.markAsSeen(); return; } final DirectThread thread = threadLiveData.getValue(); if (thread == null) return; if (markAsSeenMenuItem != null) { markAsSeenMenuItem.setEnabled(!DMUtils.isRead(thread)); } }); if (itemsAdapter == null) return; itemsAdapter.submitList(items, () -> { itemOrHeaders = itemsAdapter.getList(); binding.chats.post(() -> { final RecyclerView.LayoutManager layoutManager = binding.chats.getLayoutManager(); if (layoutManager instanceof LinearLayoutManager) { final int position = ((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition(); if (position < 0) return; if (position == itemsAdapter.getItemCount() - 1) { viewModel.fetchChats(); } } }); }); } private void setupItemsAdapter(final DirectThread thread) { if (thread == null) return; if (itemsAdapter != null) { if (itemsAdapter.getThread() == thread) return; itemsAdapter.setThread(thread); return; } final Resource currentUserResource = appStateViewModel.getCurrentUser(); if (currentUserResource == null) return; final User currentUser = currentUserResource.data; if (currentUser == null) return; itemsAdapter = new DirectItemsAdapter(currentUser, thread, directItemCallback, directItemLongClickListener); itemsAdapter.setHasStableIds(true); itemsAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); binding.chats.setAdapter(itemsAdapter); registerDataObserver(); final List items = viewModel.getItems().getValue(); if (items != null && itemsAdapter.getItems() != items) { submitItemsToAdapter(items); } } private void registerDataObserver() { itemsAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { @Override public void onItemRangeInserted(final int positionStart, final int itemCount) { super.onItemRangeInserted(positionStart, itemCount); final LinearLayoutManager layoutManager = (LinearLayoutManager) binding.chats.getLayoutManager(); if (layoutManager == null) return; int firstVisiblePosition = layoutManager.findFirstCompletelyVisibleItemPosition(); if ((firstVisiblePosition == -1 || firstVisiblePosition == 0) && (positionStart == 0)) { binding.chats.scrollToPosition(0); } } }); } private void setupInput(@Nullable final Integer inputMode) { if (inputMode != null && inputMode == 1) return; final Context context = getContext(); if (context == null) return; tooltip.setText(R.string.dms_thread_audio_hint); setMicToSendIcon(); binding.recordView.setMinMillis(1000); binding.recordView.setOnRecordListener(new RecordView.OnRecordListener() { @Override public void onStart() { isRecording = true; binding.input.setHint(null); binding.gif.setVisibility(View.GONE); binding.camera.setVisibility(View.GONE); binding.gallery.setVisibility(View.GONE); if (PermissionUtils.hasAudioRecordPerms(context)) { viewModel.startRecording(); return; } PermissionUtils.requestAudioRecordPerms(DirectMessageThreadFragment.this, AUDIO_RECORD_PERM_REQUEST_CODE); } @Override public void onCancel() { Log.d(TAG, "onCancel"); // binding.input.setHint("Message"); viewModel.stopRecording(true); isRecording = false; } @Override public void onFinish(final long recordTime) { Log.d(TAG, "onFinish"); binding.input.setHint("Message"); binding.gif.setVisibility(View.VISIBLE); binding.camera.setVisibility(View.VISIBLE); binding.gallery.setVisibility(View.VISIBLE); viewModel.stopRecording(false); isRecording = false; } @Override public void onLessThanMin() { Log.d(TAG, "onLessThanMin"); binding.input.setHint("Message"); if (PermissionUtils.hasAudioRecordPerms(context)) { tooltip.show(binding.send); } binding.gif.setVisibility(View.VISIBLE); binding.camera.setVisibility(View.VISIBLE); binding.gallery.setVisibility(View.VISIBLE); viewModel.stopRecording(true); isRecording = false; } }); binding.recordView.setOnBasketAnimationEndListener(() -> { binding.input.setHint(R.string.dms_thread_message_hint); binding.gif.setVisibility(View.VISIBLE); binding.camera.setVisibility(View.VISIBLE); binding.gallery.setVisibility(View.VISIBLE); }); binding.input.addTextChangedListener(new TextWatcherAdapter() { // int prevLength = 0; @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { final int length = s.length(); inputLength.postValue(length); } }); binding.send.setOnRecordClickListener(v -> { final Editable text = binding.input.getText(); if (TextUtils.isEmpty(text)) return; final LiveData> resourceLiveData = viewModel.sendText(text.toString()); resourceLiveData.observe(getViewLifecycleOwner(), resource -> handleSentMessage(resourceLiveData)); binding.input.setText(""); viewModel.setReplyToItem(null); }); binding.send.setOnRecordLongClickListener(v -> { Log.d(TAG, "setOnRecordLongClickListener"); return true; }); binding.input.setOnFocusChangeListener((v, hasFocus) -> { if (!hasFocus) return; final Boolean emojiPickerVisibleValue = emojiPickerVisible.getValue(); if (emojiPickerVisibleValue == null || !emojiPickerVisibleValue) return; inputHolderAnimationCallback.setShouldTranslate(false); chatsAnimationCallback.setShouldTranslate(false); emojiPickerAnimationCallback.setShouldTranslate(false); }); setupInsetsCallback(); setupEmojiPicker(); binding.gallery.setOnClickListener(v -> { final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.setType("*/*"); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{ "image/*", "video/mp4" }); startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); }); binding.gif.setOnClickListener(v -> { final GifPickerBottomDialogFragment gifPicker = GifPickerBottomDialogFragment.newInstance(); gifPicker.setOnSelectListener(giphyGif -> { gifPicker.dismiss(); if (giphyGif == null) return; handleSentMessage(viewModel.sendAnimatedMedia(giphyGif)); }); gifPicker.show(getChildFragmentManager(), "GifPicker"); }); binding.camera.setOnClickListener(v -> { final Intent intent = new Intent(context, CameraActivity.class); startActivityForResult(intent, CAMERA_REQUEST_CODE); }); } private void setupInsetsCallback() { inputHolderAnimationCallback = new TranslateDeferringInsetsAnimationCallback( binding.inputHolder, WindowInsetsCompat.Type.systemBars(), WindowInsetsCompat.Type.ime(), WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE ); ViewCompat.setWindowInsetsAnimationCallback(binding.inputHolder, inputHolderAnimationCallback); chatsAnimationCallback = new TranslateDeferringInsetsAnimationCallback( binding.chats, WindowInsetsCompat.Type.systemBars(), WindowInsetsCompat.Type.ime() ); ViewCompat.setWindowInsetsAnimationCallback(binding.chats, chatsAnimationCallback); emojiPickerAnimationCallback = new EmojiPickerInsetsAnimationCallback( binding.emojiPicker, WindowInsetsCompat.Type.systemBars(), WindowInsetsCompat.Type.ime() ); emojiPickerAnimationCallback.setKbVisibilityListener(this::onKbVisibilityChange); ViewCompat.setWindowInsetsAnimationCallback(binding.emojiPicker, emojiPickerAnimationCallback); ViewCompat.setWindowInsetsAnimationCallback( binding.input, new ControlFocusInsetsAnimationCallback(binding.input) ); final SimpleImeAnimationController imeAnimController = root.getImeAnimController(); if (imeAnimController != null) { imeAnimController.setAnimationControlListener(new WindowInsetsAnimationControlListenerCompat() { @Override public void onReady(@NonNull final WindowInsetsAnimationControllerCompat controller, final int types) {} @Override public void onFinished(@NonNull final WindowInsetsAnimationControllerCompat controller) { checkKbVisibility(); } @Override public void onCancelled(@Nullable final WindowInsetsAnimationControllerCompat controller) { checkKbVisibility(); } private void checkKbVisibility() { final WindowInsetsCompat rootWindowInsets = ViewCompat.getRootWindowInsets(binding.getRoot()); final boolean visible = rootWindowInsets != null && rootWindowInsets.isVisible(WindowInsetsCompat.Type.ime()); onKbVisibilityChange(visible); } }); } } private void onKbVisibilityChange(final boolean kbVisible) { this.kbVisible.postValue(kbVisible); if (wasToggled) { emojiPickerVisible.postValue(!kbVisible); wasToggled = false; return; } final Boolean emojiPickerVisibleValue = emojiPickerVisible.getValue(); if (kbVisible && emojiPickerVisibleValue != null && emojiPickerVisibleValue) { emojiPickerVisible.postValue(false); return; } if (!kbVisible) { emojiPickerVisible.postValue(false); } } private void startIconAnimation() { final Drawable icon = binding.send.getIcon(); if (icon instanceof Animatable) { final Animatable animatable = (Animatable) icon; if (animatable.isRunning()) { animatable.stop(); } animatable.start(); } } private void navigateToImageEditFragment(final String path) { navigateToImageEditFragment(Uri.fromFile(new File(path))); } private void navigateToImageEditFragment(final Uri uri) { try { final NavDirections navDirections = DirectMessageThreadFragmentDirections.actionToImageEdit(uri); NavHostFragment.findNavController(this).navigate(navDirections); } catch (Exception e) { Log.e(TAG, "navigateToImageEditFragment: ", e); } } private void handleSentMessage(final LiveData> resourceLiveData) { final Resource resource = resourceLiveData.getValue(); if (resource == null) return; final Resource.Status status = resource.status; switch (status) { case SUCCESS: resourceLiveData.removeObservers(getViewLifecycleOwner()); break; case LOADING: break; case ERROR: if (resource.message != null) { Snackbar.make(binding.getRoot(), resource.message, Snackbar.LENGTH_LONG).show(); } if (resource.resId != 0) { Snackbar.make(binding.getRoot(), resource.resId, Snackbar.LENGTH_LONG).show(); } resourceLiveData.removeObservers(getViewLifecycleOwner()); break; } } private void setupEmojiPicker() { root.post(() -> binding.emojiPicker.init( root, (view, emoji) -> { final KeyNotifyingEmojiEditText input = binding.input; final int start = input.getSelectionStart(); final int end = input.getSelectionEnd(); if (start < 0) { input.append(emoji.getUnicode()); return; } input.getText().replace( Math.min(start, end), Math.max(start, end), emoji.getUnicode(), 0, emoji.getUnicode().length() ); }, () -> binding.input.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) )); binding.emojiToggle.setOnClickListener(v -> { Boolean isEmojiPickerVisible = emojiPickerVisible.getValue(); if (isEmojiPickerVisible == null) isEmojiPickerVisible = false; Boolean isKbVisible = kbVisible.getValue(); if (isKbVisible == null) isKbVisible = false; wasToggled = isEmojiPickerVisible || isKbVisible; if (isEmojiPickerVisible) { if (hasKbOpenedOnce && binding.emojiPicker.getTranslationY() != 0) { inputHolderAnimationCallback.setShouldTranslate(false); chatsAnimationCallback.setShouldTranslate(false); emojiPickerAnimationCallback.setShouldTranslate(false); } // trigger ime. // Since the kb visibility listener will toggle the emojiPickerVisible live data, we do not explicitly toggle it here showKeyboard(); return; } if (isKbVisible) { // hide the keyboard, but don't translate the views // Since the kb visibility listener will toggle the emojiPickerVisible live data, we do not explicitly toggle it here inputHolderAnimationCallback.setShouldTranslate(false); chatsAnimationCallback.setShouldTranslate(false); emojiPickerAnimationCallback.setShouldTranslate(false); hideKeyboard(); } emojiPickerVisible.postValue(true); }); final LiveData> emojiKbVisibilityLD = Utils.zipLiveData(emojiPickerVisible, kbVisible); emojiKbVisibilityLD.observe(getViewLifecycleOwner(), pair -> { Boolean isEmojiPickerVisible = pair.first; Boolean isKbVisible = pair.second; if (isEmojiPickerVisible == null) isEmojiPickerVisible = false; if (isKbVisible == null) isKbVisible = false; root.setScrollImeOffScreenWhenVisible(!isEmojiPickerVisible); root.setScrollImeOnScreenWhenNotVisible(!isEmojiPickerVisible); onEmojiPickerBackPressedCallback.setEnabled(isEmojiPickerVisible && !isKbVisible); if (isEmojiPickerVisible && !isKbVisible) { animatePan(binding.emojiPicker.getMeasuredHeight(), unused -> { binding.emojiPicker.setAlpha(1); binding.emojiToggle.setIconResource(R.drawable.ic_keyboard_24); return null; }, null); return; } if (!isEmojiPickerVisible && !isKbVisible) { animatePan(0, null, unused -> { binding.emojiPicker.setAlpha(0); binding.emojiToggle.setIconResource(R.drawable.ic_face_24); return null; }); return; } // isKbVisible will always be true going forward hasKbOpenedOnce = true; if (!isEmojiPickerVisible) { binding.emojiToggle.setIconResource(R.drawable.ic_face_24); binding.emojiPicker.setAlpha(0); return; } binding.emojiPicker.setAlpha(1); }); } public void showKeyboard() { final Context context = getContext(); if (context == null) return; final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; if (!binding.input.isFocused()) { binding.input.requestFocus(); } final boolean shown = imm.showSoftInput(binding.input, InputMethodManager.SHOW_IMPLICIT); if (!shown) { Log.e(TAG, "showKeyboard: System did not display the keyboard"); } } public void hideKeyboard() { final Context context = getContext(); if (context == null) return; final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; imm.hideSoftInputFromWindow(binding.input.getWindowToken(), InputMethodManager.RESULT_UNCHANGED_SHOWN); } private void setSendToMicIcon() { final Context context = getContext(); if (context == null) return; final Drawable sendToMicDrawable = Utils.getAnimatableDrawable(context, R.drawable.avd_send_to_mic_anim); if (sendToMicDrawable instanceof Animatable) { AnimatedVectorDrawableCompat.registerAnimationCallback(sendToMicDrawable, sendToMicAnimationCallback); } binding.send.setIcon(sendToMicDrawable); } private void setMicToSendIcon() { final Context context = getContext(); if (context == null) return; final Drawable micToSendDrawable = Utils.getAnimatableDrawable(context, R.drawable.avd_mic_to_send_anim); if (micToSendDrawable instanceof Animatable) { AnimatedVectorDrawableCompat.registerAnimationCallback(micToSendDrawable, micToSendAnimationCallback); } binding.send.setIcon(micToSendDrawable); } private void setTitle(final String title) { if (actionBar == null) return; if (prevTitleRunnable != null) { appExecutors.getMainThread().cancel(prevTitleRunnable); } prevTitleRunnable = () -> actionBar.setTitle(title); // set title delayed to avoid title blink if fetch is fast appExecutors.getMainThread().execute(prevTitleRunnable, 1000); } private void downloadItem(final DirectItem item) { final Context context = getContext(); if (context == null) return; final DirectItemType itemType = item.getItemType(); if (itemType == null) return; //noinspection SwitchStatementWithTooFewBranches switch (itemType) { case VOICE_MEDIA: downloadItem(context, item.getVoiceMedia() == null ? null : item.getVoiceMedia().getMedia()); break; default: break; } } // currently ONLY for voice private void downloadItem(@NonNull final Context context, final Media media) { if (media == null) { Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); return; } DownloadUtils.download(context, media); Toast.makeText(context, R.string.downloader_downloading_media, Toast.LENGTH_SHORT).show(); } // Sets the translationY of views to height with animation private void animatePan(final int height, @Nullable final Function onAnimationStart, @Nullable final Function onAnimationEnd) { if (animatorSet != null && animatorSet.isStarted()) { animatorSet.cancel(); } final ImmutableList.Builder builder = ImmutableList.builder(); builder.add( ObjectAnimator.ofFloat(binding.chats, TRANSLATION_Y, -height), ObjectAnimator.ofFloat(binding.inputHolder, TRANSLATION_Y, -height), ObjectAnimator.ofFloat(binding.emojiPicker, TRANSLATION_Y, -height) ); // if (headerItemDecoration != null && headerItemDecoration.getCurrentHeader() != null) { // builder.add(ObjectAnimator.ofFloat(headerItemDecoration.getCurrentHeader(), TRANSLATION_Y, height)); // } animatorSet = new AnimatorSet(); animatorSet.playTogether(builder.build()); animatorSet.setDuration(200); animatorSet.setInterpolator(CubicBezierInterpolator.EASE_IN); animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(final Animator animation) { super.onAnimationStart(animation); if (onAnimationStart != null) { onAnimationStart.apply(null); } } @Override public void onAnimationEnd(final Animator animation) { super.onAnimationEnd(animation); animatorSet = null; if (onAnimationEnd != null) { onAnimationEnd.apply(null); } } }); animatorSet.start(); } private void showReactionsDialog(final DirectItem item) { final LiveData> users = viewModel.getUsers(); final LiveData> leftUsers = viewModel.getLeftUsers(); final ArrayList allUsers = new ArrayList<>(); allUsers.add(viewModel.getCurrentUser()); if (users.getValue() != null) { allUsers.addAll(users.getValue()); } if (leftUsers.getValue() != null) { allUsers.addAll(leftUsers.getValue()); } final String itemId = item.getItemId(); if (itemId == null) return; final DirectItemReactions reactions = item.getReactions(); if (reactions == null) return; reactionDialogFragment = DirectItemReactionDialogFragment.newInstance( viewModel.getViewerId(), allUsers, itemId, reactions ); reactionDialogFragment.show(getChildFragmentManager(), "reactions_dialog"); } @Override public void onReactionClick(final String itemId, final DirectItemEmojiReaction reaction) { if (reactionDialogFragment != null) { reactionDialogFragment.dismiss(); } if (itemId == null || reaction == null) return; if (reaction.getSenderId() == viewModel.getViewerId()) { final LiveData> resourceLiveData = viewModel.sendDeleteReaction(itemId); resourceLiveData.observe(getViewLifecycleOwner(), directItemResource -> handleSentMessage(resourceLiveData)); return; } // navigate to user final User user = viewModel.getUser(reaction.getSenderId()); if (user == null) return; navigateToUser(user.getUsername()); } private void navigateToUser(@NonNull final String username) { try { final NavDirections direction = DirectMessageThreadFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(DirectMessageThreadFragment.this).navigate(direction); } catch (Exception e) { Log.e(TAG, "navigateToUser: ", e); } } @Override public void onClick(final View view, final Emoji emoji) { if (addReactionItem == null || emoji == null) return; final LiveData> resourceLiveData = viewModel.sendReaction(addReactionItem, emoji); resourceLiveData.observe(getViewLifecycleOwner(), directItemResource -> handleSentMessage(resourceLiveData)); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/directmessages/DirectPendingInboxFragment.kt ================================================ package awais.instagrabber.fragments.directmessages import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Observer import androidx.navigation.fragment.NavHostFragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import awais.instagrabber.activities.MainActivity import awais.instagrabber.adapters.DirectMessageInboxAdapter import awais.instagrabber.customviews.helpers.RecyclerLazyLoaderAtEdge import awais.instagrabber.databinding.FragmentDirectPendingInboxBinding import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.directmessages.DirectInbox import awais.instagrabber.repositories.responses.directmessages.DirectThread import awais.instagrabber.viewmodels.DirectPendingInboxViewModel import com.google.android.material.snackbar.Snackbar class DirectPendingInboxFragment : Fragment(), OnRefreshListener { private val viewModel: DirectPendingInboxViewModel by activityViewModels() private lateinit var root: CoordinatorLayout private lateinit var lazyLoader: RecyclerLazyLoaderAtEdge private lateinit var binding: FragmentDirectPendingInboxBinding private lateinit var fragmentActivity: MainActivity private lateinit var inboxAdapter: DirectMessageInboxAdapter private var shouldRefresh = true private var scrollToTop = false private var navigating = false private var threadsObserver: Observer>? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) fragmentActivity = requireActivity() as MainActivity } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { if (this::root.isInitialized) { shouldRefresh = false return root } binding = FragmentDirectPendingInboxBinding.inflate(inflater, container, false) root = binding.root return root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (!shouldRefresh) return init() } override fun onRefresh() { lazyLoader.resetState() scrollToTop = true viewModel.refresh() } override fun onResume() { super.onResume() setupObservers() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) init() } override fun onDestroy() { super.onDestroy() removeViewModelObservers() viewModel.onDestroy() } private fun setupObservers() { removeViewModelObservers() threadsObserver = Observer { list: List? -> if (!this::inboxAdapter.isInitialized) return@Observer if (binding.swipeRefreshLayout.visibility == View.GONE) { binding.swipeRefreshLayout.visibility = View.VISIBLE binding.empty.visibility = View.GONE } inboxAdapter.submitList(list ?: emptyList()) { if (!scrollToTop) return@submitList binding.pendingInboxList.smoothScrollToPosition(0) scrollToTop = false } if (list == null || list.isEmpty()) { binding.swipeRefreshLayout.visibility = View.GONE binding.empty.visibility = View.VISIBLE } } threadsObserver?.let { viewModel.threads.observe(fragmentActivity, it) } viewModel.inbox.observe(viewLifecycleOwner, { inboxResource: Resource? -> if (inboxResource == null) return@observe when (inboxResource.status) { Resource.Status.SUCCESS -> binding.swipeRefreshLayout.isRefreshing = false Resource.Status.ERROR -> { if (inboxResource.message != null) { Snackbar.make(binding.root, inboxResource.message, Snackbar.LENGTH_LONG).show() } binding.swipeRefreshLayout.isRefreshing = false } Resource.Status.LOADING -> binding.swipeRefreshLayout.isRefreshing = true } }) } private fun removeViewModelObservers() { threadsObserver?.let { viewModel.threads.removeObserver(it) } } private fun init() { val context = context ?: return setupObservers() binding.swipeRefreshLayout.setOnRefreshListener(this) binding.pendingInboxList.setHasFixedSize(true) binding.pendingInboxList.setItemViewCacheSize(20) val layoutManager = LinearLayoutManager(context) binding.pendingInboxList.layoutManager = layoutManager inboxAdapter = DirectMessageInboxAdapter { thread -> if (navigating) return@DirectMessageInboxAdapter val threadId = thread.threadId ?: return@DirectMessageInboxAdapter val threadTitle = thread.threadTitle ?: return@DirectMessageInboxAdapter navigating = true if (isAdded) { val directions = DirectPendingInboxFragmentDirections.actionToThread(threadId, threadTitle) directions.pending = true NavHostFragment.findNavController(this).navigate(directions) } navigating = false } inboxAdapter.setHasStableIds(true) binding.pendingInboxList.adapter = inboxAdapter lazyLoader = RecyclerLazyLoaderAtEdge(layoutManager) { viewModel.fetchInbox() } binding.pendingInboxList.addOnScrollListener(lazyLoader) } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/FiltersFragment.java ================================================ package awais.instagrabber.fragments.imageedit; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatTextView; import androidx.constraintlayout.widget.Barrier; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SimpleItemAnimator; import com.google.android.material.slider.Slider; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.Serializable; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import awais.instagrabber.adapters.FiltersAdapter; import awais.instagrabber.databinding.FragmentFiltersBinding; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper.FilterType; import awais.instagrabber.fragments.imageedit.filters.filters.Filter; import awais.instagrabber.fragments.imageedit.filters.filters.FilterFactory; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.BitmapUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.SerializablePair; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.FiltersFragmentViewModel; import awais.instagrabber.viewmodels.ImageEditViewModel; import jp.co.cyberagent.android.gpuimage.GPUImage; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; import kotlinx.coroutines.Dispatchers; public class FiltersFragment extends Fragment { private static final String TAG = FiltersFragment.class.getSimpleName(); private static final String ARGS_SOURCE_URI = "source_uri"; private static final String ARGS_DEST_URI = "dest_uri"; private static final String ARGS_TUNING_FILTERS = "tuning_filters"; private static final String ARGS_FILTER = "filter"; private static final String ARGS_TAB = "tab"; private final Map> tuningFilters = new HashMap<>(); private final Map, Integer> propertySliderIdMap = new HashMap<>(); private GPUImageFilterGroup filterGroup; private Filter appliedFilter; private FragmentFiltersBinding binding; private AppExecutors appExecutors; private Uri sourceUri; private Uri destUri; private FiltersFragmentViewModel viewModel; private boolean isFilterGroupSet = false; private FilterCallback callback; private FiltersAdapter filtersAdapter; private HashMap> initialTuningFiltersValues; private SerializablePair> initialFilter; @NonNull public static FiltersFragment newInstance(@NonNull final Uri sourceUri, @NonNull final Uri destUri, @NonNull final ImageEditViewModel.Tab tab) { return newInstance(sourceUri, destUri, null, null, tab); } @NonNull public static FiltersFragment newInstance(@NonNull final Uri sourceUri, @NonNull final Uri destUri, final HashMap> appliedTuningFilters, final SerializablePair> appliedFilter, @NonNull final ImageEditViewModel.Tab tab) { final Bundle args = new Bundle(); args.putParcelable(ARGS_SOURCE_URI, sourceUri); args.putParcelable(ARGS_DEST_URI, destUri); if (appliedTuningFilters != null) { args.putSerializable(ARGS_TUNING_FILTERS, appliedTuningFilters); } if (appliedFilter != null) { args.putSerializable(ARGS_FILTER, appliedFilter); } args.putString(ARGS_TAB, tab.name()); final FiltersFragment fragment = new FiltersFragment(); fragment.setArguments(args); return fragment; } public FiltersFragment() { filterGroup = new GPUImageFilterGroup(); filterGroup.addFilter(new GPUImageFilter()); } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); appExecutors = AppExecutors.INSTANCE; viewModel = new ViewModelProvider(this).get(FiltersFragmentViewModel.class); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = FragmentFiltersBinding.inflate(inflater, container, false); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { init(savedInstanceState); } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); final ImageEditViewModel.Tab tab = viewModel.getCurrentTab().getValue(); if (tab != null) { outState.putString(ARGS_TAB, tab.name()); } } @Override public void onPause() { super.onPause(); // binding.preview.onPause(); } @Override public void onResume() { super.onResume(); // binding.preview.onResume(); } @Override public void onDestroyView() { super.onDestroyView(); for (final GPUImageFilter filter : filterGroup.getFilters()) { filter.destroy(); } filterGroup.getFilters().clear(); filterGroup.destroy(); } private void init(final Bundle savedInstanceState) { setupObservers(); final Bundle arguments = getArguments(); if (arguments == null) return; final Parcelable uriParcelable = arguments.getParcelable(ARGS_SOURCE_URI); if (!(uriParcelable instanceof Uri)) return; sourceUri = (Uri) uriParcelable; final Parcelable destUriParcelable = arguments.getParcelable(ARGS_DEST_URI); if (!(destUriParcelable instanceof Uri)) return; destUri = (Uri) destUriParcelable; final Serializable tuningFiltersSerializable = arguments.getSerializable(ARGS_TUNING_FILTERS); if (tuningFiltersSerializable instanceof HashMap) { try { //noinspection unchecked initialTuningFiltersValues = (HashMap>) tuningFiltersSerializable; } catch (Exception e) { Log.e(TAG, "init: ", e); } } final Serializable filterSerializable = arguments.getSerializable(ARGS_FILTER); if (filterSerializable instanceof SerializablePair) { try { //noinspection unchecked initialFilter = (SerializablePair>) filterSerializable; } catch (Exception e) { Log.e(TAG, "init: ", e); } } final Context context = getContext(); if (context == null) return; binding.preview.setScaleType(GPUImage.ScaleType.CENTER_INSIDE); appExecutors.getTasksThread().execute(() -> { binding.preview.setImage(sourceUri); setPreviewBounds(); }); setCurrentTab(ImageEditViewModel.Tab.valueOf(savedInstanceState != null && savedInstanceState.containsKey(ARGS_TAB) ? savedInstanceState.getString(ARGS_TAB) : arguments.getString(ARGS_TAB))); binding.cancel.setOnClickListener(v -> { if (callback == null) return; callback.onCancel(); }); binding.reset.setOnClickListener(v -> { final ImageEditViewModel.Tab tab = viewModel.getCurrentTab().getValue(); if (tab == ImageEditViewModel.Tab.TUNE) { final Collection> filters = tuningFilters.values(); for (final Filter filter : filters) { if (filter == null) continue; filter.reset(); } resetSliders(); } if (tab == ImageEditViewModel.Tab.FILTERS) { final List groupFilters = filterGroup.getFilters(); if (appliedFilter != null) { groupFilters.remove(appliedFilter.getInstance()); appliedFilter = null; } if (filtersAdapter != null) { filtersAdapter.setSelected(0); } binding.preview.post(() -> binding.preview.setFilter(filterGroup = new GPUImageFilterGroup(groupFilters))); } }); binding.apply.setOnClickListener(v -> { if (callback == null) return; final List> appliedTunings = getAppliedTunings(); appExecutors.getTasksThread().submit(() -> { final Bitmap bitmap = binding.preview.getGPUImage().getBitmapWithFilterApplied(); try { BitmapUtils.convertToJpegAndSaveToUri(context, bitmap, destUri); callback.onApply(destUri, appliedTunings, appliedFilter); } catch (Exception e) { Log.e(TAG, "init: ", e); } }); }); } @NonNull private List> getAppliedTunings() { return tuningFilters .values() .stream() .filter(Objects::nonNull) .filter(filter -> { final Map> propertyMap = filter.getProperties(); if (propertyMap == null) return false; final Collection> properties = propertyMap.values(); return properties.stream() .noneMatch(property -> { final Object value = property.getValue(); if (value == null) { return false; } return value.equals(property.getDefaultValue()); }); }) .collect(Collectors.toList()); } private void resetSliders() { final Set, Integer>> entries = propertySliderIdMap.entrySet(); for (final Map.Entry, Integer> entry : entries) { final Property property = entry.getKey(); final Integer viewId = entry.getValue(); final Slider slider = (Slider) binding.getRoot().findViewById(viewId); if (slider == null) continue; final Object defaultValue = property.getDefaultValue(); if (!(defaultValue instanceof Float)) continue; slider.setValue((float) defaultValue); } } private void setPreviewBounds() { InputStream inputStream = null; try { final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; final Context context = getContext(); if (context == null) return; inputStream = context.getContentResolver().openInputStream(sourceUri); BitmapFactory.decodeStream(inputStream, null, options); final float ratio = (float) options.outWidth / options.outHeight; appExecutors.getMainThread().execute(() -> { final ViewGroup.LayoutParams previewLayoutParams = binding.preview.getLayoutParams(); if (options.outHeight > options.outWidth) { previewLayoutParams.width = (int) (binding.preview.getHeight() * ratio); } else { previewLayoutParams.height = (int) (binding.preview.getWidth() / ratio); } binding.preview.setRatio(ratio); binding.preview.requestLayout(); }); } catch (FileNotFoundException e) { Log.e(TAG, "setPreviewBounds: ", e); } finally { if (inputStream != null) { try { inputStream.close(); } catch (IOException ignored) {} } } } private void setupObservers() { viewModel.isLoading().observe(getViewLifecycleOwner(), loading -> { }); viewModel.getCurrentTab().observe(getViewLifecycleOwner(), tab -> { switch (tab) { case TUNE: setupTuning(); break; case FILTERS: setupFilters(); break; default: break; } }); } private void setupTuning() { initTuningControls(); binding.filters.setVisibility(View.GONE); binding.tuneControlsWrapper.setVisibility(View.VISIBLE); } private void initTuningControls() { final Context context = getContext(); if (context == null) return; final ConstraintLayout controlsParent = new ConstraintLayout(context); controlsParent.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); final Barrier sliderBarrier = new Barrier(context); sliderBarrier.setId(Barrier.generateViewId()); sliderBarrier.setType(Barrier.START); controlsParent.addView(sliderBarrier); binding.tuneControlsWrapper.addView(controlsParent); final int labelPadding = Utils.convertDpToPx(8); final List> tuneFilters = FiltersHelper.getTuneFilters(); Slider previousSlider = null; // Need to iterate backwards for (int i = tuneFilters.size() - 1; i >= 0; i--) { final Filter tuneFilter = tuneFilters.get(i); if (tuneFilter.getProperties() == null || tuneFilter.getProperties().isEmpty() || tuneFilter.getProperties().size() > 1) continue; final int propKey = tuneFilter.getProperties().keySet().iterator().next(); final Property property = tuneFilter.getProperties().values().iterator().next(); if (!(property instanceof FloatProperty)) continue; final GPUImageFilter filterInstance = tuneFilter.getInstance(); tuningFilters.put(tuneFilter.getType(), tuneFilter); filterGroup.addFilter(filterInstance); final FloatProperty floatProperty = (FloatProperty) property; final Slider slider = new Slider(context); final int viewId = Slider.generateViewId(); slider.setId(viewId); propertySliderIdMap.put(floatProperty, viewId); final ConstraintLayout.LayoutParams sliderLayoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, ConstraintLayout.LayoutParams.WRAP_CONTENT); sliderLayoutParams.startToEnd = sliderBarrier.getId(); sliderLayoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID; if (previousSlider == null) { sliderLayoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID; } else { sliderLayoutParams.bottomToTop = previousSlider.getId(); final ConstraintLayout.LayoutParams prevSliderLayoutParams = (ConstraintLayout.LayoutParams) previousSlider.getLayoutParams(); prevSliderLayoutParams.topToBottom = slider.getId(); } if (i == 0) { sliderLayoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID; } slider.setLayoutParams(sliderLayoutParams); slider.setValueFrom(floatProperty.getMinValue()); slider.setValueTo(floatProperty.getMaxValue()); float defaultValue = floatProperty.getDefaultValue(); if (initialTuningFiltersValues != null && initialTuningFiltersValues.containsKey(tuneFilter.getType())) { final Map valueMap = initialTuningFiltersValues.get(tuneFilter.getType()); if (valueMap != null) { final Object value = valueMap.get(propKey); if (value instanceof Float) { defaultValue = (float) value; tuneFilter.adjust(propKey, value); } } } slider.setValue(defaultValue); slider.addOnChangeListener((slider1, value, fromUser) -> { final Filter filter = tuningFilters.get(tuneFilter.getType()); if (filter != null) { tuneFilter.adjust(propKey, value); } binding.preview.post(() -> binding.preview.requestRender()); }); final AppCompatTextView label = new AppCompatTextView(context); label.setId(AppCompatTextView.generateViewId()); final ConstraintLayout.LayoutParams labelLayoutParams = new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.WRAP_CONTENT, ConstraintLayout.LayoutParams.MATCH_CONSTRAINT); labelLayoutParams.topToTop = slider.getId(); labelLayoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID; labelLayoutParams.endToStart = sliderBarrier.getId(); labelLayoutParams.bottomToBottom = slider.getId(); labelLayoutParams.horizontalBias = 1; label.setLayoutParams(labelLayoutParams); label.setGravity(Gravity.CENTER); label.setPadding(labelPadding, labelPadding, labelPadding, labelPadding); label.setText(tuneFilter.getLabel()); controlsParent.addView(label); controlsParent.addView(slider); previousSlider = slider; } addInitialFilter(); if (!isFilterGroupSet) { isFilterGroupSet = true; binding.preview.post(() -> binding.preview.setFilter(filterGroup)); } } private void addInitialFilter() { if (initialFilter == null) return; final Filter instance = FilterFactory.getInstance(initialFilter.first); if (instance == null) return; addFilterToGroup(instance, initialFilter.second); appliedFilter = instance; } private void setupFilters() { final Context context = getContext(); if (context == null) return; addTuneFilters(); binding.filters.setVisibility(View.VISIBLE); final RecyclerView.ItemAnimator animator = binding.filters.getItemAnimator(); if (animator instanceof SimpleItemAnimator) { final SimpleItemAnimator itemAnimator = (SimpleItemAnimator) animator; itemAnimator.setSupportsChangeAnimations(false); } binding.tuneControlsWrapper.setVisibility(View.GONE); binding.filters.setLayoutManager(new LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)); final FiltersAdapter.OnFilterClickListener onFilterClickListener = (position, filter) -> { if (appliedFilter != null && appliedFilter.equals(filter)) return; final List filters = filterGroup.getFilters(); if (appliedFilter != null) { // remove applied filter from current filter list filters.remove(appliedFilter.getInstance()); } // add the new filter filters.add(filter.getInstance()); filterGroup = new GPUImageFilterGroup(filters); binding.preview.post(() -> binding.preview.setFilter(filterGroup)); filtersAdapter.setSelected(position); appliedFilter = filter; }; BitmapUtils.getThumbnail( context, sourceUri, CoroutineUtilsKt.getContinuation((bitmapResult, throwable) -> appExecutors.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "setupFilters: ", throwable); return; } if (bitmapResult == null || bitmapResult.getBitmap() == null) { return; } filtersAdapter = new FiltersAdapter( tuningFilters.values() .stream() .map(Filter::getInstance) .collect(Collectors.toList()), sourceUri.toString(), bitmapResult.getBitmap(), onFilterClickListener ); binding.filters.setAdapter(filtersAdapter); filtersAdapter.submitList(FiltersHelper.getFilters(), () -> { if (appliedFilter == null) return; filtersAdapter.setSelectedFilter(appliedFilter.getInstance()); }); }), Dispatchers.getIO()) ); addInitialFilter(); binding.preview.setFilter(filterGroup); } private void addTuneFilters() { if (initialTuningFiltersValues == null) return; final List> tuneFilters = FiltersHelper.getTuneFilters(); for (final Filter tuneFilter : tuneFilters) { if (!initialTuningFiltersValues.containsKey(tuneFilter.getType())) continue; addFilterToGroup(tuneFilter, initialTuningFiltersValues.get(tuneFilter.getType())); } } private void addFilterToGroup(@NonNull final Filter tuneFilter, final Map valueMap) { final GPUImageFilter filter = tuneFilter.getInstance(); filterGroup.addFilter(filter); if (valueMap == null) return; final Set> entries = valueMap.entrySet(); for (final Map.Entry entry : entries) { tuneFilter.adjust(entry.getKey(), entry.getValue()); } } public void setCurrentTab(final ImageEditViewModel.Tab tab) { viewModel.setCurrentTab(tab); } public void setCallback(final FilterCallback callback) { if (callback == null) return; this.callback = callback; } public interface FilterCallback { void onApply(final Uri uri, List> tuningFilters, Filter filter); void onCancel(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/ImageEditFragment.java ================================================ package awais.instagrabber.fragments.imageedit; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.RectF; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.SavedStateHandle; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavBackStackEntry; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.imagepipeline.request.ImageRequestBuilder; import com.yalantis.ucrop.UCrop; import com.yalantis.ucrop.UCropActivity; import com.yalantis.ucrop.UCropFragment; import com.yalantis.ucrop.UCropFragmentCallback; import java.io.File; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.databinding.FragmentImageEditBinding; import awais.instagrabber.fragments.imageedit.filters.filters.Filter; import awais.instagrabber.models.SavedImageEditState; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.ImageEditViewModel; public class ImageEditFragment extends Fragment { private static final String TAG = ImageEditFragment.class.getSimpleName(); private static final String ARGS_URI = "uri"; private static final String FILTERS_FRAGMENT_TAG = "Filters"; private FragmentImageEditBinding binding; private ImageEditViewModel viewModel; private ImageEditViewModel.Tab previousTab; private FiltersFragment filtersFragment; private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { setEnabled(false); remove(); if (previousTab != ImageEditViewModel.Tab.CROP && previousTab != ImageEditViewModel.Tab.TUNE && previousTab != ImageEditViewModel.Tab.FILTERS) { return; } final FragmentManager fragmentManager = getChildFragmentManager(); final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); fragmentTransaction.setReorderingAllowed(true) .remove(previousTab == ImageEditViewModel.Tab.CROP ? uCropFragment : filtersFragment) .commit(); viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); } }; private FragmentActivity fragmentActivity; private UCropFragment uCropFragment; public static ImageEditFragment newInstance(final Uri uri) { final Bundle args = new Bundle(); args.putParcelable(ARGS_URI, uri); final ImageEditFragment fragment = new ImageEditFragment(); fragment.setArguments(args); return fragment; } public ImageEditFragment() {} @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = getActivity(); viewModel = new ViewModelProvider(this).get(ImageEditViewModel.class); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = FragmentImageEditBinding.inflate(inflater, container, false); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { init(); } @Override public void onDestroy() { super.onDestroy(); } @Override public void onDestroyView() { super.onDestroyView(); } private void init() { setupObservers(); final Bundle arguments = getArguments(); if (arguments == null) return; final Parcelable parcelable = arguments.getParcelable(ARGS_URI); Uri originalUri = null; if (parcelable instanceof Uri) { originalUri = (Uri) parcelable; } if (originalUri == null) return; viewModel.setOriginalUri(originalUri); viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); } private void setupObservers() { viewModel.isLoading().observe(getViewLifecycleOwner(), loading -> {}); viewModel.getCurrentTab().observe(getViewLifecycleOwner(), tab -> { if (tab == null) return; switch (tab) { case RESULT: setupResult(); break; case CROP: setupCropFragment(); break; case TUNE: case FILTERS: setupFilterFragment(); break; } previousTab = tab; }); viewModel.isCropped().observe(getViewLifecycleOwner(), isCropped -> binding.crop.setSelected(isCropped)); viewModel.isTuned().observe(getViewLifecycleOwner(), isTuned -> binding.tune.setSelected(isTuned)); viewModel.isFiltered().observe(getViewLifecycleOwner(), isFiltered -> binding.filters.setSelected(isFiltered)); viewModel.getResultUri().observe(getViewLifecycleOwner(), uri -> { if (uri == null) { binding.preview.setController(null); return; } binding.preview.setController(Fresco.newDraweeControllerBuilder() .setImageRequest(ImageRequestBuilder.newBuilderWithSource(uri) .disableDiskCache() .disableMemoryCache() .build()) .build()); }); } private void setupResult() { binding.fragmentContainerView.setVisibility(View.GONE); binding.cropBottomControls.setVisibility(View.GONE); binding.preview.setVisibility(View.VISIBLE); binding.resultBottomControls.setVisibility(View.VISIBLE); binding.crop.setOnClickListener(v -> viewModel.setCurrentTab(ImageEditViewModel.Tab.CROP)); binding.tune.setOnClickListener(v -> viewModel.setCurrentTab(ImageEditViewModel.Tab.TUNE)); binding.filters.setOnClickListener(v -> viewModel.setCurrentTab(ImageEditViewModel.Tab.FILTERS)); binding.cancel.setOnClickListener(v -> { viewModel.cancel(); final NavController navController = NavHostFragment.findNavController(this); setNavControllerResult(navController, null); navController.navigateUp(); }); binding.done.setOnClickListener(v -> { final Context context = getContext(); if (context == null) return; final Uri resultUri = viewModel.getResultUri().getValue(); if (resultUri == null) return; AppExecutors.INSTANCE.getMainThread().execute(() -> { final NavController navController = NavHostFragment.findNavController(this); setNavControllerResult(navController, resultUri); navController.navigateUp(); }); // Utils.mediaScanFile(context, new File(resultUri.toString()), (path, uri) -> ); }); } private void setNavControllerResult(@NonNull final NavController navController, final Uri resultUri) { final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry(); if (navBackStackEntry == null) return; final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle(); savedStateHandle.set("result", resultUri); } private void setupCropFragment() { final Context context = getContext(); if (context == null) return; binding.preview.setVisibility(View.GONE); binding.resultBottomControls.setVisibility(View.GONE); binding.fragmentContainerView.setVisibility(View.VISIBLE); binding.cropBottomControls.setVisibility(View.VISIBLE); final UCrop.Options options = new UCrop.Options(); options.setCompressionFormat(Bitmap.CompressFormat.JPEG); options.setFreeStyleCropEnabled(true); options.setAllowedGestures(UCropActivity.SCALE, UCropActivity.ROTATE, UCropActivity.ALL); final UCrop uCrop = UCrop.of(viewModel.getOriginalUri(), viewModel.getCropDestinationUri()).withOptions(options); final SavedImageEditState savedState = viewModel.getSavedImageEditState(); if (savedState != null && savedState.getCropImageMatrixValues() != null && savedState.getCropRect() != null) { uCrop.withSavedState(savedState.getCropImageMatrixValues(), savedState.getCropRect()); } uCropFragment = uCrop.getFragment(uCrop.getIntent(context).getExtras()); final FragmentManager fragmentManager = getChildFragmentManager(); uCropFragment.setCallback(new UCropFragmentCallback() { @Override public void loadingProgress(final boolean showLoader) { Log.d(TAG, "loadingProgress: " + showLoader); } @Override public void onCropFinish(final UCropFragment.UCropResult result) { Log.d(TAG, "onCropFinish: " + result.mResultCode); if (result.mResultCode == AppCompatActivity.RESULT_OK) { final Intent resultData = result.mResultData; final Bundle extras = resultData.getExtras(); if (extras == null) return; final Object uri = extras.get(UCrop.EXTRA_OUTPUT_URI); final Object imageMatrixValues = extras.get(UCrop.EXTRA_IMAGE_MATRIX_VALUES); final Object cropRect = extras.get(UCrop.EXTRA_CROP_RECT); if (uri instanceof Uri && imageMatrixValues instanceof float[] && cropRect instanceof RectF) { Log.d(TAG, "onCropFinish: result uri: " + uri); viewModel.setCropResult((float[]) imageMatrixValues, (RectF) cropRect); viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); } } } }); final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); fragmentTransaction.setReorderingAllowed(true) .replace(R.id.fragment_container_view, uCropFragment, UCropFragment.TAG) .commit(); if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } binding.cropCancel.setOnClickListener(v -> onBackPressedCallback.handleOnBackPressed()); binding.cropReset.setOnClickListener(v -> uCropFragment.reset()); binding.cropDone.setOnClickListener(v -> uCropFragment.cropAndSaveImage()); } private void setupFilterFragment() { binding.resultBottomControls.setVisibility(View.GONE); binding.preview.setVisibility(View.GONE); binding.cropBottomControls.setVisibility(View.GONE); binding.fragmentContainerView.setVisibility(View.VISIBLE); final Boolean isCropped = viewModel.isCropped().getValue(); final Uri uri = isCropped != null && isCropped ? viewModel.getCropDestinationUri() : viewModel.getOriginalUri(); final ImageEditViewModel.Tab value = viewModel.getCurrentTab().getValue(); final SavedImageEditState savedImageEditState = viewModel.getSavedImageEditState(); filtersFragment = FiltersFragment.newInstance( uri, viewModel.getDestinationUri(), savedImageEditState.getAppliedTuningFilters(), savedImageEditState.getAppliedFilter(), value == null ? ImageEditViewModel.Tab.TUNE : value ); final FragmentManager fragmentManager = getChildFragmentManager(); final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); fragmentTransaction.setReorderingAllowed(true) .replace(R.id.fragment_container_view, filtersFragment, FILTERS_FRAGMENT_TAG) .commit(); if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } filtersFragment.setCallback(new FiltersFragment.FilterCallback() { @Override public void onApply(final Uri uri, final List> tuningFilters, final Filter filter) { viewModel.setAppliedFilters(tuningFilters, filter); viewModel.setCurrentTab(ImageEditViewModel.Tab.RESULT); } @Override public void onCancel() { onBackPressedCallback.handleOnBackPressed(); } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/FiltersHelper.java ================================================ package awais.instagrabber.fragments.imageedit.filters; import com.google.common.collect.ImmutableList; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.fragments.imageedit.filters.filters.Filter; import awais.instagrabber.fragments.imageedit.filters.filters.FilterFactory; public final class FiltersHelper { public static List> getTuneFilters() { return TUNING_FILTERS.stream() .map(FilterFactory::getInstance) .filter(Objects::nonNull) .collect(Collectors.toList()); } public static List> getFilters() { // Return all non-tuning filters return Arrays.stream(FilterType.values()) .filter(filterType -> !TUNING_FILTERS.contains(filterType)) .map(FilterFactory::getInstance) .filter(Objects::nonNull) .collect(Collectors.toList()); } public static final List TUNING_FILTERS = ImmutableList.of( FilterType.BRIGHTNESS, FilterType.CONTRAST, FilterType.VIBRANCE, FilterType.SATURATION, FilterType.SHARPEN, FilterType.EXPOSURE ); public enum FilterType { // Tune BRIGHTNESS, CONTRAST, VIBRANCE, SATURATION, SHARPEN, EXPOSURE, // Filters NORMAL, SEPIA, CLARENDON, ONE977, ADEN, VIGNETTE, // BILATERAL_BLUR, // BOX_BLUR, // BULGE_DISTORTION, // CGA_COLORSPACE, // COLOR_BALANCE, // CROSSHATCH, // DILATION, // EMBOSS, // FALSE_COLOR, // GAMMA, // GAUSSIAN_BLUR, // GLASS_SPHERE, // GRAYSCALE, // HALFTONE, // HAZE, // HIGHLIGHT_SHADOW, // HUE, // INVERT, // KUWAHARA, // LAPLACIAN, // LEVELS_FILTER_MIN, // LOOKUP_AMATORKA, // LUMINANCE, // LUMINANCE_THRESHOLD, // MONOCHROME, // NON_MAXIMUM_SUPPRESSION, // OPACITY, // PIXELATION, // POSTERIZE, // RGB, // RGB_DILATION, // SKETCH, // SMOOTH_TOON, // SOBEL_EDGE_DETECTION, // SOLARIZE, // SPHERE_REFRACTION, // SWIRL, // THREE_X_THREE_CONVOLUTION, // THRESHOLD_EDGE_DETECTION, // TONE_CURVE, // TOON, // TRANSFORM2D, // WEAK_PIXEL_INCLUSION, // ZOOM_BLUR // Can be separate tunings // WHITE_BALANCE, // BLEND_ADD, // BLEND_ALPHA, // BLEND_CHROMA_KEY, // BLEND_COLOR, // BLEND_COLOR_BURN, // BLEND_COLOR_DODGE, // BLEND_DARKEN, // BLEND_DIFFERENCE, // BLEND_DISSOLVE, // BLEND_DIVIDE, // BLEND_EXCLUSION, // BLEND_HARD_LIGHT, // BLEND_HUE, // BLEND_LIGHTEN, // BLEND_LINEAR_BURN, // BLEND_LUMINOSITY, // BLEND_MULTIPLY, // BLEND_NORMAL, // BLEND_OVERLAY, // BLEND_SATURATION, // BLEND_SCREEN, // BLEND_SOFT_LIGHT, // BLEND_SOURCE_OVER, // BLEND_SUBTRACT, } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImage1977Filter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.custom; import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageContrastFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; import jp.co.cyberagent.android.gpuimage.filter.GPUImageHueFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; public class GPUImage1977Filter extends GPUImageFilterGroup { public GPUImage1977Filter() { addFilter(new GPUImageSepiaToneFilter(0.35f)); addFilter(new GPUImageHueFilter(-30f)); addFilter(new GPUImageSaturationFilter(1.4f)); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageAdenFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.custom; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; import jp.co.cyberagent.android.gpuimage.filter.GPUImageMultiplyBlendFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; public class GPUImageAdenFilter extends GPUImageFilterGroup { public GPUImageAdenFilter() { super(); addFilter(new GPUImageSepiaToneFilter(0.2f)); addFilter(new GPUImageBrightnessFilter(0.125f)); addFilter(new GPUImageSaturationFilter(1.4f)); final GPUImageMultiplyBlendFilter blendFilter = new GPUImageMultiplyBlendFilter(); final Bitmap bitmap = Bitmap.createBitmap(5, 5, Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(bitmap); canvas.drawColor(Color.argb((int) (0.1 * 255), 125, 105, 24)); blendFilter.setBitmap(bitmap); addFilter(blendFilter); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/custom/GPUImageClarendonFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.custom; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageContrastFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; import jp.co.cyberagent.android.gpuimage.filter.GPUImageHueFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageOverlayBlendFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; public class GPUImageClarendonFilter extends GPUImageFilterGroup { public GPUImageClarendonFilter() { super(); addFilter(new GPUImageBrightnessFilter(0.15f)); addFilter(new GPUImageContrastFilter(1.25f)); addFilter(new GPUImageSaturationFilter(1.15f)); addFilter(new GPUImageSepiaToneFilter(0.15f)); addFilter(new GPUImageHueFilter(5)); final GPUImageOverlayBlendFilter blendFilter = new GPUImageOverlayBlendFilter(); final Bitmap bitmap = Bitmap.createBitmap(5, 5, Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(bitmap); canvas.drawColor(Color.argb((int) (0.4 * 255), 127, 187, 227)); blendFilter.setBitmap(bitmap); addFilter(blendFilter); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/AdenFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.custom.GPUImageAdenFilter; import awais.instagrabber.fragments.imageedit.filters.properties.Property; public class AdenFilter extends Filter { private final GPUImageAdenFilter filter; public AdenFilter() { super(FiltersHelper.FilterType.ADEN, R.string.aden); filter = new GPUImageAdenFilter(); } @Override public GPUImageAdenFilter getInstance() { return filter; } @Override public Map> getProperties() { return null; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BilateralBlurFilter.java ================================================ // package awais.instagrabber.fragments.imageedit.filters.filters; // // import java.util.Collections; // import java.util.Map; // // import awais.instagrabber.R; // import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; // import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; // import awais.instagrabber.fragments.imageedit.filters.properties.Property; // import jp.co.cyberagent.android.gpuimage.filter.GPUImageBilateralBlurFilter; // // public class BilateralBlurFilter extends Filter { // private static final int PROP_DISTANCE = 0; // // private final GPUImageBilateralBlurFilter filter; // private final Map> properties; // // public BilateralBlurFilter() { // super(FiltersHelper.FilterType.BILATERAL_BLUR, R.string.bilateral_blur); // properties = Collections.singletonMap( // PROP_DISTANCE, new FloatProperty(-1, 8f, 0f, 15.0f) // ); // filter = new GPUImageBilateralBlurFilter((Float) getProperty(PROP_DISTANCE).getDefaultValue()); // } // // @Override // public Map> getProperties() { // return properties; // } // // @Override // public void adjust(final int property, final Object value) { // super.adjust(property, value); // if (!(value instanceof Float)) return; // filter.setDistanceNormalizationFactor((Float) value); // } // // @Override // public GPUImageBilateralBlurFilter getInstance() { // return filter; // } // } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BoxBlurFilter.java ================================================ // package awais.instagrabber.fragments.imageedit.filters.filters; // // import java.util.Collections; // import java.util.Map; // // import awais.instagrabber.R; // import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; // import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; // import awais.instagrabber.fragments.imageedit.filters.properties.Property; // import jp.co.cyberagent.android.gpuimage.filter.GPUImageBoxBlurFilter; // // public class BoxBlurFilter extends Filter { // private static final int PROP_SIZE = 0; // // private final GPUImageBoxBlurFilter filter; // private final Map> properties; // // public BoxBlurFilter() { // super(FiltersHelper.FilterType.BOX_BLUR, R.string.box_blur); // properties = Collections.singletonMap( // PROP_SIZE, new FloatProperty(-1, 1f, 1f, 10.0f) // ); // filter = new GPUImageBoxBlurFilter((Float) getProperty(PROP_SIZE).getDefaultValue()); // } // // @Override // public Map> getProperties() { // return properties; // } // // @Override // public void adjust(final int property, final Object value) { // super.adjust(property, value); // if (!(value instanceof Float)) return; // filter.setBlurSize((Float) value); // } // // @Override // public GPUImageBoxBlurFilter getInstance() { // return filter; // } // } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/BrightnessFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageBrightnessFilter; public class BrightnessFilter extends Filter { private static final int PROP_BRIGHTNESS = 0; private final GPUImageBrightnessFilter filter; private final Map> properties; public BrightnessFilter() { super(FiltersHelper.FilterType.BRIGHTNESS, R.string.brightness); properties = Collections.singletonMap( PROP_BRIGHTNESS, new FloatProperty(R.string.brightness, 0.0f, -1.0f, 1.0f) ); filter = new GPUImageBrightnessFilter((Float) getProperty(PROP_BRIGHTNESS).getDefaultValue()); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); if (!(value instanceof Float)) return; filter.setBrightness((Float) value); } @Override public GPUImageBrightnessFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ClarendonFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.custom.GPUImageClarendonFilter; import awais.instagrabber.fragments.imageedit.filters.properties.Property; public class ClarendonFilter extends Filter { private final GPUImageClarendonFilter filter; public ClarendonFilter() { super(FiltersHelper.FilterType.CLARENDON, R.string.clarendon); filter = new GPUImageClarendonFilter(); } @Override public GPUImageClarendonFilter getInstance() { return filter; } @Override public Map> getProperties() { return null; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ContrastFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageContrastFilter; public class ContrastFilter extends Filter { private static final int PROP_CONTRAST = 0; private final GPUImageContrastFilter filter; private final Map> properties; public ContrastFilter() { super(FiltersHelper.FilterType.CONTRAST, R.string.contrast); properties = Collections.singletonMap( PROP_CONTRAST, new FloatProperty(R.string.contrast, 1.0f, 0.0f, 4.0f) ); filter = new GPUImageContrastFilter((Float) getProperty(PROP_CONTRAST).getDefaultValue()); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); if (!(value instanceof Float)) return; filter.setContrast((Float) value); } @Override public GPUImageContrastFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/ExposureFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageExposureFilter; public class ExposureFilter extends Filter { private static final int PROP_EXPOSURE = 0; private final GPUImageExposureFilter filter; private final Map> properties; public ExposureFilter() { super(FiltersHelper.FilterType.EXPOSURE, R.string.exposure); properties = Collections.singletonMap( PROP_EXPOSURE, new FloatProperty(R.string.exposure, 0f, -3.0f, 3.0f) ); filter = new GPUImageExposureFilter((Float) getProperty(PROP_EXPOSURE).getDefaultValue()); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); if (!(value instanceof Float)) return; filter.setExposure((Float) value); } @Override public GPUImageExposureFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/Filter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import androidx.annotation.CallSuper; import androidx.annotation.StringRes; import java.util.Map; import java.util.Set; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; public abstract class Filter { private final FiltersHelper.FilterType type; private final int label; public Filter(final FiltersHelper.FilterType type, @StringRes final int label) { this.type = type; this.label = label; } public FiltersHelper.FilterType getType() { return type; } @StringRes public int getLabel() { return label; } public abstract T getInstance(); public abstract Map> getProperties(); public Property getProperty(int property) { return getProperties().get(property); } @CallSuper public void adjust(final int property, final Object value) { final Property propertyObj = getProperty(property); propertyObj.setValue(value); } public void reset() { final Map> propertyMap = getProperties(); if (propertyMap == null) return; final Set>> entries = propertyMap.entrySet(); for (final Map.Entry> entry : entries) { adjust(entry.getKey(), entry.getValue().getDefaultValue()); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/FilterFactory.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; public final class FilterFactory { public static Filter getInstance(final FiltersHelper.FilterType type) { switch (type) { case BRIGHTNESS: return new BrightnessFilter(); case CONTRAST: return new ContrastFilter(); case VIBRANCE: return new VibranceFilter(); case SATURATION: return new SaturationFilter(); case SHARPEN: return new SharpenFilter(); case EXPOSURE: return new ExposureFilter(); case NORMAL: return new NormalFilter(); case SEPIA: return new SepiaToneFilter(); case CLARENDON: return new ClarendonFilter(); case ONE977: return new One977Filter(); case ADEN: return new AdenFilter(); // case BULGE_DISTORTION: // break; // case CGA_COLORSPACE: // break; // case COLOR_BALANCE: // break; // case CROSSHATCH: // break; // case DILATION: // break; // case EMBOSS: // break; // case FALSE_COLOR: // break; // case GAMMA: // break; // case GAUSSIAN_BLUR: // break; // case GLASS_SPHERE: // break; // case GRAYSCALE: // break; // case HALFTONE: // break; // case HAZE: // break; // case HIGHLIGHT_SHADOW: // break; // case HUE: // break; // case INVERT: // break; // case KUWAHARA: // break; // case LAPLACIAN: // break; // case LEVELS_FILTER_MIN: // break; // case LOOKUP_AMATORKA: // break; // case LUMINANCE: // break; // case LUMINANCE_THRESHOLD: // break; // case MONOCHROME: // break; // case NON_MAXIMUM_SUPPRESSION: // break; // case OPACITY: // break; // case PIXELATION: // break; // case POSTERIZE: // break; // case RGB: // break; // case RGB_DILATION: // break; // case SKETCH: // break; // case SMOOTH_TOON: // break; // case SOBEL_EDGE_DETECTION: // break; // case SOLARIZE: // break; // case SPHERE_REFRACTION: // break; // case SWIRL: // break; // case THREE_X_THREE_CONVOLUTION: // break; // case THRESHOLD_EDGE_DETECTION: // break; // case TONE_CURVE: // break; // case TOON: // break; // case TRANSFORM2D: // break; // case WEAK_PIXEL_INCLUSION: // break; // case WHITE_BALANCE: // break; // case ZOOM_BLUR: // break; case VIGNETTE: return new VignetteFilter(); // case BILATERAL_BLUR: // return new BilateralBlurFilter(); // case BOX_BLUR: // return new BoxBlurFilter(); } return null; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/NormalFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; public class NormalFilter extends Filter { private final GPUImageFilter filter; private final Map> properties; public NormalFilter() { super(FiltersHelper.FilterType.NORMAL, R.string.normal); properties = Collections.emptyMap(); filter = new GPUImageFilter(); } @Override public Map> getProperties() { return properties; } @Override public GPUImageFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/One977Filter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.custom.GPUImage1977Filter; import awais.instagrabber.fragments.imageedit.filters.properties.Property; public class One977Filter extends Filter { private final GPUImage1977Filter filter; public One977Filter() { super(FiltersHelper.FilterType.ONE977, R.string.one977); filter = new GPUImage1977Filter(); } @Override public GPUImage1977Filter getInstance() { return filter; } @Override public Map> getProperties() { return null; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SaturationFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSaturationFilter; public class SaturationFilter extends Filter { private static final int PROP_SATURATION = 0; private final GPUImageSaturationFilter filter; private final Map> properties; public SaturationFilter() { super(FiltersHelper.FilterType.SATURATION, R.string.saturation); properties = Collections.singletonMap( PROP_SATURATION, new FloatProperty(R.string.saturation, 1.0f, 0f, 2.0f) ); filter = new GPUImageSaturationFilter((Float) getProperty(PROP_SATURATION).getDefaultValue()); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); if (!(value instanceof Float)) return; filter.setSaturation((Float) value); } @Override public GPUImageSaturationFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SepiaToneFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSepiaToneFilter; public class SepiaToneFilter extends Filter { private static final int PROP_INTENSITY = 0; private final GPUImageSepiaToneFilter filter; private final Map> properties; public SepiaToneFilter() { super(FiltersHelper.FilterType.SEPIA, R.string.sepia); properties = Collections.singletonMap( PROP_INTENSITY, new FloatProperty(-1, 1f, 1f, 10.0f) ); filter = new GPUImageSepiaToneFilter((Float) getProperty(PROP_INTENSITY).getDefaultValue()); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); if (!(value instanceof Float)) return; filter.setIntensity((Float) value); } @Override public GPUImageSepiaToneFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/SharpenFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageSharpenFilter; public class SharpenFilter extends Filter { private static final int PROP_SHARPNESS = 0; private final GPUImageSharpenFilter filter; private final Map> properties; public SharpenFilter() { super(FiltersHelper.FilterType.SHARPEN, R.string.sharpen); properties = Collections.singletonMap( PROP_SHARPNESS, new FloatProperty(R.string.sharpen, 0f, -0.5f, 0.5f) ); filter = new GPUImageSharpenFilter((Float) getProperty(PROP_SHARPNESS).getDefaultValue()); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); if (!(value instanceof Float)) return; filter.setSharpness((Float) value); } @Override public GPUImageSharpenFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VibranceFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import java.util.Collections; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageVibranceFilter; public class VibranceFilter extends Filter { private static final int PROP_VIBRANCE = 0; private final GPUImageVibranceFilter filter; private final Map> properties; public VibranceFilter() { super(FiltersHelper.FilterType.VIBRANCE, R.string.vibrance); properties = Collections.singletonMap( PROP_VIBRANCE, new FloatProperty(R.string.vibrance, 0f, -1.2f, 1.2f) ); filter = new GPUImageVibranceFilter((Float) getProperty(PROP_VIBRANCE).getDefaultValue()); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); if (!(value instanceof Float)) return; filter.setVibrance((Float) value); } @Override public GPUImageVibranceFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/filters/VignetteFilter.java ================================================ package awais.instagrabber.fragments.imageedit.filters.filters; import android.graphics.Color; import android.graphics.PointF; import com.google.common.collect.ImmutableMap; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper; import awais.instagrabber.fragments.imageedit.filters.properties.ColorProperty; import awais.instagrabber.fragments.imageedit.filters.properties.FloatProperty; import awais.instagrabber.fragments.imageedit.filters.properties.PointFProperty; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import jp.co.cyberagent.android.gpuimage.filter.GPUImageVignetteFilter; public class VignetteFilter extends Filter { private static final int PROP_CENTER = 0; private static final int PROP_COLOR = 1; private static final int PROP_START = 2; private static final int PROP_END = 3; private final GPUImageVignetteFilter filter; private final Map> properties; public VignetteFilter() { super(FiltersHelper.FilterType.VIGNETTE, R.string.vignette); properties = ImmutableMap.of( PROP_CENTER, new PointFProperty(R.string.center, new PointF(0.5f, 0.5f)), PROP_COLOR, new ColorProperty(R.string.color, Color.BLACK), PROP_START, new FloatProperty(R.string.start, 0.3f), PROP_END, new FloatProperty(R.string.end, 0.75f) ); filter = new GPUImageVignetteFilter( (PointF) getProperty(PROP_CENTER).getDefaultValue(), getFloatArrayFromColor((Integer) getProperty(PROP_COLOR).getDefaultValue()), (Float) getProperty(PROP_START).getDefaultValue(), (Float) getProperty(PROP_END).getDefaultValue() ); } @Override public Map> getProperties() { return properties; } @Override public void adjust(final int property, final Object value) { super.adjust(property, value); switch (property) { case PROP_CENTER: filter.setVignetteCenter((PointF) value); return; case PROP_COLOR: final int color = (int) value; filter.setVignetteColor(getFloatArrayFromColor(color)); return; case PROP_START: filter.setVignetteStart((float) value); return; case PROP_END: filter.setVignetteEnd((float) value); return; default: } } private float[] getFloatArrayFromColor(final int color) { return new float[]{Color.red(color) / 255f, Color.green(color) / 255f, Color.blue(color) / 255f}; } @Override public GPUImageVignetteFilter getInstance() { return filter; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/ColorProperty.java ================================================ package awais.instagrabber.fragments.imageedit.filters.properties; import androidx.annotation.StringRes; /** * Min and Max values do not matter here */ public class ColorProperty extends Property { private final int label; private final int defaultValue; public ColorProperty(@StringRes final int label, final int defaultValue) { this.label = label; this.defaultValue = defaultValue; } @Override public int getLabel() { return label; } @Override public Integer getDefaultValue() { return defaultValue; } @Override public Integer getMinValue() { return defaultValue; } @Override public Integer getMaxValue() { return defaultValue; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/FloatProperty.java ================================================ package awais.instagrabber.fragments.imageedit.filters.properties; import androidx.annotation.StringRes; public class FloatProperty extends Property { private final int label; private final float defaultValue; private final float minValue; private final float maxValue; public FloatProperty(@StringRes final int label, final float defaultValue, final float minValue, final float maxValue) { this.label = label; this.defaultValue = defaultValue; this.minValue = minValue; this.maxValue = maxValue; } public FloatProperty(@StringRes final int label, final float value) { this.label = label; this.defaultValue = value; this.minValue = value; this.maxValue = value; } @Override public int getLabel() { return label; } @Override public Float getDefaultValue() { return defaultValue; } @Override public Float getMinValue() { return minValue; } @Override public Float getMaxValue() { return maxValue; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/PointFProperty.java ================================================ package awais.instagrabber.fragments.imageedit.filters.properties; import android.graphics.PointF; import androidx.annotation.StringRes; /** * Min and Max values do not matter here */ public class PointFProperty extends Property { private final int label; private final PointF defaultValue; public PointFProperty(@StringRes final int label, final PointF defaultValue) { this.label = label; this.defaultValue = defaultValue; } @Override public int getLabel() { return label; } @Override public PointF getDefaultValue() { return defaultValue; } @Override public PointF getMinValue() { return defaultValue; } @Override public PointF getMaxValue() { return defaultValue; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/imageedit/filters/properties/Property.java ================================================ package awais.instagrabber.fragments.imageedit.filters.properties; import android.util.Log; import androidx.annotation.StringRes; public abstract class Property { private static final String TAG = Property.class.getSimpleName(); protected T value; @StringRes public abstract int getLabel(); public abstract T getDefaultValue(); public abstract T getMinValue(); public abstract T getMaxValue(); public T getValue() { return value; } public void setValue(final Object value) { try { //noinspection unchecked this.value = (T) value; } catch (ClassCastException e) { Log.e(TAG, "setValue: ", e); } } public void reset() { setValue(getDefaultValue()); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/main/DiscoverFragment.java ================================================ package awais.instagrabber.fragments.main; import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.fragment.app.Fragment; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.common.collect.ImmutableList; import java.util.Set; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.asyncs.DiscoverPostFetchService; import awais.instagrabber.customviews.PrimaryActionModeCallback; import awais.instagrabber.databinding.FragmentDiscoverBinding; import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.PostItemType; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.webservices.DiscoverService; import static awais.instagrabber.utils.Utils.settingsHelper; public class DiscoverFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = "DiscoverFragment"; private MainActivity fragmentActivity; private SwipeRefreshLayout root; private FragmentDiscoverBinding binding; private ActionMode actionMode; private boolean isLoggedIn, shouldRefresh = true; private String keyword; private Set selectedFeedModels; private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_TOPIC_POSTS_LAYOUT); private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { binding.posts.endSelection(); } }; private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { @Override public void onDestroy(final ActionMode mode) { binding.posts.endSelection(); } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { if (item.getItemId() == R.id.action_download) { if (selectedFeedModels == null) return false; final Context context = getContext(); if (context == null) return false; DownloadUtils.download(context, ImmutableList.copyOf(DiscoverFragment.this.selectedFeedModels)); binding.posts.endSelection(); } return false; } }); private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { @Override public void onPostClick(final Media feedModel) { openPostDialog(feedModel, -1); } @Override public void onSliderClick(final Media feedModel, final int position) { openPostDialog(feedModel, position); } @Override public void onCommentsClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; try { final NavDirections commentsAction = ProfileFragmentDirections.actionToComments( feedModel.getCode(), feedModel.getPk(), user.getPk() ); NavHostFragment.findNavController(DiscoverFragment.this).navigate(commentsAction); } catch (Exception e) { Log.e(TAG, "onCommentsClick: ", e); } } @Override public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { final Context context = getContext(); if (context == null) return; DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); } @Override public void onHashtagClick(final String hashtag) { try { final NavDirections action = ProfileFragmentDirections.actionToHashtag(hashtag); NavHostFragment.findNavController(DiscoverFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onLocationClick(final Media feedModel) { final Location location = feedModel.getLocation(); if (location == null) return; try { final NavDirections action = ProfileFragmentDirections.actionToLocation(location.getPk()); NavHostFragment.findNavController(DiscoverFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onLocationClick: ", e); } } @Override public void onMentionClick(final String mention) { navigateToProfile(mention.trim()); } @Override public void onNameClick(final Media feedModel) { navigateToProfile("@" + feedModel.getUser().getUsername()); } @Override public void onProfilePicClick(final Media feedModel) { final User user = feedModel.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } @Override public void onURLClick(final String url) { Utils.openURL(getContext(), url); } @Override public void onEmailClick(final String emailId) { Utils.openEmailAddress(getContext(), emailId); } private void openPostDialog(final Media feedModel, final int position) { try { final NavDirections action = DiscoverFragmentDirections.actionToPost(feedModel, position); NavHostFragment.findNavController(DiscoverFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "openPostDialog: ", e); } } }; private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { @Override public void onSelectionStart() { if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } if (actionMode == null) { actionMode = fragmentActivity.startActionMode(multiSelectAction); } } @Override public void onSelectionChange(final Set selectedFeedModels) { final String title = getString(R.string.number_selected, selectedFeedModels.size()); if (actionMode != null) { actionMode.setTitle(title); } DiscoverFragment.this.selectedFeedModels = selectedFeedModels; } @Override public void onSelectionEnd() { if (onBackPressedCallback.isEnabled()) { onBackPressedCallback.setEnabled(false); onBackPressedCallback.remove(); } if (actionMode != null) { actionMode.finish(); actionMode = null; } } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) getActivity(); setHasOptionsMenu(true); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { final String cookie = settingsHelper.getString(Constants.COOKIE); isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; if (root != null) { shouldRefresh = false; return root; } binding = FragmentDiscoverBinding.inflate(getLayoutInflater(), container, false); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; binding.swipeRefreshLayout.setOnRefreshListener(this); init(); shouldRefresh = false; } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.saved_viewer_menu, menu); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (item.getItemId() == R.id.layout) { showPostsLayoutPreferences(); return true; } return super.onOptionsItemSelected(item); } @Override public void onRefresh() { binding.posts.refresh(); } private void init() { final Bundle arguments = getArguments(); if (arguments == null) return; final DiscoverFragmentArgs fragmentArgs = DiscoverFragmentArgs.fromBundle(arguments); keyword = fragmentArgs.getKeyword(); setupPosts(); } private void setupPosts() { binding.posts.setViewModelStoreOwner(this) .setLifeCycleOwner(this) .setPostFetchService(new DiscoverPostFetchService(new DiscoverService.TopicalExploreRequest())) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) .setFeedItemCallback(feedItemCallback) .setSelectionModeCallback(selectionModeCallback) .init(); binding.swipeRefreshLayout.setRefreshing(true); } private void updateSwipeRefreshState() { AppExecutors.INSTANCE.getMainThread().execute(() -> binding.swipeRefreshLayout.setRefreshing(binding.posts.isFetching()) ); } private void navigateToProfile(final String username) { try { final NavDirections action = DiscoverFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "navigateToProfile: ", e); } } private void showPostsLayoutPreferences() { final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( Constants.PREF_TOPIC_POSTS_LAYOUT, preferences -> { layoutPreferences = preferences; new Handler().postDelayed(() -> binding.posts.setLayoutPreferences(preferences), 200); }); fragment.show(getChildFragmentManager(), "posts_layout_preferences"); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/main/FeedFragment.java ================================================ package awais.instagrabber.fragments.main; import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavController; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.common.collect.ImmutableList; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.FeedAdapterV2; import awais.instagrabber.adapters.FeedStoriesAdapter; import awais.instagrabber.asyncs.FeedPostFetchService; import awais.instagrabber.customviews.PrimaryActionModeCallback; import awais.instagrabber.databinding.FragmentFeedBinding; import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.repositories.requests.StoryViewerOptions; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.stories.Story; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.viewmodels.FeedStoriesViewModel; import awais.instagrabber.webservices.StoriesRepository; import kotlinx.coroutines.Dispatchers; public class FeedFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { private static final String TAG = "FeedFragment"; private MainActivity fragmentActivity; private CoordinatorLayout root; private FragmentFeedBinding binding; private StoriesRepository storiesRepository; private boolean shouldRefresh = true; private FeedStoriesViewModel feedStoriesViewModel; private boolean storiesFetching; private ActionMode actionMode; private Set selectedFeedModels; private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_POSTS_LAYOUT); private MenuItem storyListMenu; private final FeedStoriesAdapter feedStoriesAdapter = new FeedStoriesAdapter( new FeedStoriesAdapter.OnFeedStoryClickListener() { @Override public void onFeedStoryClick(Story model, int position) { final NavController navController = NavHostFragment.findNavController(FeedFragment.this); if (isSafeToNavigate(navController)) { try { final NavDirections action = FeedFragmentDirections.actionToStory(StoryViewerOptions.forFeedStoryPosition(position)); navController.navigate(action); } catch (Exception e) { Log.e(TAG, "onFeedStoryClick: ", e); } } } @Override public void onFeedStoryLongClick(Story model, int position) { final User user = model.getUser(); if (user == null) return; navigateToProfile("@" + user.getUsername()); } } ); private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { @Override public void onPostClick(final Media feedModel) { openPostDialog(feedModel, -1); } @Override public void onSliderClick(final Media feedModel, final int position) { openPostDialog(feedModel, position); } @Override public void onCommentsClick(final Media feedModel) { try { final User user = feedModel.getUser(); if (user == null) return; final NavDirections commentsAction = FeedFragmentDirections.actionToComments( feedModel.getCode(), feedModel.getPk(), user.getPk() ); NavHostFragment.findNavController(FeedFragment.this).navigate(commentsAction); } catch (Exception e) { Log.e(TAG, "onCommentsClick: ", e); } } @Override public void onDownloadClick(final Media feedModel, final int childPosition, final View popupLocation) { final Context context = getContext(); if (context == null) return; DownloadUtils.showDownloadDialog(context, feedModel, childPosition, popupLocation); } @Override public void onHashtagClick(final String hashtag) { try { final NavDirections action = FeedFragmentDirections.actionToHashtag(hashtag); NavHostFragment.findNavController(FeedFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onHashtagClick: ", e); } } @Override public void onLocationClick(final Media feedModel) { final Location location = feedModel.getLocation(); if (location == null) return; try { final NavDirections action = FeedFragmentDirections.actionToLocation(location.getPk()); NavHostFragment.findNavController(FeedFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onLocationClick: ", e); } } @Override public void onMentionClick(final String mention) { navigateToProfile(mention.trim()); } @Override public void onNameClick(final Media feedModel) { if (feedModel.getUser() == null) return; navigateToProfile("@" + feedModel.getUser().getUsername()); } @Override public void onProfilePicClick(final Media feedModel) { if (feedModel.getUser() == null) return; navigateToProfile("@" + feedModel.getUser().getUsername()); } @Override public void onURLClick(final String url) { Utils.openURL(getContext(), url); } @Override public void onEmailClick(final String emailId) { Utils.openEmailAddress(getContext(), emailId); } private void openPostDialog(final Media feedModel, final int position) { try { final NavDirections action = FeedFragmentDirections.actionToPost(feedModel, position); NavHostFragment.findNavController(FeedFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "openPostDialog: ", e); } } }; private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { binding.feedRecyclerView.endSelection(); } }; private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { @Override public void onDestroy(final ActionMode mode) { binding.feedRecyclerView.endSelection(); } @Override public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { if (item.getItemId() == R.id.action_download) { if (FeedFragment.this.selectedFeedModels == null) return false; final Context context = getContext(); if (context == null) return false; DownloadUtils.download(context, ImmutableList.copyOf(FeedFragment.this.selectedFeedModels)); binding.feedRecyclerView.endSelection(); return true; } return false; } }); private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { @Override public void onSelectionStart() { if (!onBackPressedCallback.isEnabled()) { final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); onBackPressedCallback.setEnabled(true); onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); } if (actionMode == null) { actionMode = fragmentActivity.startActionMode(multiSelectAction); } } @Override public void onSelectionChange(final Set selectedFeedModels) { final String title = getString(R.string.number_selected, selectedFeedModels.size()); if (actionMode != null) { actionMode.setTitle(title); } FeedFragment.this.selectedFeedModels = selectedFeedModels; } @Override public void onSelectionEnd() { if (onBackPressedCallback.isEnabled()) { onBackPressedCallback.setEnabled(false); onBackPressedCallback.remove(); } if (actionMode != null) { actionMode.finish(); actionMode = null; } } }; private void navigateToProfile(final String username) { try { final NavDirections action = FeedFragmentDirections.actionToProfile().setUsername(username); NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "navigateToProfile: ", e); } } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); fragmentActivity = (MainActivity) requireActivity(); storiesRepository = StoriesRepository.Companion.getInstance(); setHasOptionsMenu(true); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentFeedBinding.inflate(inflater, container, false); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; binding.feedSwipeRefreshLayout.setOnRefreshListener(this); /* FabAnimation.init(binding.fabCamera); FabAnimation.init(binding.fabStory); binding.fabAdd.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { isRotate = FabAnimation.rotateFab(v, !isRotate); if (isRotate) { FabAnimation.showIn(binding.fabCamera); FabAnimation.showIn(binding.fabStory); } else { FabAnimation.showOut(binding.fabCamera); FabAnimation.showOut(binding.fabStory); } } }); */ setupFeedStories(); setupFeed(); shouldRefresh = false; } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.feed_menu, menu); storyListMenu = menu.findItem(R.id.storyList); storyListMenu.setVisible(!storiesFetching); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (item.getItemId() == R.id.storyList) { try { final NavDirections action = FeedFragmentDirections.actionToStoryList("feed"); NavHostFragment.findNavController(FeedFragment.this).navigate(action); } catch (Exception e) { Log.e(TAG, "onOptionsItemSelected: ", e); } } else if (item.getItemId() == R.id.layout) { showPostsLayoutPreferences(); return true; } return super.onOptionsItemSelected(item); } @Override public void onRefresh() { binding.feedRecyclerView.refresh(); fetchStories(); } @Override public void onResume() { super.onResume(); fragmentActivity.setToolbar(binding.toolbar, this); } @Override public void onStop() { super.onStop(); fragmentActivity.resetToolbar(this); } private void setupFeed() { binding.feedRecyclerView.setViewModelStoreOwner(this) .setLifeCycleOwner(this) .setPostFetchService(new FeedPostFetchService()) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) .setFeedItemCallback(feedItemCallback) .setSelectionModeCallback(selectionModeCallback) .init(); // binding.feedRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { // @Override // public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { // super.onScrolled(recyclerView, dx, dy); // final boolean canScrollVertically = recyclerView.canScrollVertically(-1); // final MotionScene.Transition transition = root.getTransition(R.id.transition); // if (transition != null) { // transition.setEnable(!canScrollVertically); // } // } // }); // if (shouldAutoPlay) { // videoAwareRecyclerScroller = new VideoAwareRecyclerScroller(); // binding.feedRecyclerView.addOnScrollListener(videoAwareRecyclerScroller); // } } private void updateSwipeRefreshState() { AppExecutors.INSTANCE.getMainThread().execute(() -> binding.feedSwipeRefreshLayout .setRefreshing(binding.feedRecyclerView.isFetching() || storiesFetching) ); } private void setupFeedStories() { if (storyListMenu != null) storyListMenu.setVisible(false); feedStoriesViewModel = new ViewModelProvider(fragmentActivity).get(FeedStoriesViewModel.class); final Context context = getContext(); if (context == null) return; final RecyclerView storiesRecyclerView = binding.header; storiesRecyclerView.setLayoutManager(new LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)); storiesRecyclerView.setAdapter(feedStoriesAdapter); feedStoriesViewModel.getList().observe(fragmentActivity, feedStoriesAdapter::submitList); fetchStories(); } private void fetchStories() { if (storiesFetching) return; // final String cookie = settingsHelper.getString(Constants.COOKIE); storiesFetching = true; updateSwipeRefreshState(); storiesRepository.getFeedStories( CoroutineUtilsKt.getContinuation((feedStoryModels, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "failed", throwable); storiesFetching = false; updateSwipeRefreshState(); return; } storiesFetching = false; //noinspection unchecked if (Utils.settingsHelper.getBoolean(PreferenceKeys.HIDE_MUTED_REELS)) { feedStoriesViewModel.getList().postValue(feedStoryModels .stream() .filter(s -> s.getMuted() != true) .collect(Collectors.toList())); } feedStoriesViewModel.getList().postValue((List) feedStoryModels); if (storyListMenu != null) storyListMenu.setVisible(true); updateSwipeRefreshState(); }), Dispatchers.getIO()) ); } private void showPostsLayoutPreferences() { final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( Constants.PREF_POSTS_LAYOUT, preferences -> { layoutPreferences = preferences; new Handler().postDelayed(() -> binding.feedRecyclerView.setLayoutPreferences(preferences), 200); } ); fragment.show(getChildFragmentManager(), "posts_layout_preferences"); } public void scrollToTop() { if (binding != null) { binding.feedRecyclerView.smoothScrollToPosition(0); // binding.storiesContainer.setExpanded(true); } } private boolean isSafeToNavigate(final NavController navController) { return navController.getCurrentDestination() != null && navController.getCurrentDestination().getId() == R.id.feedFragment; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt ================================================ package awais.instagrabber.fragments.main import android.content.Intent import android.graphics.Typeface import android.os.Bundle import android.os.Handler import android.os.Looper import android.text.SpannableStringBuilder import android.text.style.RelativeSizeSpan import android.text.style.StyleSpan import android.util.Log import android.view.* import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.TooltipCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import awais.instagrabber.R import awais.instagrabber.activities.MainActivity import awais.instagrabber.adapters.FeedAdapterV2 import awais.instagrabber.adapters.HighlightsAdapter import awais.instagrabber.asyncs.ProfilePostFetchService import awais.instagrabber.customviews.PrimaryActionModeCallback import awais.instagrabber.customviews.RamboTextViewV2 import awais.instagrabber.customviews.RamboTextViewV2.* import awais.instagrabber.databinding.FragmentProfileBinding import awais.instagrabber.db.repositories.FavoriteRepository import awais.instagrabber.dialogs.* import awais.instagrabber.dialogs.ConfirmDialogFragment.ConfirmDialogFragmentCallback import awais.instagrabber.dialogs.MultiOptionDialogFragment.MultiOptionDialogSingleCallback import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option import awais.instagrabber.fragments.UserSearchMode import awais.instagrabber.managers.DirectMessagesManager import awais.instagrabber.models.Resource import awais.instagrabber.models.enums.PostItemType import awais.instagrabber.repositories.requests.StoryViewerOptions import awais.instagrabber.repositories.responses.FriendshipStatus import awais.instagrabber.repositories.responses.Media import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.UserProfileContextLink import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.utils.* import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.utils.extensions.isReallyPrivate import awais.instagrabber.utils.extensions.trimAll import awais.instagrabber.viewmodels.AppStateViewModel import awais.instagrabber.viewmodels.ProfileFragmentViewModel import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileEvent.* import awais.instagrabber.viewmodels.ProfileFragmentViewModelFactory import awais.instagrabber.webservices.* class ProfileFragment : Fragment(), OnRefreshListener, ConfirmDialogFragmentCallback, MultiOptionDialogSingleCallback { private var backStackSavedStateResultLiveData: MutableLiveData? = null private var shareDmMenuItem: MenuItem? = null private var shareLinkMenuItem: MenuItem? = null private var removeFollowerMenuItem: MenuItem? = null private var chainingMenuItem: MenuItem? = null private var mutePostsMenuItem: MenuItem? = null private var muteStoriesMenuItem: MenuItem? = null private var restrictMenuItem: MenuItem? = null private var blockMenuItem: MenuItem? = null private var setupPostsDone: Boolean = false private var selectedMedia: List? = null private var actionMode: ActionMode? = null private var disableDm: Boolean = false // private var shouldRefresh: Boolean = true private var highlightsAdapter: HighlightsAdapter? = null private var layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_PROFILE_POSTS_LAYOUT) private lateinit var mainActivity: MainActivity // private lateinit var root: MotionLayout private lateinit var binding: FragmentProfileBinding private lateinit var appStateViewModel: AppStateViewModel private lateinit var viewModel: ProfileFragmentViewModel private val userRepository by lazy { UserRepository.getInstance() } private val friendshipRepository by lazy { FriendshipRepository.getInstance() } private val storiesRepository by lazy { StoriesRepository.getInstance() } private val mediaRepository by lazy { MediaRepository.getInstance() } private val graphQLRepository by lazy { GraphQLRepository.getInstance() } private val favoriteRepository by lazy { FavoriteRepository.getInstance(requireContext()) } private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } private val confirmDialogFragmentRequestCode = 100 private val ppOptsDialogRequestCode = 101 private val bioDialogRequestCode = 102 private val translationDialogRequestCode = 103 private val feedItemCallback: FeedAdapterV2.FeedItemCallback = object : FeedAdapterV2.FeedItemCallback { override fun onPostClick(media: Media) { openPostDialog(media, -1) } override fun onProfilePicClick(media: Media) { navigateToProfile(media.user?.username) } override fun onNameClick(media: Media) { navigateToProfile(media.user?.username) } override fun onLocationClick(media: Media?) { try { val action = ProfileFragmentDirections.actionToLocation(media?.location?.pk ?: return) findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "onLocationClick: ", e) } } override fun onMentionClick(mention: String?) { navigateToProfile(mention?.trimAll() ?: return) } override fun onHashtagClick(hashtag: String?) { try { val action = ProfileFragmentDirections.actionToHashtag(hashtag ?: return) findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "onHashtagClick: ", e) } } override fun onCommentsClick(media: Media?) { try { val commentsAction = ProfileFragmentDirections.actionToComments( media?.code ?: return, media.pk ?: return, media.user?.pk ?: return ) findNavController().navigate(commentsAction) } catch (e: Exception) { Log.e(TAG, "onCommentsClick: ", e) } } override fun onDownloadClick(media: Media?, childPosition: Int, popupLocation: View) { DownloadUtils.showDownloadDialog(context ?: return, media ?: return, childPosition, popupLocation) } override fun onEmailClick(emailId: String?) { Utils.openEmailAddress(context ?: return, emailId ?: return) } override fun onURLClick(url: String?) { Utils.openURL(context ?: return, url ?: return) } override fun onSliderClick(media: Media?, position: Int) { openPostDialog(media ?: return, position) } } private val onBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { binding.postsRecyclerView.endSelection() } } private val multiSelectAction = PrimaryActionModeCallback( R.menu.multi_select_download_menu, object : PrimaryActionModeCallback.CallbacksHelper() { override fun onDestroy(mode: ActionMode?) { binding.postsRecyclerView.endSelection() } override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { val item1 = item ?: return false if (item1.itemId == R.id.action_download) { val selectedMedia = this@ProfileFragment.selectedMedia ?: return false val context = context ?: return false DownloadUtils.download(context, selectedMedia) binding.postsRecyclerView.endSelection() return true } return false } } ) private val selectionModeCallback = object : FeedAdapterV2.SelectionModeCallback { override fun onSelectionStart() { if (!onBackPressedCallback.isEnabled) { onBackPressedCallback.isEnabled = true mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) } if (actionMode == null) { actionMode = mainActivity.startActionMode(multiSelectAction) } } override fun onSelectionChange(mediaSet: Set?) { if (mediaSet == null) { selectedMedia = null return } val title = getString(R.string.number_selected, mediaSet.size) actionMode?.title = title selectedMedia = mediaSet.toList() } override fun onSelectionEnd() { if (onBackPressedCallback.isEnabled) { onBackPressedCallback.isEnabled = false onBackPressedCallback.remove() } (actionMode ?: return).finish() actionMode = null } } private val onProfilePicClickListener = View.OnClickListener { val hasStories = viewModel.userStories.value?.data != null if (!hasStories) { showProfilePicDialog() return@OnClickListener } val dialog = MultiOptionDialogFragment.newInstance( ppOptsDialogRequestCode, 0, arrayListOf( Option(getString(R.string.view_pfp), "profile_pic"), Option(getString(R.string.show_stories), "show_stories") ) ) dialog.show(childFragmentManager, MultiOptionDialogFragment::class.java.simpleName) } private val onFollowersClickListener = View.OnClickListener { try { val action = ProfileFragmentDirections.actionToFollowViewer( viewModel.profile.value?.data?.pk ?: return@OnClickListener, true, viewModel.profile.value?.data?.username ?: return@OnClickListener ) findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "onFollowersClickListener: ", e) } } private val onFollowingClickListener = View.OnClickListener { try { val action = ProfileFragmentDirections.actionToFollowViewer( viewModel.profile.value?.data?.pk ?: return@OnClickListener, false, viewModel.profile.value?.data?.username ?: return@OnClickListener ) findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "onFollowersClickListener: ", e) } } private val onEmailClickListener = OnEmailClickListener { Utils.openEmailAddress(context ?: return@OnEmailClickListener, it.originalText.trimAll()) } private val onHashtagClickListener = OnHashtagClickListener { try { val actionToHashtag = ProfileFragmentDirections.actionToHashtag(it.originalText.trimAll()) findNavController().navigate(actionToHashtag) } catch (e: Exception) { Log.e(TAG, "onHashtagClickListener: ", e) } } private val onMentionClickListener = OnMentionClickListener { navigateToProfile(it.originalText.trimAll()) } private val onURLClickListener = OnURLClickListener { Utils.openURL(context ?: return@OnURLClickListener, it.originalText.trimAll()) } @Suppress("UNCHECKED_CAST") private val backStackSavedStateObserver = Observer { result -> if (result == null) return@Observer if ((result is RankedRecipient)) { if (context != null) { Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() } viewModel.shareDm(result) } else if ((result is Set<*>)) { try { if (context != null) { Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() } viewModel.shareDm(result as Set) } catch (e: Exception) { Log.e(TAG, "share: ", e) } } // clear result backStackSavedStateResultLiveData?.postValue(null) } private fun openPostDialog(media: Media, position: Int) { try { val actionToPost = ProfileFragmentDirections.actionToPost(media, position) findNavController().navigate(actionToPost) } catch (e: Exception) { Log.e(TAG, "openPostDialog: ", e) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mainActivity = requireActivity() as MainActivity appStateViewModel = ViewModelProvider(mainActivity).get(AppStateViewModel::class.java) val cookie = Utils.settingsHelper.getString(Constants.COOKIE) val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) val csrfToken = getCsrfTokenFromCookie(cookie) val userId = getUserIdFromCookie(cookie) val isLoggedIn = !csrfToken.isNullOrBlank() && userId != 0L && deviceUuid.isNotBlank() viewModel = ViewModelProvider( this, ProfileFragmentViewModelFactory( csrfToken, deviceUuid, userRepository, friendshipRepository, storiesRepository, mediaRepository, graphQLRepository, favoriteRepository, directMessagesRepository, if (isLoggedIn) DirectMessagesManager else null, this, arguments ) ).get(ProfileFragmentViewModel::class.java) setHasOptionsMenu(true) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentProfileBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { init() } override fun onRefresh() { viewModel.refresh() binding.postsRecyclerView.refresh() } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.profile_menu, menu) blockMenuItem = menu.findItem(R.id.block) restrictMenuItem = menu.findItem(R.id.restrict) muteStoriesMenuItem = menu.findItem(R.id.mute_stories) mutePostsMenuItem = menu.findItem(R.id.mute_posts) chainingMenuItem = menu.findItem(R.id.chaining) removeFollowerMenuItem = menu.findItem(R.id.remove_follower) shareLinkMenuItem = menu.findItem(R.id.share_link) shareDmMenuItem = menu.findItem(R.id.share_dm) } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.layout -> showPostsLayoutPreferences() R.id.restrict -> viewModel.restrictUser() R.id.block -> viewModel.blockUser() R.id.chaining -> navigateToChaining() R.id.mute_stories -> viewModel.muteStories() R.id.mute_posts -> viewModel.mutePosts() R.id.remove_follower -> viewModel.removeFollower() R.id.share_link -> shareProfileLink() R.id.share_dm -> shareProfileViaDm() } return true } override fun onResume() { super.onResume() mainActivity.setToolbar(binding.toolbar, this) try { val backStackEntry = NavHostFragment.findNavController(this).currentBackStackEntry if (backStackEntry != null) { backStackSavedStateResultLiveData = backStackEntry.savedStateHandle.getLiveData("result") backStackSavedStateResultLiveData?.observe(viewLifecycleOwner, backStackSavedStateObserver) } mainActivity.supportActionBar?.title = viewModel.username.value val (currentUserData, profileData) = viewModel.currentUserProfileActionLiveData.value!! setupOptionsMenuItems(currentUserData.data, profileData.data) } catch (e: Exception) { Log.e(TAG, "onResume: ", e) } } override fun onStop() { super.onStop() mainActivity.resetToolbar(this) } override fun onDestroyView() { super.onDestroyView() setupPostsDone = false } private fun shareProfileViaDm() { try { val actionToUserSearch = ProfileFragmentDirections.actionToUserSearch().apply { title = getString(R.string.share) actionLabel = getString(R.string.send) showGroups = true multiple = true searchMode = UserSearchMode.RAVEN } findNavController().navigate(actionToUserSearch) } catch (e: Exception) { Log.e(TAG, "shareProfileViaDm: ", e) } } private fun shareProfileLink() { val profile = viewModel.profile.value?.data ?: return val sharingIntent = Intent(Intent.ACTION_SEND) sharingIntent.type = "text/plain" sharingIntent.putExtra(Intent.EXTRA_TEXT, "https://instagram.com/" + profile.username) startActivity(Intent.createChooser(sharingIntent, null)) } private fun navigateToChaining() { viewModel.currentUser.value?.data ?: return val profile = viewModel.profile.value?.data ?: return try { val actionToNotifications = ProfileFragmentDirections.actionToNotifications().apply { type = "chaining" targetId = profile.pk } findNavController().navigate(actionToNotifications) } catch (e: Exception) { Log.e(TAG, "navigateToChaining: ", e) } } private fun init() { binding.swipeRefreshLayout.setOnRefreshListener(this) disableDm = !isNavRootInCurrentTabs("direct_messages_nav_graph") setupHighlights() setupObservers() } private fun setupObservers() { appStateViewModel.currentUserLiveData.observe(viewLifecycleOwner, viewModel::setCurrentUser) viewModel.isLoggedIn.observe(viewLifecycleOwner) { // observe so that `isLoggedIn.value` is correct Log.d(TAG, "setupObservers: $it") } viewModel.currentUserProfileActionLiveData.observe(viewLifecycleOwner) { val (currentUserResource, profileResource) = it if (currentUserResource.status == Resource.Status.ERROR || profileResource.status == Resource.Status.ERROR) { context?.let { ctx -> Toast.makeText(ctx, R.string.error_loading_profile, Toast.LENGTH_LONG).show() } return@observe } if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { binding.swipeRefreshLayout.isRefreshing = true return@observe } binding.swipeRefreshLayout.isRefreshing = false val currentUser = currentUserResource.data val profile = profileResource.data val stateUsername = arguments?.getString("username") setupOptionsMenuItems(currentUser, profile) if (currentUser == null && profile == null && stateUsername.isNullOrBlank()) { // default anonymous state, show default message showDefaultMessage() return@observe } if (profile == null && !stateUsername.isNullOrBlank()) { context?.let { ctx -> Toast.makeText(ctx, R.string.error_loading_profile, Toast.LENGTH_LONG).show() } return@observe } setupFavChip(profile, currentUser) setupFavButton(currentUser, profile) setupSavedButton(currentUser, profile) setupTaggedButton(profile) setupLikedButton(currentUser, profile) setupDMButton(currentUser, profile) if (profile == null) return@observe if (profile.isReallyPrivate(currentUser)) { showPrivateAccountMessage() return@observe } if (!setupPostsDone) { setupPosts(profile, currentUser) } } viewModel.username.observe(viewLifecycleOwner) { mainActivity.supportActionBar?.title = it mainActivity.supportActionBar?.subtitle = null } viewModel.profilePicUrl.observe(viewLifecycleOwner) { val visibility = if (it.isNullOrBlank()) View.INVISIBLE else View.VISIBLE binding.header.mainProfileImage.visibility = visibility binding.header.mainProfileImage.setImageURI(if (it.isNullOrBlank()) null else it) binding.header.mainProfileImage.setOnClickListener(if (it.isNullOrBlank()) null else onProfilePicClickListener) } viewModel.fullName.observe(viewLifecycleOwner) { binding.header.mainFullName.text = it ?: "" } viewModel.biography.observe(viewLifecycleOwner, this::setupBiography) viewModel.url.observe(viewLifecycleOwner, this::setupProfileURL) viewModel.followersCount.observe(viewLifecycleOwner, this::setupFollowers) viewModel.followingCount.observe(viewLifecycleOwner, this::setupFollowing) viewModel.postCount.observe(viewLifecycleOwner, this::setupPostsCount) viewModel.friendshipStatus.observe(viewLifecycleOwner) { setupFollowButton(it) setupMainStatus(it) } viewModel.isVerified.observe(viewLifecycleOwner) { binding.header.isVerified.visibility = if (it == true) View.VISIBLE else View.GONE } viewModel.isPrivate.observe(viewLifecycleOwner) { binding.header.isPrivate.visibility = if (it == true) View.VISIBLE else View.GONE } viewModel.isFavorite.observe(viewLifecycleOwner) { if (!it) { binding.header.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24) binding.header.favChip.setText(R.string.add_to_favorites) return@observe } binding.header.favChip.setChipIconResource(R.drawable.ic_star_check_24) binding.header.favChip.setText(R.string.favorite_short) } viewModel.profileContext.observe(viewLifecycleOwner, this::setupProfileContext) viewModel.userHighlights.observe(viewLifecycleOwner) { binding.header.highlightsList.visibility = if (it.data.isNullOrEmpty()) View.GONE else View.VISIBLE highlightsAdapter?.submitList(it.data) } viewModel.userStories.observe(viewLifecycleOwner) { binding.header.mainProfileImage.setStoriesBorder(if (it.data == null) 0 else 1) } viewModel.eventLiveData.observe(viewLifecycleOwner) { val event = it?.getContentIfNotHandled() ?: return@observe when (event) { ShowConfirmUnfollowDialog -> showConfirmUnfollowDialog() is DMButtonState -> binding.header.btnDM.isEnabled = !event.disabled is NavigateToThread -> mainActivity.navigateToThread(event.threadId, event.username) is ShowTranslation -> showTranslationDialog(event.result) } } } private fun showPrivateAccountMessage() { binding.header.mainFollowers.isClickable = false binding.header.mainFollowing.isClickable = false binding.privatePage.visibility = VISIBLE binding.privatePage.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT binding.privatePage1.setImageResource(R.drawable.lock) binding.privatePage2.setText(R.string.priv_acc) binding.privatePage.visibility = VISIBLE binding.privatePage1.visibility = VISIBLE binding.privatePage2.visibility = VISIBLE binding.postsRecyclerView.visibility = GONE binding.swipeRefreshLayout.isRefreshing = false } private fun setupProfileContext(contextPair: Pair?>) { val (profileContext, contextLinkList) = contextPair if (profileContext == null || contextLinkList == null) { binding.header.profileContext.visibility = GONE binding.header.profileContext.clearOnMentionClickListeners() return } var updatedProfileContext: String = profileContext contextLinkList.forEachIndexed { i, link -> if (link.username == null) return@forEachIndexed updatedProfileContext = updatedProfileContext.substring(0, link.start + i) + "@" + updatedProfileContext.substring(link.start + i) } binding.header.profileContext.visibility = VISIBLE binding.header.profileContext.text = updatedProfileContext binding.header.profileContext.addOnMentionClickListener(onMentionClickListener) } private fun setupProfileURL(url: String?) { if (url.isNullOrBlank()) { binding.header.mainUrl.visibility = GONE binding.header.mainUrl.clearOnURLClickListeners() binding.header.mainUrl.setOnLongClickListener(null) return } binding.header.mainUrl.visibility = VISIBLE binding.header.mainUrl.text = url binding.header.mainUrl.addOnURLClickListener { Utils.openURL(context ?: return@addOnURLClickListener, it.originalText.trimAll()) } binding.header.mainUrl.setOnLongClickListener { Utils.copyText(context ?: return@setOnLongClickListener false, url.trimAll()) return@setOnLongClickListener true } } private fun showTranslationDialog(result: String) { val dialog = ConfirmDialogFragment.newInstance( translationDialogRequestCode, 0, result, R.string.ok, 0, 0 ) dialog.show(childFragmentManager, ConfirmDialogFragment::class.java.simpleName) } private fun setupBiography(bio: String?) { if (bio.isNullOrBlank()) { binding.header.mainBiography.visibility = View.GONE binding.header.mainBiography.clearAllAutoLinkListeners() binding.header.mainBiography.setOnLongClickListener(null) return } binding.header.mainBiography.visibility = View.VISIBLE binding.header.mainBiography.text = bio setCommonAutoLinkListeners(binding.header.mainBiography) binding.header.mainBiography.setOnLongClickListener { val isLoggedIn = viewModel.isLoggedIn.value ?: false val options = arrayListOf(Option(getString(R.string.bio_copy), "copy")) if (isLoggedIn) { options.add(Option(getString(R.string.bio_translate), "translate")) } val dialog = MultiOptionDialogFragment.newInstance( bioDialogRequestCode, 0, options ) dialog.show(childFragmentManager, MultiOptionDialogFragment::class.java.simpleName) return@setOnLongClickListener true } } private fun setCommonAutoLinkListeners(textView: RamboTextViewV2) { textView.addOnEmailClickListener(onEmailClickListener) textView.addOnHashtagListener(onHashtagClickListener) textView.addOnMentionClickListener(onMentionClickListener) textView.addOnURLClickListener(onURLClickListener) } private fun setupOptionsMenuItems(currentUser: User?, profile: User?) { val isMe = currentUser?.pk == profile?.pk if (profile == null || (currentUser != null && isMe)) { hideAllOptionsMenuItems() return } if (currentUser == null) { hideAllOptionsMenuItems() shareLinkMenuItem?.isVisible = profile.username.isNotBlank() return } blockMenuItem?.isVisible = true blockMenuItem?.setTitle(if (profile.friendshipStatus?.blocking == true) R.string.unblock else R.string.block) restrictMenuItem?.isVisible = true restrictMenuItem?.setTitle(if (profile.friendshipStatus?.isRestricted == true) R.string.unrestrict else R.string.restrict) muteStoriesMenuItem?.isVisible = true muteStoriesMenuItem?.setTitle(if (profile.friendshipStatus?.isMutingReel == true) R.string.unmute_stories else R.string.mute_stories) mutePostsMenuItem?.isVisible = true mutePostsMenuItem?.setTitle(if (profile.friendshipStatus?.muting == true) R.string.unmute_posts else R.string.mute_posts) chainingMenuItem?.isVisible = profile.hasChaining removeFollowerMenuItem?.isVisible = profile.friendshipStatus?.followedBy ?: false shareLinkMenuItem?.isVisible = profile.username.isNotBlank() shareDmMenuItem?.isVisible = profile.pk != 0L } private fun hideAllOptionsMenuItems() { blockMenuItem?.isVisible = false restrictMenuItem?.isVisible = false muteStoriesMenuItem?.isVisible = false mutePostsMenuItem?.isVisible = false chainingMenuItem?.isVisible = false removeFollowerMenuItem?.isVisible = false shareLinkMenuItem?.isVisible = false shareDmMenuItem?.isVisible = false } private fun setupPostsCount(count: Long?) { if (count == null) { binding.header.mainPostCount.visibility = View.GONE return } binding.header.mainPostCount.visibility = View.VISIBLE binding.header.mainPostCount.text = getCountSpan(R.plurals.main_posts_count, abbreviate(count, null), count) if (count >= 1000) { TooltipCompat.setTooltipText(binding.header.mainPostCount, count.toString(10)) } } private fun setupFollowing(count: Long?) { if (count == null) { binding.header.mainFollowing.visibility = View.GONE return } val abbreviate = abbreviate(count, null) val span = SpannableStringBuilder(getString(R.string.main_posts_following, abbreviate)) binding.header.mainFollowing.visibility = View.VISIBLE binding.header.mainFollowing.text = getCountSpan(span, abbreviate) if (count <= 0) { binding.header.mainFollowing.setOnClickListener(null) return } binding.header.mainFollowing.setOnClickListener(onFollowingClickListener) if (count >= 1000) { TooltipCompat.setTooltipText(binding.header.mainFollowing, count.toString(10)) } } private fun setupFollowers(count: Long?) { if (count == null) { binding.header.mainFollowers.visibility = View.GONE return } binding.header.mainFollowers.visibility = View.VISIBLE binding.header.mainFollowers.text = getCountSpan(R.plurals.main_posts_followers, abbreviate(count, null), count) if (count <= 0) { binding.header.mainFollowers.setOnClickListener(null) return } binding.header.mainFollowers.setOnClickListener(onFollowersClickListener) if (count >= 1000) { TooltipCompat.setTooltipText(binding.header.mainFollowers, count.toString(10)) } } private fun setupDMButton(currentUser: User?, profile: User?) { val visibility = if (disableDm || (currentUser != null && profile?.pk == currentUser.pk)) View.GONE else View.VISIBLE binding.header.btnDM.visibility = visibility if (visibility == View.GONE) { binding.header.btnDM.setOnClickListener(null) return } binding.header.btnDM.setOnClickListener { viewModel.sendDm() } } private fun setupLikedButton(currentUser: User?, profile: User?) { val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE binding.header.btnLiked.visibility = visibility if (visibility == View.GONE) { binding.header.btnLiked.setOnClickListener(null) return } binding.header.btnLiked.setOnClickListener { try { val action = ProfileFragmentDirections.actionToSaved( viewModel.profile.value?.data?.username ?: return@setOnClickListener, viewModel.profile.value?.data?.pk ?: return@setOnClickListener, PostItemType.LIKED ) findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "setupLikedButton: ", e) } } } private fun setupTaggedButton(profile: User?) { val visibility = if (profile?.usertagsCount == 0L) View.GONE else View.VISIBLE binding.header.btnTagged.visibility = visibility if (visibility == View.GONE) { binding.header.btnTagged.setOnClickListener(null) return } binding.header.btnTagged.setOnClickListener { try { val action = ProfileFragmentDirections.actionToSaved( viewModel.profile.value?.data?.username ?: return@setOnClickListener, viewModel.profile.value?.data?.pk ?: return@setOnClickListener, PostItemType.TAGGED ) findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "setupTaggedButton: ", e) } } } private fun setupSavedButton(currentUser: User?, profile: User?) { val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE binding.header.btnSaved.visibility = visibility if (visibility == View.GONE) { binding.header.btnSaved.setOnClickListener(null) return } binding.header.btnSaved.setOnClickListener { try { val action = ProfileFragmentDirections.actionToSavedCollections().apply { isSaving = false } findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "setupSavedButton: ", e) } } } private fun setupFavButton(currentUser: User?, profile: User?) { val visibility = if (currentUser != null && profile?.pk != currentUser.pk) View.VISIBLE else View.GONE binding.header.btnFollow.visibility = visibility if (visibility == View.GONE) { binding.header.btnFollow.setOnClickListener(null) return } binding.header.btnFollow.setOnClickListener { viewModel.toggleFollow(false) } } private fun setupFavChip(profile: User?, currentUser: User?) { val visibility = if (profile?.pk != currentUser?.pk) View.VISIBLE else View.GONE binding.header.favChip.visibility = visibility if (visibility == View.GONE) { binding.header.favChip.setOnClickListener(null) return } binding.header.favChip.setOnClickListener { viewModel.toggleFavorite() } } private fun setupFollowButton(it: FriendshipStatus?) { if (it == null) return if (it.following) { binding.header.btnFollow.setText(R.string.unfollow) binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24) return } if (it.outgoingRequest) { binding.header.btnFollow.setText(R.string.cancel) binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24) return } binding.header.btnFollow.setText(R.string.follow) binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_24) } private fun setupMainStatus(it: FriendshipStatus?) { if (it == null || (!it.following && !it.followedBy)) { binding.header.mainStatus.visibility = View.GONE return } binding.header.mainStatus.visibility = View.VISIBLE if (it.following && it.followedBy) { context?.let { ctx -> binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.green_800) binding.header.mainStatus.setText(R.string.status_mutual) } return } if (it.following) { context?.let { ctx -> binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.deep_orange_800) binding.header.mainStatus.setText(R.string.status_following) } return } context?.let { ctx -> binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.blue_800) binding.header.mainStatus.setText(R.string.status_follower) } } private fun getCountSpan(pluralRes: Int, countString: String, count: Long): SpannableStringBuilder { val span = SpannableStringBuilder(resources.getQuantityString(pluralRes, count.toInt(), countString)) return getCountSpan(span, countString) } private fun getCountSpan(span: SpannableStringBuilder, countString: String): SpannableStringBuilder { span.setSpan(RelativeSizeSpan(1.2f), 0, countString.length, 0) span.setSpan(StyleSpan(Typeface.BOLD), 0, countString.length, 0) return span } private fun showDefaultMessage() { binding.header.root.visibility = GONE binding.swipeRefreshLayout.visibility = GONE binding.privatePage.visibility = VISIBLE binding.privatePage1.visibility = VISIBLE binding.privatePage2.visibility = VISIBLE binding.privatePage1.setImageResource(R.drawable.ic_outline_info_24) binding.privatePage2.setText(R.string.no_acc) } private fun setupHighlights() { val context = context ?: return highlightsAdapter = HighlightsAdapter { model, position -> val options = StoryViewerOptions.forHighlight(model.user!!.pk, "").apply { currentFeedStoryIndex = position } val action = ProfileFragmentDirections.actionToStory(options) NavHostFragment.findNavController(this).navigate(action) } binding.header.highlightsList.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) binding.header.highlightsList.adapter = highlightsAdapter } private fun setupPosts(profile: User, currentUser: User?) { binding.postsRecyclerView.setViewModelStoreOwner(this) .setLifeCycleOwner(this) .setPostFetchService(ProfilePostFetchService(profile, currentUser != null)) .setLayoutPreferences(layoutPreferences) .addFetchStatusChangeListener { AppExecutors.mainThread.execute { binding.swipeRefreshLayout.isRefreshing = it } } .setFeedItemCallback(feedItemCallback) .setSelectionModeCallback(selectionModeCallback) .init() // binding.postsRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { // override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { // super.onScrolled(recyclerView, dx, dy) // val canScrollVertically = recyclerView.canScrollVertically(-1) // if (!canScrollVertically) { // (binding.collapsingToolbarLayout.layoutParams as AppBarLayout.LayoutParams).scrollFlags = 0 // } // } // }) setupPostsDone = true } private fun navigateToProfile(username: String?) { try { val username1 = username ?: return val actionToProfile = ProfileFragmentDirections.actionToProfile().apply { this.username = username1 } findNavController().navigate(actionToProfile) } catch (e: Exception) { Log.e(TAG, "navigateToProfile: ", e) } } private fun showConfirmUnfollowDialog() { val isPrivate = viewModel.profile.value?.data?.isPrivate ?: return val titleRes = if (isPrivate) R.string.priv_acc else 0 val messageRes = if (isPrivate) R.string.priv_acc_confirm else R.string.are_you_sure val dialog = ConfirmDialogFragment.newInstance( confirmDialogFragmentRequestCode, titleRes, messageRes, R.string.confirm, R.string.cancel, 0, ) dialog.show(childFragmentManager, ConfirmDialogFragment::class.java.simpleName) } override fun onPositiveButtonClicked(requestCode: Int) { when (requestCode) { confirmDialogFragmentRequestCode -> { viewModel.toggleFollow(true) } } } override fun onNegativeButtonClicked(requestCode: Int) {} override fun onNeutralButtonClicked(requestCode: Int) {} override fun onSelect(requestCode: Int, result: String?) { val r = result ?: return when (requestCode) { ppOptsDialogRequestCode -> onPpOptionSelect(r) bioDialogRequestCode -> onBioOptionSelect(r) } } private fun onBioOptionSelect(result: String) { when (result) { "copy" -> Utils.copyText(context ?: return, viewModel.biography.value ?: return) "translate" -> viewModel.translateBio() } } private fun onPpOptionSelect(result: String) { when (result) { "profile_pic" -> showProfilePicDialog() "show_stories" -> { try { val action = ProfileFragmentDirections.actionToStory( StoryViewerOptions.forUser( viewModel.profile.value?.data?.pk ?: return, viewModel.profile.value?.data?.username ?: return, ) ) findNavController().navigate(action) } catch (e: Exception) { Log.e(TAG, "omPpOptionSelect: ", e) } } } } override fun onCancel(requestCode: Int) {} private fun showProfilePicDialog() { val profile = viewModel.profile.value?.data ?: return val fragment = ProfilePicDialogFragment.getInstance( profile.pk, profile.username, profile.profilePicUrl ?: return ) val ft = childFragmentManager.beginTransaction() ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) .add(fragment, ProfilePicDialogFragment::class.java.simpleName) .commit() } private fun showPostsLayoutPreferences() { val fragment = PostsLayoutPreferencesDialogFragment(Constants.PREF_PROFILE_POSTS_LAYOUT) { layoutPreferences = it Handler(Looper.getMainLooper()).postDelayed( { binding.postsRecyclerView.layoutPreferences = it }, 200 ) } fragment.show(childFragmentManager, PostsLayoutPreferencesDialogFragment::class.java.simpleName) } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/search/SearchCategoryFragment.java ================================================ package awais.instagrabber.fragments.search; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LiveData; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import java.io.Serializable; import java.util.Collections; import java.util.List; import java.util.Objects; import awais.instagrabber.adapters.SearchItemsAdapter; import awais.instagrabber.models.Resource; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.repositories.responses.search.SearchItem; import awais.instagrabber.viewmodels.SearchFragmentViewModel; public class SearchCategoryFragment extends Fragment { private static final String TAG = SearchCategoryFragment.class.getSimpleName(); private static final String ARG_TYPE = "type"; @Nullable private SwipeRefreshLayout swipeRefreshLayout; @Nullable private RecyclerView list; private SearchFragmentViewModel viewModel; private FavoriteType type; private SearchItemsAdapter searchItemsAdapter; @Nullable private OnSearchItemClickListener onSearchItemClickListener; private boolean skipViewRefresh; private String prevQuery; @NonNull public static SearchCategoryFragment newInstance(@NonNull final FavoriteType type) { final SearchCategoryFragment fragment = new SearchCategoryFragment(); final Bundle args = new Bundle(); args.putSerializable(ARG_TYPE, type); fragment.setArguments(args); return fragment; } public SearchCategoryFragment() {} @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); final Fragment parentFragment = getParentFragment(); if (!(parentFragment instanceof OnSearchItemClickListener)) return; onSearchItemClickListener = (OnSearchItemClickListener) parentFragment; } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final FragmentActivity fragmentActivity = getActivity(); if (fragmentActivity == null) return; viewModel = new ViewModelProvider(fragmentActivity).get(SearchFragmentViewModel.class); final Bundle args = getArguments(); if (args == null) { Log.e(TAG, "onCreate: arguments are null"); return; } final Serializable typeSerializable = args.getSerializable(ARG_TYPE); if (!(typeSerializable instanceof FavoriteType)) { Log.e(TAG, "onCreate: type not a FavoriteType"); return; } type = (FavoriteType) typeSerializable; } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { final Context context = getContext(); if (context == null) return null; skipViewRefresh = false; if (swipeRefreshLayout != null) { skipViewRefresh = true; return swipeRefreshLayout; } swipeRefreshLayout = new SwipeRefreshLayout(context); swipeRefreshLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); list = new RecyclerView(context); list.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); swipeRefreshLayout.addView(list); return swipeRefreshLayout; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (skipViewRefresh) return; setupList(); } @Override public void onResume() { super.onResume(); // Log.d(TAG, "onResume: type: " + type); setupObservers(); final String currentQuery = viewModel.getQuery().getValue(); if (prevQuery != null && currentQuery != null && !Objects.equals(prevQuery, currentQuery)) { viewModel.search(currentQuery, type); } prevQuery = null; } private void setupList() { if (list == null || swipeRefreshLayout == null) return; final Context context = getContext(); if (context == null) return; list.setLayoutManager(new LinearLayoutManager(context)); searchItemsAdapter = new SearchItemsAdapter(onSearchItemClickListener); list.setAdapter(searchItemsAdapter); swipeRefreshLayout.setOnRefreshListener(() -> { String currentQuery = viewModel.getQuery().getValue(); if (currentQuery == null) currentQuery = ""; viewModel.search(currentQuery, type); }); } private void setupObservers() { viewModel.getQuery().observe(getViewLifecycleOwner(), q -> { if (!isVisible() || Objects.equals(prevQuery, q)) return; viewModel.search(q, type); prevQuery = q; }); final LiveData>> resultsLiveData = getResultsLiveData(); if (resultsLiveData != null) { resultsLiveData.observe(getViewLifecycleOwner(), this::onResults); } } private void onResults(final Resource> listResource) { if (listResource == null) return; switch (listResource.status) { case SUCCESS: if (searchItemsAdapter != null) { searchItemsAdapter.submitList(listResource.data); } if (swipeRefreshLayout != null) { swipeRefreshLayout.setRefreshing(false); } break; case ERROR: if (searchItemsAdapter != null) { searchItemsAdapter.submitList(Collections.emptyList()); } if (swipeRefreshLayout != null) { swipeRefreshLayout.setRefreshing(false); } break; case LOADING: if (swipeRefreshLayout != null) { swipeRefreshLayout.setRefreshing(true); } break; default: break; } } @Nullable private LiveData>> getResultsLiveData() { switch (type) { case TOP: return viewModel.getTopResults(); case USER: return viewModel.getUserResults(); case HASHTAG: return viewModel.getHashtagResults(); case LOCATION: return viewModel.getLocationResults(); } return null; } public interface OnSearchItemClickListener { void onSearchItemClick(SearchItem searchItem); void onSearchItemDelete(SearchItem searchItem, FavoriteType type); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/search/SearchFragment.java ================================================ package awais.instagrabber.fragments.search; import android.content.Context; import android.os.Bundle; import android.text.Editable; import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.LinearLayoutCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.tabs.TabLayoutMediator; import java.util.Arrays; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.adapters.SearchCategoryAdapter; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.FragmentSearchBinding; import awais.instagrabber.models.Resource; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.repositories.responses.search.SearchItem; import awais.instagrabber.viewmodels.SearchFragmentViewModel; import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_SEARCH_FOCUS_KEYBOARD; import static awais.instagrabber.utils.Utils.settingsHelper; public class SearchFragment extends Fragment implements SearchCategoryFragment.OnSearchItemClickListener { private static final String TAG = SearchFragment.class.getSimpleName(); private static final String QUERY = "query"; private FragmentSearchBinding binding; private LinearLayoutCompat root; private boolean shouldRefresh = true; @Nullable private EditText searchInput; @Nullable private MainActivity mainActivity; private SearchFragmentViewModel viewModel; private final TextWatcherAdapter textWatcher = new TextWatcherAdapter() { @Override public void afterTextChanged(final Editable s) { if (s == null) return; viewModel.submitQuery(s.toString().trim()); } }; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final FragmentActivity fragmentActivity = getActivity(); if (!(fragmentActivity instanceof MainActivity)) return; mainActivity = (MainActivity) fragmentActivity; viewModel = new ViewModelProvider(mainActivity).get(SearchFragmentViewModel.class); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { if (root != null) { shouldRefresh = false; return root; } binding = FragmentSearchBinding.inflate(inflater, container, false); root = binding.getRoot(); return root; } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { if (!shouldRefresh) return; init(savedInstanceState); shouldRefresh = false; } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); final String current = viewModel.getQuery().getValue(); if (TextUtils.isEmpty(current)) return; outState.putString(QUERY, current); } @Override public void onPause() { super.onPause(); if (mainActivity != null) { mainActivity.hideSearchView(); } } @Override public void onDestroy() { super.onDestroy(); if (mainActivity != null) { mainActivity.hideSearchView(); } if (searchInput != null) { searchInput.removeTextChangedListener(textWatcher); searchInput.setText(""); } } @Override public void onResume() { super.onResume(); if (mainActivity != null) { mainActivity.showSearchView(); } if (settingsHelper.getBoolean(PREF_SEARCH_FOCUS_KEYBOARD)) { if (searchInput != null) { searchInput.requestFocus(); } final InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); if (imm != null) imm.showSoftInput(searchInput, InputMethodManager.SHOW_IMPLICIT); } } private void init(@Nullable final Bundle savedInstanceState) { if (mainActivity == null) return; searchInput = mainActivity.showSearchView().getEditText(); setupObservers(); setupViewPager(); setupSearchInput(savedInstanceState); } private void setupObservers() { viewModel.getQuery().observe(getViewLifecycleOwner(), q -> {}); // need to observe, so that getQuery returns proper value } private void setupSearchInput(@Nullable final Bundle savedInstanceState) { if (searchInput == null) return; searchInput.removeTextChangedListener(textWatcher); // make sure we add only 1 instance of textWatcher searchInput.addTextChangedListener(textWatcher); boolean triggerEmptyQuery = true; if (savedInstanceState != null) { final String savedQuery = savedInstanceState.getString(QUERY); if (TextUtils.isEmpty(savedQuery)) return; searchInput.setText(savedQuery); triggerEmptyQuery = false; } if (settingsHelper.getBoolean(PREF_SEARCH_FOCUS_KEYBOARD)) { searchInput.requestFocus(); final InputMethodManager imm = (InputMethodManager) requireContext().getSystemService(Context.INPUT_METHOD_SERVICE); if (imm != null) imm.showSoftInput(searchInput, InputMethodManager.SHOW_IMPLICIT); } if (triggerEmptyQuery) { viewModel.submitQuery(""); } } private void setupViewPager() { binding.pager.setSaveEnabled(false); final List categories = Arrays.asList(FavoriteType.values()); binding.pager.setAdapter(new SearchCategoryAdapter(this, categories)); final TabLayoutMediator mediator = new TabLayoutMediator(binding.tabLayout, binding.pager, (tab, position) -> { try { final FavoriteType type = categories.get(position); final int resId; switch (type) { case TOP: resId = R.string.top; break; case USER: resId = R.string.accounts; break; case HASHTAG: resId = R.string.hashtags; break; case LOCATION: resId = R.string.locations; break; default: throw new IllegalStateException("Unexpected value: " + type); } tab.setText(resId); } catch (Exception e) { Log.e(TAG, "setupViewPager: ", e); } }); mediator.attach(); } @Override public void onSearchItemClick(final SearchItem searchItem) { if (searchItem == null) return; final FavoriteType type = searchItem.getType(); if (type == null) return; try { if (!searchItem.isFavorite()) { viewModel.saveToRecentSearches(searchItem); // insert or update recent } final NavDirections action; switch (type) { case USER: action = SearchFragmentDirections.actionToProfile().setUsername(searchItem.getUser().getUsername()); break; case HASHTAG: action = SearchFragmentDirections.actionToHashtag(searchItem.getHashtag().getName()); break; case LOCATION: action = SearchFragmentDirections.actionToLocation(searchItem.getPlace().getLocation().getPk()); break; default: return; } NavHostFragment.findNavController(this).navigate(action); } catch (Exception e) { Log.e(TAG, "onSearchItemClick: ", e); } } @Override public void onSearchItemDelete(final SearchItem searchItem, final FavoriteType type) { final LiveData> liveData = viewModel.deleteRecentSearch(searchItem); if (liveData == null) return; liveData.observe(getViewLifecycleOwner(), new Observer>() { @Override public void onChanged(final Resource resource) { if (resource == null) return; switch (resource.status) { case SUCCESS: viewModel.search("", type); viewModel.search("", FavoriteType.TOP); liveData.removeObserver(this); break; case ERROR: Snackbar.make(binding.getRoot(), R.string.error, Snackbar.LENGTH_SHORT).show(); liveData.removeObserver(this); break; case LOADING: default: break; } } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/AboutFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import android.content.Intent; import android.net.Uri; import androidx.annotation.NonNull; import androidx.appcompat.widget.AppCompatTextView; import androidx.preference.Preference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceScreen; import awais.instagrabber.R; public class AboutFragment extends BasePreferencesFragment { private static AppCompatTextView customPathTextView; @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; final PreferenceCategory generalCategory = new PreferenceCategory(context); screen.addPreference(generalCategory); generalCategory.setTitle(R.string.pref_category_general); generalCategory.setIconSpaceReserved(false); generalCategory.addPreference(getDocsPreference()); generalCategory.addPreference(getRepoPreference()); generalCategory.addPreference(getFeedbackPreference()); final PreferenceCategory licenseCategory = new PreferenceCategory(context); screen.addPreference(licenseCategory); licenseCategory.setTitle(R.string.about_category_license); licenseCategory.setIconSpaceReserved(false); licenseCategory.addPreference(getLicensePreference()); licenseCategory.addPreference(getLiabilityPreference()); final PreferenceCategory thirdPartyCategory = new PreferenceCategory(context); screen.addPreference(thirdPartyCategory); thirdPartyCategory.setTitle(R.string.about_category_3pt); thirdPartyCategory.setIconSpaceReserved(false); // alphabetical order!!! thirdPartyCategory.addPreference(get3ptPreference( context, "Apache Commons Imaging", "Copyright 2007-2020 The Apache Software Foundation. Apache 2.0. This product includes software developed at The Apache Software Foundation (http://www.apache.org/).", "https://commons.apache.org/proper/commons-imaging/" )); thirdPartyCategory.addPreference(get3ptPreference( context, "AutoLinkTextViewV2", "Copyright (C) 2019 Arman Chatikyan. Apache 2.0.", "https://github.com/armcha/AutoLinkTextViewV2" )); thirdPartyCategory.addPreference(get3ptPreference( context, "ExoPlayer", "Copyright (C) 2016 The Android Open Source Project. Apache 2.0.", "https://exoplayer.dev" )); thirdPartyCategory.addPreference(get3ptPreference( context, "Fresco", "Copyright (c) Facebook, Inc. and its affiliates. MIT License.", "https://frescolib.org" )); thirdPartyCategory.addPreference(get3ptPreference( context, "GPUImage", "Copyright 2018 CyberAgent, Inc. Apache 2.0.", "https://github.com/cats-oss/android-gpuimage" )); thirdPartyCategory.addPreference(get3ptPreference( context, "Material Design Icons", "Copyright (C) 2014 Austin Andrews & Google LLC. Apache 2.0.", "https://materialdesignicons.com" )); thirdPartyCategory.addPreference(get3ptPreference( context, "Process Phoenix", "Copyright (C) 2015 Jake Wharton. Apache 2.0.", "https://github.com/JakeWharton/ProcessPhoenix" )); thirdPartyCategory.addPreference(get3ptPreference( context, "Retrofit", "Copyright 2013 Square, Inc. Apache 2.0.", "https://square.github.io/retrofit/" )); thirdPartyCategory.addPreference(get3ptPreference( context, "uCrop", "Copyright 2017 Yalantis. Apache 2.0.", "https://github.com/Yalantis/uCrop" )); } private Preference getDocsPreference() { final Context context = getContext(); if (context == null) return null; final Preference preference = new Preference(context); preference.setTitle(R.string.about_documentation); preference.setSummary(R.string.about_documentation_summary); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(p -> { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse("https://barinsta.austinhuang.me")); startActivity(intent); return true; }); return preference; } private Preference getRepoPreference() { final Context context = getContext(); if (context == null) return null; final Preference preference = new Preference(context); preference.setTitle(R.string.about_repository); preference.setSummary(R.string.about_repository_summary); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(p -> { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse("https://github.com/austinhuang0131/barinsta")); startActivity(intent); return true; }); return preference; } private Preference getFeedbackPreference() { final Context context = getContext(); if (context == null) return null; final Preference preference = new Preference(context); preference.setTitle(R.string.about_feedback); preference.setSummary(R.string.about_feedback_summary); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(p -> { final Intent intent = new Intent(Intent.ACTION_SEND); intent.setType("message/rfc822") .putExtra(Intent.EXTRA_EMAIL, getString(R.string.about_feedback_summary)) .putExtra(Intent.EXTRA_TEXT, "Please note that your email address and the entire content will be published onto GitHub issues. If you do not wish to do that, use other contact methods instead."); if (intent.resolveActivity(context.getPackageManager()) != null) startActivity(intent); return true; }); return preference; } private Preference getLicensePreference() { final Context context = getContext(); if (context == null) return null; final Preference preference = new Preference(context); preference.setSummary(R.string.license); preference.setEnabled(false); preference.setIcon(R.drawable.ic_outline_info_24); preference.setIconSpaceReserved(true); return preference; } private Preference getLiabilityPreference() { final Context context = getContext(); if (context == null) return null; final Preference preference = new Preference(context); preference.setSummary(R.string.liability); preference.setEnabled(false); preference.setIcon(R.drawable.ic_warning); preference.setIconSpaceReserved(true); return preference; } private Preference get3ptPreference(@NonNull final Context context, final String title, final String summary, final String url) { final Preference preference = new Preference(context); preference.setTitle(title); preference.setSummary(summary); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(p -> { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse(url)); startActivity(intent); return true; }); return preference; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/BackupPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import android.os.Build; import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.preference.Preference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceScreen; import androidx.preference.SwitchPreferenceCompat; import com.google.android.material.snackbar.BaseTransientBottomBar; import com.google.android.material.snackbar.Snackbar; import awais.instagrabber.R; import awais.instagrabber.dialogs.CreateBackupDialogFragment; import awais.instagrabber.dialogs.RestoreBackupDialogFragment; public class BackupPreferencesFragment extends BasePreferencesFragment { @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) { return; } if (Build.VERSION.SDK_INT >= 23) { final PreferenceCategory autoCategory = new PreferenceCategory(context); screen.addPreference(autoCategory); autoCategory.setTitle(R.string.auto_backup); autoCategory.addPreference(getAboutPreference(context, true)); autoCategory.addPreference(getWarningPreference(context, true)); autoCategory.addPreference(getAutoBackupPreference(context)); } final PreferenceCategory manualCategory = new PreferenceCategory(context); screen.addPreference(manualCategory); manualCategory.setTitle(R.string.manual_backup); manualCategory.addPreference(getAboutPreference(context, false)); manualCategory.addPreference(getWarningPreference(context, false)); manualCategory.addPreference(getCreatePreference(context)); manualCategory.addPreference(getRestorePreference(context)); } private Preference getAboutPreference(@NonNull final Context context, @NonNull final boolean auto) { final Preference preference = new Preference(context); preference.setSummary(auto ? R.string.auto_backup_summary : R.string.backup_summary); preference.setEnabled(false); preference.setIcon(R.drawable.ic_outline_info_24); preference.setIconSpaceReserved(true); return preference; } private Preference getWarningPreference(@NonNull final Context context, @NonNull final boolean auto) { final Preference preference = new Preference(context); preference.setSummary(auto ? R.string.auto_backup_warning : R.string.backup_warning); preference.setEnabled(false); preference.setIcon(R.drawable.ic_warning); preference.setIconSpaceReserved(true); return preference; } private Preference getAutoBackupPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.PREF_AUTO_BACKUP_ENABLED); preference.setTitle(R.string.auto_backup_setting); preference.setIconSpaceReserved(false); return preference; } private Preference getCreatePreference(@NonNull final Context context) { final Preference preference = new Preference(context); preference.setTitle(R.string.create_backup); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(preference1 -> { final FragmentManager fragmentManager = getParentFragmentManager(); final CreateBackupDialogFragment fragment = new CreateBackupDialogFragment(result -> { final View view = getView(); if (view != null) { Snackbar.make(view, result ? R.string.dialog_export_success : R.string.dialog_export_failed, BaseTransientBottomBar.LENGTH_LONG) .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) .setAction(R.string.ok, v -> {}) .show(); return; } Toast.makeText(context, result ? R.string.dialog_export_success : R.string.dialog_export_failed, Toast.LENGTH_LONG) .show(); }); final FragmentTransaction ft = fragmentManager.beginTransaction(); ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) .add(fragment, "createBackup") .commit(); return true; }); return preference; } private Preference getRestorePreference(@NonNull final Context context) { final Preference preference = new Preference(context); preference.setTitle(R.string.restore_backup); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(preference1 -> { final FragmentManager fragmentManager = getParentFragmentManager(); final RestoreBackupDialogFragment fragment = new RestoreBackupDialogFragment(result -> { final View view = getView(); if (view != null) { Snackbar.make(view, result ? R.string.dialog_import_success : R.string.dialog_import_failed, BaseTransientBottomBar.LENGTH_LONG) .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) .setAction(R.string.ok, v -> {}) .addCallback(new BaseTransientBottomBar.BaseCallback() { @Override public void onDismissed(final Snackbar transientBottomBar, final int event) { recreateActivity(result); } }) .show(); return; } recreateActivity(result); }); final FragmentTransaction ft = fragmentManager.beginTransaction(); ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) .add(fragment, "restoreBackup") .commit(); return true; }); return preference; } private void recreateActivity(final boolean result) { if (!result) return; final FragmentActivity activity = getActivity(); if (activity == null) return; activity.recreate(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/BasePreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import androidx.preference.PreferenceScreen; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.LocaleUtils; public abstract class BasePreferencesFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { private boolean shouldRecreate = false; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { final PreferenceManager preferenceManager = getPreferenceManager(); preferenceManager.setSharedPreferencesName(Constants.SHARED_PREFERENCES_NAME); preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(this); final Context context = getContext(); if (context == null) return; final PreferenceScreen screen = preferenceManager.createPreferenceScreen(context); setupPreferenceScreen(screen); setPreferenceScreen(screen); } abstract void setupPreferenceScreen(PreferenceScreen screen); protected void shouldRecreate() { this.shouldRecreate = true; } @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { if (!shouldRecreate) return; final MainActivity activity = (MainActivity) getActivity(); if (activity == null) return; if (key.equals(PreferenceKeys.APP_LANGUAGE)) { LocaleUtils.setLocale(activity.getBaseContext()); } shouldRecreate = false; activity.recreate(); } @NonNull protected Preference getDivider(final Context context) { final Preference divider = new Preference(context); divider.setLayoutResource(R.layout.item_pref_divider); return divider; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/DMPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import android.content.Intent; import android.text.Editable; import android.util.Log; import android.view.View; import android.widget.AdapterView; import android.widget.ArrayAdapter; import androidx.annotation.NonNull; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import androidx.preference.PreferenceViewHolder; import java.util.Objects; import awais.instagrabber.R; import awais.instagrabber.customviews.helpers.TextWatcherAdapter; import awais.instagrabber.databinding.PrefAutoRefreshDmFreqBinding; import awais.instagrabber.services.DMSyncAlarmReceiver; import awais.instagrabber.services.DMSyncService; import awais.instagrabber.utils.Debouncer; import awais.instagrabber.utils.TextUtils; import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER; import static awais.instagrabber.fragments.settings.PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT; import static awais.instagrabber.utils.Utils.settingsHelper; public class DMPreferencesFragment extends BasePreferencesFragment { private static final String TAG = DMPreferencesFragment.class.getSimpleName(); @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; screen.addPreference(getMarkDMSeenPreference(context)); // screen.addPreference(getAutoRefreshDMPreference(context)); // screen.addPreference(getAutoRefreshDMFreqPreference(context)); } private Preference getMarkDMSeenPreference(@NonNull final Context context) { return PreferenceHelper.getSwitchPreference( context, PreferenceKeys.DM_MARK_AS_SEEN, R.string.dm_mark_as_seen_setting, R.string.dm_mark_as_seen_setting_summary, false, null ); } private Preference getAutoRefreshDMPreference(@NonNull final Context context) { return PreferenceHelper.getSwitchPreference( context, PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH, R.string.enable_dm_auto_refesh, -1, false, (preference, newValue) -> { if (!(newValue instanceof Boolean)) return false; final boolean enabled = (Boolean) newValue; if (enabled) { DMSyncAlarmReceiver.setAlarm(context); return true; } DMSyncAlarmReceiver.cancelAlarm(context); try { final Context applicationContext = context.getApplicationContext(); applicationContext.stopService(new Intent(applicationContext, DMSyncService.class)); } catch (Exception e) { Log.e(TAG, "getAutoRefreshDMPreference: ", e); } return true; } ); } private Preference getAutoRefreshDMFreqPreference(@NonNull final Context context) { return new AutoRefreshDMFrePreference(context); } public static class AutoRefreshDMFrePreference extends Preference { private static final String TAG = AutoRefreshDMFrePreference.class.getSimpleName(); private static final String DEBOUNCE_KEY = "dm_sync_service_update"; public static final int INTERVAL = 2000; private final Debouncer.Callback changeCallback; private Debouncer serviceUpdateDebouncer; private PrefAutoRefreshDmFreqBinding binding; public AutoRefreshDMFrePreference(final Context context) { super(context); setLayoutResource(R.layout.pref_auto_refresh_dm_freq); // setKey(key); setIconSpaceReserved(false); changeCallback = new Debouncer.Callback() { @Override public void call(final String key) { DMSyncAlarmReceiver.setAlarm(context); } @Override public void onError(final Throwable t) { Log.e(TAG, "onError: ", t); } }; serviceUpdateDebouncer = new Debouncer<>(changeCallback, INTERVAL); } @Override public void onDependencyChanged(final Preference dependency, final boolean disableDependent) { // super.onDependencyChanged(dependency, disableDependent); if (binding == null) return; binding.startText.setEnabled(!disableDependent); binding.freqNum.setEnabled(!disableDependent); binding.freqUnit.setEnabled(!disableDependent); if (disableDependent) { serviceUpdateDebouncer.terminate(); return; } serviceUpdateDebouncer = new Debouncer<>(changeCallback, INTERVAL); } @Override public void onBindViewHolder(final PreferenceViewHolder holder) { super.onBindViewHolder(holder); setDependency(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH); binding = PrefAutoRefreshDmFreqBinding.bind(holder.itemView); final Context context = getContext(); if (context == null) return; setupUnitSpinner(context); setupNumberEditText(context); } private void setupUnitSpinner(final Context context) { final ArrayAdapter adapter = ArrayAdapter.createFromResource(context, R.array.dm_auto_refresh_freq_unit_labels, android.R.layout.simple_spinner_item); final String[] values = context.getResources().getStringArray(R.array.dm_auto_refresh_freq_units); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); binding.freqUnit.setAdapter(adapter); String unit = settingsHelper.getString(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT); if (TextUtils.isEmpty(unit)) { unit = "secs"; } int position = 0; for (int i = 0; i < values.length; i++) { if (Objects.equals(unit, values[i])) { position = i; break; } } binding.freqUnit.setSelection(position); binding.freqUnit.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(final AdapterView parent, final View view, final int position, final long id) { settingsHelper.putString(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT, values[position]); if (!isEnabled()) { serviceUpdateDebouncer.terminate(); return; } serviceUpdateDebouncer.call(DEBOUNCE_KEY); } @Override public void onNothingSelected(final AdapterView parent) {} }); } private void setupNumberEditText(final Context context) { int currentValue = settingsHelper.getInteger(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER); if (currentValue <= 0) { currentValue = 30; } binding.freqNum.setText(String.valueOf(currentValue)); binding.freqNum.addTextChangedListener(new TextWatcherAdapter() { @Override public void afterTextChanged(final Editable s) { if (TextUtils.isEmpty(s)) return; try { final int value = Integer.parseInt(s.toString()); if (value <= 0) return; settingsHelper.putInteger(PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER, value); if (!isEnabled()) { serviceUpdateDebouncer.terminate(); return; } serviceUpdateDebouncer.call(DEBOUNCE_KEY); } catch (Exception e) { Log.e(TAG, "afterTextChanged: ", e); } } }); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/DownloadsPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.provider.DocumentsContract; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import androidx.preference.SwitchPreferenceCompat; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import awais.instagrabber.R; import awais.instagrabber.dialogs.ConfirmDialogFragment; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import static android.app.Activity.RESULT_OK; import static awais.instagrabber.activities.DirectorySelectActivity.SELECT_DIR_REQUEST_CODE; import static awais.instagrabber.utils.Utils.settingsHelper; public class DownloadsPreferencesFragment extends BasePreferencesFragment { private static final String TAG = DownloadsPreferencesFragment.class.getSimpleName(); private Preference dirPreference; @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; screen.addPreference(getDownloadUserFolderPreference(context)); screen.addPreference(getSaveToCustomFolderPreference(context)); screen.addPreference(getPrependUsernameToFilenamePreference(context)); } private Preference getDownloadUserFolderPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.DOWNLOAD_USER_FOLDER); preference.setTitle(R.string.download_user_folder); preference.setIconSpaceReserved(false); return preference; } private Preference getSaveToCustomFolderPreference(@NonNull final Context context) { dirPreference = new Preference(context); dirPreference.setIconSpaceReserved(false); dirPreference.setTitle(R.string.barinsta_folder); final String currentValue = settingsHelper.getString(PreferenceKeys.PREF_BARINSTA_DIR_URI); if (TextUtils.isEmpty(currentValue)) dirPreference.setSummary(""); else { String path; try { path = URLDecoder.decode(currentValue, StandardCharsets.UTF_8.toString()); } catch (UnsupportedEncodingException e) { path = currentValue; } dirPreference.setSummary(path); } dirPreference.setOnPreferenceClickListener(p -> { openDirectoryChooser(DownloadUtils.getRootDirUri()); return true; }); return dirPreference; } private void openDirectoryChooser(final Uri initialUri) { final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && initialUri != null) { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialUri); } try { startActivityForResult(intent, SELECT_DIR_REQUEST_CODE); } catch (ActivityNotFoundException e) { Log.e(TAG, "openDirectoryChooser: ", e); showErrorDialog(getString(R.string.no_directory_picker_activity)); } catch (Exception e) { Log.e(TAG, "openDirectoryChooser: ", e); } } @SuppressLint("StringFormatInvalid") @Override public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { if (requestCode != SELECT_DIR_REQUEST_CODE) return; if (resultCode != RESULT_OK) return; if (data == null || data.getData() == null) return; final Context context = getContext(); if (context == null) return; AppExecutors.INSTANCE.getMainThread().execute(() -> { try { Utils.setupSelectedDir(context, data); String path; try { path = URLDecoder.decode(data.getData().toString(), StandardCharsets.UTF_8.name()); } catch (UnsupportedEncodingException e) { path = data.getData().toString(); } dirPreference.setSummary(path); } catch (Exception e) { // Should not come to this point. // If it does, we have to show this error to the user so that they can report it. try (final StringWriter sw = new StringWriter(); final PrintWriter pw = new PrintWriter(sw)) { e.printStackTrace(pw); showErrorDialog("com.android.externalstorage.documents".equals(data.getData().getAuthority()) ? "Please report this error to the developers:\n\n" + sw.toString() : getString(R.string.dir_select_no_download_folder, data.getData().getAuthority())); } catch (IOException ioException) { Log.e(TAG, "onActivityResult: ", ioException); } } }, 500); } private void showErrorDialog(final String message) { final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( 123, R.string.error, message, R.string.ok, 0, 0 ); dialogFragment.show(getChildFragmentManager(), ConfirmDialogFragment.class.getSimpleName()); } private Preference getPrependUsernameToFilenamePreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME); preference.setTitle(R.string.download_prepend_username); preference.setIconSpaceReserved(false); return preference; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/GeneralPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import androidx.preference.SwitchPreferenceCompat; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.dialogs.ConfirmDialogFragment; import awais.instagrabber.dialogs.TabOrderPreferenceDialogFragment; import awais.instagrabber.models.Tab; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.NavigationHelperKt; import awais.instagrabber.utils.TextUtils; import kotlin.Pair; import static awais.instagrabber.utils.Utils.settingsHelper; public class GeneralPreferencesFragment extends BasePreferencesFragment implements TabOrderPreferenceDialogFragment.Callback { @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; final String cookie = settingsHelper.getString(Constants.COOKIE); final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; if (isLoggedIn) { screen.addPreference(getDefaultTabPreference(context)); screen.addPreference(getTabOrderPreference(context)); } screen.addPreference(getDisableScreenTransitionsPreference(context)); screen.addPreference(getUpdateCheckPreference(context)); screen.addPreference(getFlagSecurePreference(context)); screen.addPreference(getSearchFocusPreference(context)); final List preferences = FlavorSettings .getInstance() .getPreferences( context, getChildFragmentManager(), SettingCategory.GENERAL ); for (final Preference preference : preferences) { screen.addPreference(preference); } } private Preference getDefaultTabPreference(@NonNull final Context context) { final ListPreference preference = new ListPreference(context); preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); final Pair, List> listPair = NavigationHelperKt.getLoggedInNavTabs(context); final List tabs = listPair.getFirst(); final String[] titles = tabs.stream() .map(Tab::getTitle) .toArray(String[]::new); final String[] navGraphFileNames = tabs.stream() .map(tab -> NavigationHelperKt.getNavGraphNameForNavRootId(tab.getNavigationRootId())) .toArray(String[]::new); preference.setKey(Constants.DEFAULT_TAB); preference.setTitle(R.string.pref_start_screen); preference.setDialogTitle(R.string.pref_start_screen); preference.setEntries(titles); preference.setEntryValues(navGraphFileNames); preference.setIconSpaceReserved(false); return preference; } @NonNull private Preference getTabOrderPreference(@NonNull final Context context) { final Preference preference = new Preference(context); preference.setTitle(R.string.tab_order); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(preference1 -> { final TabOrderPreferenceDialogFragment dialogFragment = TabOrderPreferenceDialogFragment.newInstance(); dialogFragment.show(getChildFragmentManager(), "tab_order_dialog"); return true; }); return preference; } private Preference getDisableScreenTransitionsPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.PREF_DISABLE_SCREEN_TRANSITIONS); preference.setTitle(R.string.disable_screen_transitions); preference.setIconSpaceReserved(false); return preference; } private Preference getUpdateCheckPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.CHECK_UPDATES); preference.setTitle(R.string.update_check); preference.setIconSpaceReserved(false); return preference; } private Preference getFlagSecurePreference(@NonNull final Context context) { return PreferenceHelper.getSwitchPreference( context, PreferenceKeys.FLAG_SECURE, R.string.flag_secure, -1, false, (preference, newValue) -> { shouldRecreate(); return true; }); } private Preference getSearchFocusPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.PREF_SEARCH_FOCUS_KEYBOARD); preference.setTitle(R.string.pref_search_focus_keyboard); preference.setIconSpaceReserved(false); return preference; } @Override public void onSave(final boolean orderHasChanged) { if (!orderHasChanged) return; final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( 111, 0, R.string.tab_order_start_next_launch, R.string.ok, 0, 0); dialogFragment.show(getChildFragmentManager(), "tab_order_set_dialog"); } @Override public void onCancel() { } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/IFlavorSettings.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.fragment.app.FragmentManager; import androidx.preference.Preference; import java.util.List; public interface IFlavorSettings { List getPreferences(Context context, FragmentManager childFragmentManager, SettingCategory settingCategory); } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/LocalePreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import java.time.format.DateTimeFormatter; import awais.instagrabber.R; import awais.instagrabber.dialogs.TimeSettingsDialog; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.LocaleUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.UserAgentUtils; import static awais.instagrabber.utils.Utils.settingsHelper; public class LocalePreferencesFragment extends BasePreferencesFragment { @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; screen.addPreference(getLanguagePreference(context)); screen.addPreference(getPostTimeFormatPreference(context)); } private Preference getLanguagePreference(@NonNull final Context context) { final ListPreference preference = new ListPreference(context); preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); final int length = getResources().getStringArray(R.array.languages).length; final String[] values = new String[length]; for (int i = 0; i < length; i++) { values[i] = String.valueOf(i); } preference.setKey(PreferenceKeys.APP_LANGUAGE); preference.setTitle(R.string.select_language); preference.setDialogTitle(R.string.select_language); preference.setEntries(R.array.languages); preference.setIconSpaceReserved(false); preference.setEntryValues(values); preference.setOnPreferenceChangeListener((preference1, newValue) -> { shouldRecreate(); final int appUaCode = settingsHelper.getInteger(Constants.APP_UA_CODE); final String appUa = UserAgentUtils.generateAppUA(appUaCode, LocaleUtils.getCurrentLocale().getLanguage()); settingsHelper.putString(Constants.APP_UA, appUa); return true; }); return preference; } private Preference getPostTimeFormatPreference(@NonNull final Context context) { final Preference preference = new Preference(context); preference.setTitle(R.string.time_settings); preference.setSummary(TextUtils.nowToString()); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(preference1 -> { new TimeSettingsDialog( settingsHelper.getBoolean(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED), settingsHelper.getString(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT), settingsHelper.getString(PreferenceKeys.DATE_TIME_SELECTION), settingsHelper.getBoolean(PreferenceKeys.SWAP_DATE_TIME_FORMAT_ENABLED), (isCustomFormat, spTimeFormatSelectedItemPosition, spSeparatorSelectedItemPosition, spDateFormatSelectedItemPosition, selectedFormat, swapDateTime) -> { settingsHelper.putBoolean(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED, isCustomFormat); settingsHelper.putBoolean(PreferenceKeys.SWAP_DATE_TIME_FORMAT_ENABLED, swapDateTime); if (isCustomFormat) { settingsHelper.putString(PreferenceKeys.CUSTOM_DATE_TIME_FORMAT, selectedFormat); } else { final String formatSelectionUpdated = spTimeFormatSelectedItemPosition + ";" + spSeparatorSelectedItemPosition + ';' + spDateFormatSelectedItemPosition; // time;separator;date settingsHelper.putString(PreferenceKeys.DATE_TIME_FORMAT, selectedFormat); settingsHelper.putString(PreferenceKeys.DATE_TIME_SELECTION, formatSelectionUpdated); } TextUtils.setFormatter(DateTimeFormatter.ofPattern(selectedFormat, LocaleUtils.getCurrentLocale())); preference.setSummary(TextUtils.nowToString()); } ).show(getParentFragmentManager(), null); return true; }); return preference; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/MorePreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentManager; import androidx.navigation.NavController; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.preference.Preference; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceScreen; import androidx.preference.PreferenceViewHolder; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.BuildConfig; import awais.instagrabber.R; import awais.instagrabber.activities.Login; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.databinding.PrefAccountSwitcherBinding; import awais.instagrabber.db.repositories.AccountRepository; import awais.instagrabber.dialogs.AccountSwitcherDialogFragment; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.FlavorTown; import awais.instagrabber.utils.NavigationHelperKt; import awais.instagrabber.utils.ProcessPhoenix; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import awais.instagrabber.webservices.UserRepository; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class MorePreferencesFragment extends BasePreferencesFragment { private static final String TAG = "MorePreferencesFragment"; private AccountRepository accountRepository; public MorePreferencesFragment() { } @Override public RecyclerView onCreateRecyclerView(final LayoutInflater inflater, final ViewGroup parent, final Bundle savedInstanceState) { final RecyclerView recyclerView = super.onCreateRecyclerView(inflater, parent, savedInstanceState); final Context context = getContext(); if (recyclerView != null && context != null) { recyclerView.setClipToPadding(false); recyclerView.setPadding(recyclerView.getPaddingLeft(), recyclerView.getPaddingTop(), recyclerView.getPaddingRight(), Utils.getActionBarHeight(context)); } return recyclerView; } @Override void setupPreferenceScreen(final PreferenceScreen screen) { final String cookie = settingsHelper.getString(Constants.COOKIE); final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; final MainActivity activity = (MainActivity) getActivity(); // screen.addPreference(new MoreHeaderPreference(getContext())); final Context context = getContext(); if (context == null) return; accountRepository = AccountRepository.Companion.getInstance(context); final PreferenceCategory accountCategory = new PreferenceCategory(context); accountCategory.setTitle(R.string.account); accountCategory.setIconSpaceReserved(false); screen.addPreference(accountCategory); if (isLoggedIn) { accountCategory.setSummary(R.string.account_hint); accountCategory.addPreference(getAccountSwitcherPreference(cookie, context)); accountCategory.addPreference(getPreference(R.string.logout, R.string.logout_summary, R.drawable.ic_logout_24, preference -> { final Context context1 = getContext(); if (context1 == null) return false; CookieUtils.setupCookies("LOGOUT"); // shouldRecreate(); Toast.makeText(context1, R.string.logout_success, Toast.LENGTH_SHORT).show(); settingsHelper.putString(Constants.COOKIE, ""); AppExecutors.INSTANCE.getMainThread().execute(() -> ProcessPhoenix.triggerRebirth(context1), 200); return true; })); } accountRepository.getAllAccounts( CoroutineUtilsKt.getContinuation((accounts, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.d(TAG, "getAllAccounts", throwable); if (!isLoggedIn) { // Need to show something to trigger login activity accountCategory.addPreference(getPreference(R.string.add_account, R.drawable.ic_add, preference -> { startActivityForResult(new Intent(getContext(), Login.class), Constants.LOGIN_RESULT_CODE); return true; })); } return; } if (!isLoggedIn) { if (accounts.size() > 0) { final Context context1 = getContext(); final AccountSwitcherPreference preference = getAccountSwitcherPreference(null, context1); if (preference == null) return; accountCategory.addPreference(preference); } // Need to show something to trigger login activity final Preference preference1 = getPreference(R.string.add_account, R.drawable.ic_add, preference -> { final Context context1 = getContext(); if (context1 == null) return false; startActivityForResult(new Intent(context1, Login.class), Constants.LOGIN_RESULT_CODE); return true; }); if (preference1 == null) return; accountCategory.addPreference(preference1); } if (accounts.size() > 0) { final Preference preference1 = getPreference( R.string.remove_all_acc, null, R.drawable.ic_account_multiple_remove_24, preference -> { if (getContext() == null) return false; new AlertDialog.Builder(getContext()) .setTitle(R.string.logout) .setMessage(R.string.remove_all_acc_warning) .setPositiveButton(R.string.yes, (dialog, which) -> { final Context context1 = getContext(); if (context1 == null) return; CookieUtils.removeAllAccounts( context1, CoroutineUtilsKt.getContinuation( (unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { return; } final Context context2 = getContext(); if (context2 == null) return; Toast.makeText(context2, R.string.logout_success, Toast.LENGTH_SHORT).show(); settingsHelper.putString(Constants.COOKIE, ""); AppExecutors.INSTANCE .getMainThread() .execute(() -> ProcessPhoenix.triggerRebirth(context1), 200); }), Dispatchers.getIO() ) ); }) .setNegativeButton(R.string.cancel, null) .show(); return true; }); if (preference1 == null) return; accountCategory.addPreference(preference1); } }), Dispatchers.getIO()) ); // final PreferenceCategory generalCategory = new PreferenceCategory(context); // generalCategory.setTitle(R.string.pref_category_general); // generalCategory.setIconSpaceReserved(false); // screen.addPreference(generalCategory); screen.addPreference(getDivider(context)); final NavController navController = NavHostFragment.findNavController(this); if (isLoggedIn) { boolean showActivity = true; boolean showExplore = false; if (activity != null) { showActivity = !NavigationHelperKt.isNavRootInCurrentTabs("notification_viewer_nav_graph"); showExplore = !NavigationHelperKt.isNavRootInCurrentTabs("discover_nav_graph"); } if (showActivity) { screen.addPreference(getPreference(R.string.action_notif, R.drawable.ic_not_liked, preference -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToNotifications().setType("notif"); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); } if (showExplore) { screen.addPreference(getPreference(R.string.title_discover, R.drawable.ic_explore_24, preference -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToDiscover(); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); } screen.addPreference(getPreference(R.string.action_ayml, R.drawable.ic_suggested_users, preference -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToNotifications().setType("ayml"); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); screen.addPreference(getPreference(R.string.action_archive, R.drawable.ic_archive, preference -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToStoryList("archive"); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); } // Check if favorites has been added as a tab. And if so, do not add in this list boolean showFavorites = true; if (activity != null) { showFavorites = !NavigationHelperKt.isNavRootInCurrentTabs("favorites_nav_graph"); } if (showFavorites) { screen.addPreference(getPreference(R.string.title_favorites, R.drawable.ic_star_24, preference -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToFavorites(); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); } screen.addPreference(getDivider(context)); screen.addPreference(getPreference(R.string.action_settings, R.drawable.ic_outline_settings_24, preference -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToSettings(); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); screen.addPreference(getPreference(R.string.backup_and_restore, R.drawable.ic_settings_backup_restore_24, preference -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToBackup(); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); screen.addPreference(getPreference(R.string.action_about, R.drawable.ic_outline_info_24, preference1 -> { if (isSafeToNavigate(navController)) { try { final NavDirections navDirections = MorePreferencesFragmentDirections.actionToAbout(); navController.navigate(navDirections); } catch (Exception e) { Log.e(TAG, "setupPreferenceScreen: ", e); } } return true; })); screen.addPreference(getDivider(context)); screen.addPreference(getPreference( R.string.version, BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")", -1, preference -> { if (BuildConfig.isPre) return true; if (activity == null) return false; FlavorTown.updateCheck(activity, true); return true; }) ); screen.addPreference(getDivider(context)); final Preference reminderPreference = getPreference(R.string.reminder, R.string.reminder_summary, R.drawable.ic_warning, null); if (reminderPreference == null) return; reminderPreference.setSelectable(false); screen.addPreference(reminderPreference); } private boolean isSafeToNavigate(final NavController navController) { return navController.getCurrentDestination() != null && navController.getCurrentDestination().getId() == R.id.morePreferencesFragment; } @Override public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { if (resultCode == Constants.LOGIN_RESULT_CODE) { if (data == null) return; final String cookie = data.getStringExtra("cookie"); CookieUtils.setupCookies(cookie); settingsHelper.putString(Constants.COOKIE, cookie); // No use as the timing of show is unreliable // Toast.makeText(getContext(), R.string.login_success_loading_cookies, Toast.LENGTH_SHORT).show(); // adds cookies to database for quick access final long uid = CookieUtils.getUserIdFromCookie(cookie); final UserRepository userRepository = UserRepository.Companion.getInstance(); userRepository .getUserInfo(uid, CoroutineUtilsKt.getContinuation((user, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "Error fetching user info", throwable); return; } if (user != null) { accountRepository.insertOrUpdateAccount( uid, user.getUsername(), cookie, user.getFullName(), user.getProfilePicUrl(), CoroutineUtilsKt.getContinuation((account, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "onActivityResult: ", throwable1); return; } AppExecutors.INSTANCE.getMainThread().execute(() -> { final Context context = getContext(); if (context == null) return; ProcessPhoenix.triggerRebirth(context); }, 200); }), Dispatchers.getIO()) ); } }), Dispatchers.getIO())); } } @Nullable private AccountSwitcherPreference getAccountSwitcherPreference(final String cookie, final Context context) { if (context == null) return null; return new AccountSwitcherPreference(context, cookie, accountRepository, v -> showAccountSwitcherDialog()); } private void showAccountSwitcherDialog() { final AccountSwitcherDialogFragment dialogFragment = new AccountSwitcherDialogFragment(dialog -> { dialog.dismiss(); startActivityForResult(new Intent(getContext(), Login.class), Constants.LOGIN_RESULT_CODE); }); final FragmentManager fragmentManager = getChildFragmentManager(); dialogFragment.show(fragmentManager, "accountSwitcher"); } @Nullable private Preference getPreference(final int title, final int icon, final Preference.OnPreferenceClickListener clickListener) { return getPreference(title, -1, icon, clickListener); } @Nullable private Preference getPreference(final int title, final int summary, final int icon, final Preference.OnPreferenceClickListener clickListener) { String string = null; if (summary > 0) { try { string = getString(summary); } catch (Resources.NotFoundException e) { Log.e(TAG, "Error", e); } } return getPreference(title, string, icon, clickListener); } @Nullable private Preference getPreference(final int title, final String summary, final int icon, final Preference.OnPreferenceClickListener clickListener) { final Context context = getContext(); if (context == null) return null; final Preference preference = new Preference(context); if (icon <= 0) preference.setIconSpaceReserved(false); if (icon > 0) preference.setIcon(icon); preference.setTitle(title); if (!TextUtils.isEmpty(summary)) { preference.setSummary(summary); } preference.setOnPreferenceClickListener(clickListener); return preference; } // public static class MoreHeaderPreference extends Preference { // // public MoreHeaderPreference(final Context context) { // super(context); // setLayoutResource(R.layout.pref_more_header); // setSelectable(false); // } // } public static class AccountSwitcherPreference extends Preference { private final String cookie; private final AccountRepository accountRepository; private final View.OnClickListener onClickListener; public AccountSwitcherPreference(final Context context, final String cookie, final AccountRepository accountRepository, final View.OnClickListener onClickListener) { super(context); this.cookie = cookie; this.accountRepository = accountRepository; this.onClickListener = onClickListener; setLayoutResource(R.layout.pref_account_switcher); } @SuppressLint("SetTextI18n") @Override public void onBindViewHolder(final PreferenceViewHolder holder) { final View root = holder.itemView; if (onClickListener != null) root.setOnClickListener(onClickListener); final PrefAccountSwitcherBinding binding = PrefAccountSwitcherBinding.bind(root); final long uid = CookieUtils.getUserIdFromCookie(cookie); if (uid <= 0) return; accountRepository.getAccount( uid, CoroutineUtilsKt.getContinuation((account, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "onBindViewHolder: ", throwable); return; } if (account == null) return; binding.getRoot().post(() -> { binding.fullName.setText(account.getFullName()); binding.username.setText("@" + account.getUsername()); binding.profilePic.setImageURI(account.getProfilePic()); binding.getRoot().requestLayout(); }); }), Dispatchers.getIO()) ); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/NotificationsPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import awais.instagrabber.R; public class NotificationsPreferencesFragment extends BasePreferencesFragment { @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; screen.addPreference(getActivityNotificationsPreference(context)); // screen.addPreference(getDMNotificationsPreference(context)); } private Preference getActivityNotificationsPreference(@NonNull final Context context) { return PreferenceHelper.getSwitchPreference( context, PreferenceKeys.CHECK_ACTIVITY, R.string.activity_setting, -1, false, (preference, newValue) -> { shouldRecreate(); return true; }); } private Preference getDMNotificationsPreference(@NonNull final Context context) { return PreferenceHelper.getSwitchPreference( context, PreferenceKeys.PREF_ENABLE_DM_NOTIFICATIONS, R.string.enable_dm_notifications, -1, false, null); } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/PostPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import androidx.preference.SwitchPreferenceCompat; import awais.instagrabber.R; import awais.instagrabber.dialogs.KeywordsFilterDialog; public class PostPreferencesFragment extends BasePreferencesFragment { @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; // generalCategory.addPreference(getAutoPlayVideosPreference(context)); screen.addPreference(getBackgroundPlayPreference(context)); screen.addPreference(getAlwaysMuteVideosPreference(context)); screen.addPreference(getToggleKeywordFilterPreference(context)); screen.addPreference(getEditKeywordFilterPreference(context)); } // private Preference getAutoPlayVideosPreference(@NonNull final Context context) { // final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); // preference.setKey(Constants.AUTOPLAY_VIDEOS); // preference.setTitle(R.string.post_viewer_autoplay_video); // preference.setIconSpaceReserved(false); // return preference; // } private Preference getBackgroundPlayPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.PLAY_IN_BACKGROUND); preference.setTitle(R.string.post_viewer_background_play); preference.setSummary(R.string.post_viewer_background_play_summary); preference.setIconSpaceReserved(false); return preference; } private Preference getAlwaysMuteVideosPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.MUTED_VIDEOS); preference.setTitle(R.string.post_viewer_muted_autoplay); preference.setIconSpaceReserved(false); return preference; } private Preference getToggleKeywordFilterPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.TOGGLE_KEYWORD_FILTER); preference.setDefaultValue(false); preference.setTitle(R.string.toggle_keyword_filter); preference.setIconSpaceReserved(false); return preference; } private Preference getEditKeywordFilterPreference(@NonNull final Context context){ final Preference preference = new Preference(context); preference.setTitle(R.string.edit_keyword_filter); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(view ->{ new KeywordsFilterDialog().show(getParentFragmentManager(), null); return true; }); return preference; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/PreferenceHelper.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.preference.Preference.OnPreferenceChangeListener; import androidx.preference.SwitchPreferenceCompat; public final class PreferenceHelper { public static SwitchPreferenceCompat getSwitchPreference(@NonNull final Context context, @NonNull final String key, @StringRes final int titleResId, @StringRes final int summaryResId, final boolean iconSpaceReserved, final OnPreferenceChangeListener onPreferenceChangeListener) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(key); preference.setTitle(titleResId); preference.setIconSpaceReserved(iconSpaceReserved); if (summaryResId != -1) { preference.setSummary(summaryResId); } if (onPreferenceChangeListener != null) { preference.setOnPreferenceChangeListener(onPreferenceChangeListener); } return preference; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/PreferenceKeys.kt ================================================ package awais.instagrabber.fragments.settings object PreferenceKeys { // new boolean prefs const val PREF_ENABLE_DM_NOTIFICATIONS = "enable_dm_notifications" const val PREF_ENABLE_DM_AUTO_REFRESH = "enable_dm_auto_refresh" const val PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT = "enable_dm_auto_refresh_freq_unit" const val PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER = "enable_dm_auto_refresh_freq_number" const val PREF_ENABLE_SENTRY = "enable_sentry" const val PREF_TAB_ORDER = "tab_order" const val PREF_SHOWN_COUNT_TOOLTIP = "shown_count_tooltip" const val PREF_SEARCH_FOCUS_KEYBOARD = "search_focus_keyboard" const val PREF_AUTO_BACKUP_ENABLED = "auto_backup_enabled" const val PREF_DISABLE_SCREEN_TRANSITIONS = "disable_screen_transitions" const val PREF_STORY_SHOW_LIST = "story_show_list" // string prefs const val FOLDER_PATH = "custom_path" const val DATE_TIME_FORMAT = "date_time_format" const val DATE_TIME_SELECTION = "date_time_selection" const val CUSTOM_DATE_TIME_FORMAT = "date_time_custom_format" const val APP_THEME = "app_theme_v19" const val APP_LANGUAGE = "app_language_v19" const val STORY_SORT = "story_sort" const val PREF_BARINSTA_DIR_URI = "barinsta_dir_uri" // set string prefs const val KEYWORD_FILTERS = "keyword_filters" // old boolean prefs const val DOWNLOAD_USER_FOLDER = "download_user_folder" const val TOGGLE_KEYWORD_FILTER = "toggle_keyword_filter" const val DOWNLOAD_PREPEND_USER_NAME = "download_user_name" const val PLAY_IN_BACKGROUND = "play_in_background" const val AUTOPLAY_VIDEOS_STORIES = "autoplay_videos" const val MUTED_VIDEOS = "muted_videos" // const val SHOW_CAPTIONS = "show_captions" const val CUSTOM_DATE_TIME_FORMAT_ENABLED = "data_time_custom_enabled" const val SWAP_DATE_TIME_FORMAT_ENABLED = "swap_date_time_enabled" const val MARK_AS_SEEN = "mark_as_seen" const val HIDE_MUTED_REELS = "hide_muted_reels" const val DM_MARK_AS_SEEN = "dm_mark_as_seen" const val CHECK_ACTIVITY = "check_activity" const val CHECK_UPDATES = "check_updates" const val FLAG_SECURE = "flag_secure" } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/SettingCategory.java ================================================ package awais.instagrabber.fragments.settings; public enum SettingCategory { GENERAL, // add more as and when required } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/SettingsPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.navigation.NavDirections; import androidx.navigation.fragment.NavHostFragment; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import com.google.common.collect.ImmutableList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.TextUtils; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToDm; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToDownloads; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToGeneral; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToLocale; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToNotifications; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToPost; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToStories; import static awais.instagrabber.fragments.settings.SettingsPreferencesFragmentDirections.actionSettingsToTheme; import static awais.instagrabber.utils.Utils.settingsHelper; public class SettingsPreferencesFragment extends BasePreferencesFragment { private static final String TAG = SettingsPreferencesFragment.class.getSimpleName(); private static final List screens = ImmutableList.of( new SettingScreen(R.string.pref_category_general, actionSettingsToGeneral()), new SettingScreen(R.string.pref_category_theme, actionSettingsToTheme()), new SettingScreen(R.string.pref_category_locale, actionSettingsToLocale()), new SettingScreen(R.string.pref_category_post, actionSettingsToPost()), new SettingScreen(R.string.pref_category_stories, actionSettingsToStories(), true), new SettingScreen(R.string.pref_category_dm, actionSettingsToDm(), true), new SettingScreen(R.string.pref_category_notifications, actionSettingsToNotifications(), true), new SettingScreen(R.string.pref_category_downloads, actionSettingsToDownloads()) ); @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; final String cookie = settingsHelper.getString(Constants.COOKIE); final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; for (final SettingScreen settingScreen : screens) { if (settingScreen.isLoginRequired() && !isLoggedIn) continue; screen.addPreference(getNavPreference(context, settingScreen)); } // else { // final PreferenceCategory anonUsersPreferenceCategory = new PreferenceCategory(context); // screen.addPreference(anonUsersPreferenceCategory); // anonUsersPreferenceCategory.setIconSpaceReserved(false); // anonUsersPreferenceCategory.setTitle(R.string.anonymous_settings); // } } private Preference getNavPreference(@NonNull final Context context, @NonNull final SettingScreen settingScreen) { final Preference preference = new Preference(context); preference.setTitle(settingScreen.getTitleResId()); preference.setIconSpaceReserved(false); preference.setOnPreferenceClickListener(preference1 -> { NavHostFragment.findNavController(this).navigate(settingScreen.getDirections()); return true; }); return preference; } private static class SettingScreen { private final int titleResId; private final NavDirections directions; private final boolean loginRequired; public SettingScreen(@StringRes final int titleResId, final NavDirections directions) { this(titleResId, directions, false); } public SettingScreen(@StringRes final int titleResId, final NavDirections directions, final boolean loginRequired) { this.titleResId = titleResId; this.directions = directions; this.loginRequired = loginRequired; } public int getTitleResId() { return titleResId; } public NavDirections getDirections() { return directions; } public boolean isLoginRequired() { return loginRequired; } } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/StoriesPreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import androidx.annotation.NonNull; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import androidx.preference.SwitchPreferenceCompat; import awais.instagrabber.R; public class StoriesPreferencesFragment extends BasePreferencesFragment { @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; screen.addPreference(getStorySortPreference(context)); screen.addPreference(getHideMutedReelsPreference(context)); screen.addPreference(getMarkStoriesSeenPreference(context)); screen.addPreference(getAutoPlayPreference(context)); screen.addPreference(getStoryListPreference(context)); } private Preference getStorySortPreference(@NonNull final Context context) { final ListPreference preference = new ListPreference(context); preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); final int length = getResources().getStringArray(R.array.story_sorts).length; final String[] values = new String[length]; for (int i = 0; i < length; i++) { values[i] = String.valueOf(i); } preference.setKey(PreferenceKeys.STORY_SORT); preference.setTitle(R.string.story_sort_setting); preference.setDialogTitle(R.string.story_sort_setting); preference.setEntries(R.array.story_sorts); preference.setIconSpaceReserved(false); preference.setEntryValues(values); return preference; } private Preference getHideMutedReelsPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.HIDE_MUTED_REELS); preference.setTitle(R.string.hide_muted_reels_setting); preference.setIconSpaceReserved(false); return preference; } private Preference getMarkStoriesSeenPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.MARK_AS_SEEN); preference.setTitle(R.string.mark_as_seen_setting); preference.setSummary(R.string.mark_as_seen_setting_summary); preference.setIconSpaceReserved(false); return preference; } private Preference getAutoPlayPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.AUTOPLAY_VIDEOS_STORIES); preference.setTitle(R.string.autoplay_stories_setting); preference.setIconSpaceReserved(false); return preference; } private Preference getStoryListPreference(@NonNull final Context context) { final SwitchPreferenceCompat preference = new SwitchPreferenceCompat(context); preference.setKey(PreferenceKeys.PREF_STORY_SHOW_LIST); preference.setTitle(R.string.story_list_setting); preference.setSummary(R.string.story_list_setting_summary); preference.setIconSpaceReserved(false); return preference; } } ================================================ FILE: app/src/main/java/awais/instagrabber/fragments/settings/ThemePreferencesFragment.java ================================================ package awais.instagrabber.fragments.settings; import android.content.Context; import android.content.res.TypedArray; import androidx.annotation.NonNull; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceScreen; import awais.instagrabber.R; import awais.instagrabber.utils.Constants; public class ThemePreferencesFragment extends BasePreferencesFragment { @Override void setupPreferenceScreen(final PreferenceScreen screen) { final Context context = getContext(); if (context == null) return; screen.addPreference(getThemePreference(context)); screen.addPreference(getLightThemePreference(context)); screen.addPreference(getDarkThemePreference(context)); } private Preference getThemePreference(@NonNull final Context context) { final ListPreference preference = new ListPreference(context); preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); final int length = getResources().getStringArray(R.array.theme_presets).length; final String[] values = new String[length]; for (int i = 0; i < length; i++) { values[i] = String.valueOf(i); } preference.setKey(PreferenceKeys.APP_THEME); preference.setTitle(R.string.theme_settings); preference.setDialogTitle(R.string.theme_settings); preference.setEntries(R.array.theme_presets); preference.setIconSpaceReserved(false); preference.setEntryValues(values); preference.setOnPreferenceChangeListener((preference1, newValue) -> { shouldRecreate(); return true; }); return preference; } private Preference getLightThemePreference(final Context context) { final ListPreference preference = new ListPreference(context); preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); final TypedArray lightThemeValues = getResources().obtainTypedArray(R.array.light_theme_values); final int length = lightThemeValues.length(); final String[] values = new String[length]; for (int i = 0; i < length; i++) { final int resourceId = lightThemeValues.getResourceId(i, -1); if (resourceId < 0) continue; values[i] = getResources().getResourceEntryName(resourceId); } lightThemeValues.recycle(); preference.setKey(Constants.PREF_LIGHT_THEME); preference.setTitle(R.string.light_theme_settings); preference.setDialogTitle(R.string.light_theme_settings); preference.setEntries(R.array.light_themes); preference.setIconSpaceReserved(false); preference.setEntryValues(values); preference.setOnPreferenceChangeListener((preference1, newValue) -> { shouldRecreate(); return true; }); return preference; } private Preference getDarkThemePreference(final Context context) { final ListPreference preference = new ListPreference(context); preference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); final TypedArray darkThemeValues = getResources().obtainTypedArray(R.array.dark_theme_values); final int length = darkThemeValues.length(); final String[] values = new String[length]; for (int i = 0; i < length; i++) { final int resourceId = darkThemeValues.getResourceId(i, -1); if (resourceId < 0) continue; values[i] = getResources().getResourceEntryName(resourceId); } darkThemeValues.recycle(); preference.setKey(Constants.PREF_DARK_THEME); preference.setTitle(R.string.dark_theme_settings); preference.setDialogTitle(R.string.dark_theme_settings); preference.setEntries(R.array.dark_themes); preference.setIconSpaceReserved(false); preference.setEntryValues(values); preference.setOnPreferenceChangeListener((preference1, newValue) -> { shouldRecreate(); return true; }); return preference; } } ================================================ FILE: app/src/main/java/awais/instagrabber/interfaces/FetchListener.java ================================================ package awais.instagrabber.interfaces; public interface FetchListener { void onResult(T result); default void doBefore() {} default void onFailure(Throwable t) {} } ================================================ FILE: app/src/main/java/awais/instagrabber/interfaces/LazyLoadListener.java ================================================ package awais.instagrabber.interfaces; public interface LazyLoadListener { void onLoadMore(final int page, final int totalItemsCount); } ================================================ FILE: app/src/main/java/awais/instagrabber/interfaces/OnGroupClickListener.java ================================================ package awais.instagrabber.interfaces; public interface OnGroupClickListener { void toggleGroup(final int flatPos); } ================================================ FILE: app/src/main/java/awais/instagrabber/interfaces/SwipeEvent.java ================================================ package awais.instagrabber.interfaces; public interface SwipeEvent { void onSwipe(final boolean isRight); } ================================================ FILE: app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt ================================================ package awais.instagrabber.managers import android.content.ContentResolver import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.repositories.requests.directmessages.ThreadIdsOrUserIds import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.DirectThread import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.utils.Constants import awais.instagrabber.utils.Utils import awais.instagrabber.utils.getCsrfTokenFromCookie import awais.instagrabber.utils.getUserIdFromCookie import awais.instagrabber.webservices.DirectMessagesRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.* object DirectMessagesManager { val inboxManager: InboxManager by lazy { InboxManager(false) } val pendingInboxManager: InboxManager by lazy { InboxManager(true) } private val TAG = DirectMessagesManager::class.java.simpleName private val viewerId: Long private val deviceUuid: String private val csrfToken: String private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } fun moveThreadFromPending(threadId: String) { val pendingThreads = pendingInboxManager.threads.value ?: return val index = pendingThreads.indexOfFirst { it.threadId == threadId } if (index < 0) return val thread = pendingThreads[index] val threadFirstDirectItem = thread.firstDirectItem ?: return val threads = inboxManager.threads.value var insertIndex = 0 if (threads != null) { for (tempThread in threads) { val firstDirectItem = tempThread.firstDirectItem ?: continue val timestamp = firstDirectItem.getTimestamp() if (timestamp < threadFirstDirectItem.getTimestamp()) { break } insertIndex++ } } thread.pending = false inboxManager.addThread(thread, insertIndex) pendingInboxManager.removeThread(threadId) val currentTotal = inboxManager.getPendingRequestsTotal().value ?: return inboxManager.setPendingRequestsTotal(currentTotal - 1) } fun getThreadManager( threadId: String, pending: Boolean, currentUser: User, contentResolver: ContentResolver, ): ThreadManager { return ThreadManager(threadId, pending, currentUser, contentResolver, viewerId, csrfToken, deviceUuid) } suspend fun createThread(userPk: Long): DirectThread = directMessagesRepository.createThread(csrfToken, viewerId, deviceUuid, listOf(userPk), null) fun sendMedia(recipient: RankedRecipient, mediaId: String, secondId: String?, itemType: BroadcastItemType, scope: CoroutineScope) { sendMedia(setOf(recipient), mediaId, secondId, itemType, scope) } fun sendMedia( recipients: Set, mediaId: String, secondId: String?, itemType: BroadcastItemType, scope: CoroutineScope, ) { val threadIds = recipients.mapNotNull { it.thread?.threadId } val userIdsTemp = recipients.mapNotNull { it.user?.pk } val userIds = userIdsTemp.map { listOf(it.toString(10)) } sendMedia(threadIds, userIds, mediaId, secondId, itemType, scope) { inboxManager.refresh(scope) } } private fun sendMedia( threadIds: List, userIds: List>, mediaId: String, secondId: String?, itemType: BroadcastItemType, scope: CoroutineScope, callback: (() -> Unit)?, ): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { when (itemType) { BroadcastItemType.MEDIA_SHARE -> directMessagesRepository.broadcastMediaShare( csrfToken, viewerId, deviceUuid, UUID.randomUUID().toString(), ThreadIdsOrUserIds(threadIds, userIds), mediaId, secondId ) BroadcastItemType.PROFILE -> directMessagesRepository.broadcastProfile( csrfToken, viewerId, deviceUuid, UUID.randomUUID().toString(), ThreadIdsOrUserIds(threadIds, userIds), mediaId ) BroadcastItemType.STORY -> directMessagesRepository.broadcastStory( csrfToken, viewerId, deviceUuid, UUID.randomUUID().toString(), ThreadIdsOrUserIds(threadIds, userIds), mediaId, secondId!! ) } data.postValue(success(Any())) callback?.invoke() } catch (e: Exception) { Log.e(TAG, "sendMedia: ", e) data.postValue(error(e.message, null)) callback?.invoke() } } return data } fun replyToStory( recipientId: Long?, reelId: String?, mediaId: String?, text: String, scope: CoroutineScope ): LiveData> { Log.d("austin_debug", "replying") val data = MutableLiveData>() data.postValue(loading(null)) if (recipientId == null || reelId == null || mediaId == null) { data.postValue(error("arguments are null", null)) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.broadcastStoryReply( csrfToken, viewerId, deviceUuid, ThreadIdsOrUserIds.Companion.ofOneUser(recipientId.toString(10)), text, mediaId, reelId ) inboxManager.refresh(scope) data.postValue(success(null)) } catch (e: Exception) { Log.e(TAG, "story reply: ", e) data.postValue(error(e.message, null)) } } return data } init { val cookie = Utils.settingsHelper.getString(Constants.COOKIE) viewerId = getUserIdFromCookie(cookie) deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) val csrfToken = getCsrfTokenFromCookie(cookie) require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } this.csrfToken = csrfToken } } ================================================ FILE: app/src/main/java/awais/instagrabber/managers/InboxManager.kt ================================================ package awais.instagrabber.managers import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import awais.instagrabber.R import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.* import awais.instagrabber.utils.* import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.webservices.DirectMessagesRepository import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheLoader import com.google.common.collect.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import retrofit2.Call import java.util.* import java.util.concurrent.TimeUnit class InboxManager(private val pending: Boolean) { private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } private val inbox = MutableLiveData>(success(null)) private val unseenCount = MutableLiveData>() private val pendingRequestsTotal = MutableLiveData(0) val threads: LiveData> private var inboxRequest: Call? = null private var unseenCountRequest: Call? = null private var seqId: Long = 0 private var cursor: String? = null private var hasOlder = true var viewer: User? = null private set fun getInbox(): LiveData> { return Transformations.distinctUntilChanged(inbox) } fun getUnseenCount(): LiveData> { return Transformations.distinctUntilChanged(unseenCount) } fun getPendingRequestsTotal(): LiveData { return Transformations.distinctUntilChanged(pendingRequestsTotal) } fun fetchInbox(scope: CoroutineScope) { val inboxResource = inbox.value if (inboxResource != null && inboxResource.status === Resource.Status.LOADING || !hasOlder) return inbox.postValue(loading(currentDirectInbox)) scope.launch(Dispatchers.IO) { try { val inboxValue = if (pending) { directMessagesRepository.fetchPendingInbox(cursor, seqId) } else { directMessagesRepository.fetchInbox(cursor, seqId) } parseInboxResponse(inboxValue) } catch (e: Exception) { inbox.postValue(error(e.message, currentDirectInbox)) hasOlder = false } } } fun fetchUnseenCount(scope: CoroutineScope) { val unseenCountResource = unseenCount.value if (unseenCountResource != null && unseenCountResource.status === Resource.Status.LOADING) return stopCurrentUnseenCountRequest() unseenCount.postValue(loading(currentUnseenCount)) scope.launch(Dispatchers.IO) { try { val directBadgeCount = directMessagesRepository.fetchUnseenCount() unseenCount.postValue(success(directBadgeCount.badgeCount)) } catch (e: Exception) { Log.e(TAG, "Failed fetching unseen count", e) unseenCount.postValue(error(e.message, currentUnseenCount)) } } } fun refresh(scope: CoroutineScope) { cursor = null seqId = 0 hasOlder = true fetchInbox(scope) if (!pending) { fetchUnseenCount(scope) } } private val currentDirectInbox: DirectInbox? get() { val inboxResource = inbox.value return inboxResource?.data } private fun parseInboxResponse(response: DirectInboxResponse) { if (response.status != "ok") { Log.e(TAG, "DM inbox fetch response: status not ok") inbox.postValue(error(R.string.generic_not_ok_response, currentDirectInbox)) hasOlder = false return } seqId = response.seqId if (viewer == null) { viewer = response.viewer } val inbox = response.inbox ?: return if (!cursor.isNullOrBlank()) { val currentDirectInbox = currentDirectInbox currentDirectInbox?.let { val threads = it.threads val threadsCopy = if (threads == null) LinkedList() else LinkedList(threads) threadsCopy.addAll(inbox.threads ?: emptyList()) inbox.threads = threadsCopy } } this.inbox.postValue(success(inbox)) cursor = inbox.oldestCursor hasOlder = inbox.hasOlder pendingRequestsTotal.postValue(response.pendingRequestsTotal) } fun setThread( threadId: String, thread: DirectThread, ) { val inbox = currentDirectInbox ?: return val index = getThreadIndex(threadId, inbox) setThread(inbox, index, thread) } private fun setThread( inbox: DirectInbox, index: Int, thread: DirectThread, ) { if (index < 0) return synchronized(this.inbox) { val threads = inbox.threads val threadsCopy = if (threads == null) LinkedList() else LinkedList(threads) threadsCopy[index] = thread try { val clone = inbox.clone() as DirectInbox clone.threads = threadsCopy this.inbox.postValue(success(clone)) } catch (e: CloneNotSupportedException) { Log.e(TAG, "setThread: ", e) } } } fun addItemsToThread( threadId: String, insertIndex: Int, items: Collection, ) { val inbox = currentDirectInbox ?: return synchronized(THREAD_LOCKS.getUnchecked(threadId)) { val index = getThreadIndex(threadId, inbox) if (index < 0) return val threads = inbox.threads ?: return val thread = threads[index] val threadItems = thread.items val list = if (threadItems == null) LinkedList() else LinkedList(threadItems) if (insertIndex >= 0) { list.addAll(insertIndex, items) } else { list.addAll(items) } try { val threadClone = thread.clone() as DirectThread threadClone.items = list setThread(inbox, index, threadClone) } catch (e: Exception) { Log.e(TAG, "addItemsToThread: ", e) } } } fun setItemsToThread( threadId: String, updatedItems: List, ) { val inbox = currentDirectInbox ?: return synchronized(THREAD_LOCKS.getUnchecked(threadId)) { val index = getThreadIndex(threadId, inbox) if (index < 0) return val threads = inbox.threads ?: return val thread = threads[index] try { val threadClone = thread.clone() as DirectThread threadClone.items = updatedItems setThread(inbox, index, threadClone) } catch (e: Exception) { Log.e(TAG, "setItemsToThread: ", e) } } } private fun getThreadIndex( threadId: String, inbox: DirectInbox, ): Int { val threads = inbox.threads return if (threads == null || threads.isEmpty()) { -1 } else threads.indexOfFirst { it.threadId == threadId } } private val currentUnseenCount: Int? get() { val unseenCountResource = unseenCount.value return unseenCountResource?.data } private fun stopCurrentInboxRequest() { inboxRequest?.let { if (it.isCanceled || it.isExecuted) return it.cancel() } inboxRequest = null } private fun stopCurrentUnseenCountRequest() { unseenCountRequest?.let { if (it.isCanceled || it.isExecuted) return it.cancel() } unseenCountRequest = null } fun onDestroy() { stopCurrentInboxRequest() stopCurrentUnseenCountRequest() } fun addThread(thread: DirectThread, insertIndex: Int) { if (insertIndex < 0) return synchronized(inbox) { val currentDirectInbox = currentDirectInbox ?: return val threads = currentDirectInbox.threads val threadsCopy = if (threads == null) LinkedList() else LinkedList(threads) threadsCopy.add(insertIndex, thread) try { val clone = currentDirectInbox.clone() as DirectInbox clone.threads = threadsCopy inbox.postValue(success(clone)) } catch (e: CloneNotSupportedException) { Log.e(TAG, "setThread: ", e) } } } fun removeThread(threadId: String) { synchronized(inbox) { val currentDirectInbox = currentDirectInbox ?: return val threads = currentDirectInbox.threads ?: return val threadsCopy = threads.asSequence().filter { it.threadId != threadId }.toList() try { val clone = currentDirectInbox.clone() as DirectInbox clone.threads = threadsCopy inbox.postValue(success(clone)) } catch (e: CloneNotSupportedException) { Log.e(TAG, "setThread: ", e) } } } fun setPendingRequestsTotal(total: Int) { pendingRequestsTotal.postValue(total) } fun containsThread(threadId: String?): Boolean { if (threadId == null) return false synchronized(inbox) { val currentDirectInbox = currentDirectInbox ?: return false val threads = currentDirectInbox.threads ?: return false return threads.any { it.threadId == threadId } } } companion object { private val THREAD_LOCKS = CacheBuilder .newBuilder() .expireAfterAccess(1, TimeUnit.MINUTES) // max lock time ever expected .build(CacheLoader.from { Object() }) private val THREAD_COMPARATOR = Comparator { t1: DirectThread, t2: DirectThread -> val t1FirstDirectItem = t1.firstDirectItem val t2FirstDirectItem = t2.firstDirectItem if (t1FirstDirectItem == null && t2FirstDirectItem == null) return@Comparator 0 if (t1FirstDirectItem == null) return@Comparator 1 if (t2FirstDirectItem == null) return@Comparator -1 t2FirstDirectItem.getTimestamp().compareTo(t1FirstDirectItem.getTimestamp()) } } init { val cookie = Utils.settingsHelper.getString(Constants.COOKIE) val viewerId = getUserIdFromCookie(cookie) val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) val csrfToken = getCsrfTokenFromCookie(cookie) require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } // Transformations threads = Transformations.distinctUntilChanged(Transformations.map(inbox) { inboxResource: Resource -> // if (inboxResource == null) { // return@map emptyList() // } val inbox = inboxResource.data val threads = inbox?.threads ?: emptyList() ImmutableList.sortedCopyOf(THREAD_COMPARATOR, threads) }) } } ================================================ FILE: app/src/main/java/awais/instagrabber/managers/ThreadManager.kt ================================================ package awais.instagrabber.managers import android.content.ContentResolver import android.net.Uri import android.util.Log import androidx.core.util.Pair import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations.distinctUntilChanged import androidx.lifecycle.Transformations.map import awais.instagrabber.R import awais.instagrabber.customviews.emoji.Emoji import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success import awais.instagrabber.models.enums.DirectItemType import awais.instagrabber.repositories.requests.UploadFinishOptions import awais.instagrabber.repositories.requests.VideoOptions import awais.instagrabber.repositories.requests.directmessages.ThreadIdsOrUserIds import awais.instagrabber.repositories.requests.directmessages.ThreadIdsOrUserIds.Companion.of import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.* import awais.instagrabber.repositories.responses.giphy.GiphyGif import awais.instagrabber.utils.* import awais.instagrabber.utils.MediaUploader.MediaUploadResponse import awais.instagrabber.utils.MediaUploader.uploadPhoto import awais.instagrabber.utils.MediaUploader.uploadVideo import awais.instagrabber.utils.MediaUtils.OnInfoLoadListener import awais.instagrabber.utils.MediaUtils.VideoInfo import awais.instagrabber.utils.TextUtils.isEmpty import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.webservices.DirectMessagesRepository import awais.instagrabber.webservices.FriendshipRepository import awais.instagrabber.webservices.MediaRepository import com.google.common.collect.ImmutableList import com.google.common.collect.Iterables import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import retrofit2.Call import java.io.IOException import java.net.HttpURLConnection import java.util.* import java.util.stream.Collectors class ThreadManager( private val threadId: String, pending: Boolean, private val currentUser: User?, private val contentResolver: ContentResolver, private val viewerId: Long, private val csrfToken: String, private val deviceUuid: String, ) { private val _fetching = MutableLiveData>() val fetching: LiveData> = _fetching private val _replyToItem = MutableLiveData() val replyToItem: LiveData = _replyToItem private val _pendingRequests = MutableLiveData(null) val pendingRequests: LiveData = _pendingRequests private val inboxManager: InboxManager = if (pending) DirectMessagesManager.pendingInboxManager else DirectMessagesManager.inboxManager private val threadIdsOrUserIds: ThreadIdsOrUserIds = of(threadId) private val friendshipRepository: FriendshipRepository by lazy { FriendshipRepository.getInstance() } private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } val thread: LiveData by lazy { distinctUntilChanged(map(inboxManager.getInbox()) { inboxResource: Resource? -> if (inboxResource == null) return@map null val (threads) = inboxResource.data ?: return@map null if (threads.isNullOrEmpty()) return@map null val thread = threads.firstOrNull { it.threadId == threadId } thread?.also { cursor = thread.oldestCursor hasOlder = thread.hasOlder } }) } val inputMode: LiveData by lazy { distinctUntilChanged(map(thread) { it?.inputMode ?: 1 }) } val threadTitle: LiveData by lazy { distinctUntilChanged(map(thread) { it?.threadTitle }) } val users: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.users ?: emptyList() }) } val usersWithCurrent: LiveData> by lazy { distinctUntilChanged(map(thread) { if (it == null) return@map emptyList() getUsersWithCurrentUser(it) }) } val leftUsers: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.leftUsers ?: emptyList() }) } val usersAndLeftUsers: LiveData, List>> by lazy { distinctUntilChanged(map(thread) { if (it == null) return@map Pair, List>(emptyList(), emptyList()) val users = getUsersWithCurrentUser(it) val leftUsers = it.leftUsers Pair(users, leftUsers) }) } val isPending: LiveData by lazy { distinctUntilChanged(map(thread) { it?.pending ?: true }) } val adminUserIds: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.adminUserIds ?: emptyList() }) } val items: LiveData> by lazy { distinctUntilChanged(map(thread) { it?.items ?: emptyList() }) } val isViewerAdmin: LiveData by lazy { distinctUntilChanged(map(thread) { it?.adminUserIds?.contains(viewerId) ?: false }) } val isGroup: LiveData by lazy { distinctUntilChanged(map(thread) { it?.isGroup ?: false }) } val isMuted: LiveData by lazy { distinctUntilChanged(map(thread) { it?.muted ?: false }) } val isApprovalRequiredToJoin: LiveData by lazy { distinctUntilChanged(map(thread) { it?.approvalRequiredForNewMembers ?: false }) } val isMentionsMuted: LiveData by lazy { distinctUntilChanged(map(thread) { it?.mentionsMuted ?: false }) } val pendingRequestsCount: LiveData by lazy { distinctUntilChanged(map(_pendingRequests) { it?.totalParticipantRequests ?: 0 }) } val inviter: LiveData by lazy { distinctUntilChanged(map(thread) { it?.inviter }) } private var hasOlder = true private var cursor: String? = null private var chatsRequest: Call? = null private fun getUsersWithCurrentUser(t: DirectThread): List { val builder = ImmutableList.builder() if (currentUser != null) { builder.add(currentUser) } val users: List? = t.users if (users != null) { builder.addAll(users) } return builder.build() } fun fetchChats(scope: CoroutineScope) { val fetchingValue = _fetching.value if (fetchingValue != null && fetchingValue.status === Resource.Status.LOADING || !hasOlder) return _fetching.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { val threadFeedResponse = directMessagesRepository.fetchThread(threadId, cursor) if (threadFeedResponse.status != null && threadFeedResponse.status != "ok") { _fetching.postValue(error(R.string.generic_not_ok_response, null)) return@launch } val thread = threadFeedResponse.thread if (thread == null) { _fetching.postValue(error("thread is null!", null)) return@launch } setThread(thread) _fetching.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "Failed fetching dm chats", e) _fetching.postValue(error(e.message, null)) hasOlder = false } } if (cursor == null) { fetchPendingRequests(scope) } } fun fetchPendingRequests(scope: CoroutineScope) { val isGroup = isGroup.value if (isGroup == null || !isGroup) return scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.participantRequests(threadId, 1) _pendingRequests.postValue(response) } catch (e: Exception) { Log.e(TAG, "fetchPendingRequests: ", e) } } } private fun setThread(thread: DirectThread, skipItems: Boolean) { // if (thread.getInputMode() != 1 && thread.isGroup() && viewerIsAdmin) { // fetchPendingRequests(); // } val items = thread.items if (skipItems) { val currentThread = this.thread.value if (currentThread != null) { thread.items = currentThread.items } } if (!skipItems && !cursor.isNullOrBlank()) { val currentThread = this.thread.value if (currentThread != null) { val currentItems = currentThread.items val list = if (currentItems == null) LinkedList() else LinkedList(currentItems) if (items != null) { list.addAll(items) } thread.items = list } } inboxManager.setThread(threadId, thread) } private fun setThread(thread: DirectThread) { setThread(thread, false) } private fun setThreadUsers(users: List?, leftUsers: List?) { val currentThread = thread.value ?: return val thread: DirectThread = try { currentThread.clone() as DirectThread } catch (e: CloneNotSupportedException) { Log.e(TAG, "setThreadUsers: ", e) return } if (users != null) { thread.users = users } if (leftUsers != null) { thread.leftUsers = leftUsers } inboxManager.setThread(threadId, thread) } private fun addItems(index: Int, items: Collection) { inboxManager.addItemsToThread(threadId, index, items) } private fun addReaction(item: DirectItem, emoji: Emoji) { if (currentUser == null) return val isLike = emoji.unicode == "❤️" var reactions = item.reactions reactions = if (reactions == null) { DirectItemReactions(null, null) } else { try { reactions.clone() as DirectItemReactions } catch (e: CloneNotSupportedException) { Log.e(TAG, "addReaction: ", e) return } } if (isLike) { val likes = addEmoji(reactions.likes, null, false) reactions.likes = likes } val emojis = addEmoji(reactions.emojis, emoji.unicode, true) reactions.emojis = emojis val currentItems = items.value val items = if (currentItems == null) LinkedList() else LinkedList(currentItems) val index = getItemIndex(item, items) if (index >= 0) { try { val clone = items[index].clone() as DirectItem clone.reactions = reactions items[index] = clone } catch (e: CloneNotSupportedException) { Log.e(TAG, "addReaction: error cloning", e) } } inboxManager.setItemsToThread(threadId, items) } private fun removeReaction(item: DirectItem) { try { val itemClone = item.clone() as DirectItem val reactions = itemClone.reactions var reactionsClone: DirectItemReactions? = null if (reactions != null) { reactionsClone = reactions.clone() as DirectItemReactions } var likes: List? = null if (reactionsClone != null) { likes = reactionsClone.likes } if (likes != null) { val updatedLikes = likes.stream() .filter { (senderId) -> senderId != viewerId } .collect(Collectors.toList()) if (reactionsClone != null) { reactionsClone.likes = updatedLikes } } var emojis: List? = null if (reactionsClone != null) { emojis = reactionsClone.emojis } if (emojis != null) { val updatedEmojis = emojis.stream() .filter { (senderId) -> senderId != viewerId } .collect(Collectors.toList()) if (reactionsClone != null) { reactionsClone.emojis = updatedEmojis } } itemClone.reactions = reactionsClone val items = items.value val list = if (items == null) LinkedList() else LinkedList(items) val index = getItemIndex(item, list) if (index >= 0) { list[index] = itemClone } inboxManager.setItemsToThread(threadId, list) } catch (e: Exception) { Log.e(TAG, "removeReaction: ", e) } } private fun removeItem(item: DirectItem): Int { val items = items.value val list = if (items == null) LinkedList() else LinkedList(items) val index = getItemIndex(item, list) if (index >= 0) { list.removeAt(index) inboxManager.setItemsToThread(threadId, list) } return index } private fun addEmoji( reactionList: List?, emoji: String?, shouldReplaceIfAlreadyReacted: Boolean, ): List? { if (currentUser == null) return reactionList val temp: MutableList = if (reactionList == null) ArrayList() else ArrayList(reactionList) var index = -1 for (i in temp.indices) { val (senderId) = temp[i] if (senderId == currentUser.pk) { index = i break } } val reaction = DirectItemEmojiReaction( currentUser.pk, System.currentTimeMillis() * 1000, emoji, "none" ) if (index < 0) { temp.add(0, reaction) } else if (shouldReplaceIfAlreadyReacted) { temp[index] = reaction } return temp } fun sendText(text: String, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() val userId = getCurrentUserId(data) ?: return data val clientContext = UUID.randomUUID().toString() val replyToItemValue = _replyToItem.value val directItem = createText(userId, clientContext, text, replyToItemValue) // Log.d(TAG, "sendText: sending: itemId: " + directItem.getItemId()); directItem.isPending = true addItems(0, listOf(directItem)) data.postValue(loading(directItem)) val repliedToItemId = replyToItemValue?.itemId val repliedToClientContext = replyToItemValue?.clientContext scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.broadcastText( csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, text, repliedToItemId, repliedToClientContext ) parseResponse(response, data, directItem) } catch (e: Exception) { data.postValue(error(e.message, directItem)) Log.e(TAG, "sendText: ", e) } } return data } fun sendUri(uri: Uri, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() val mimeType = Utils.getMimeType(uri, contentResolver) if (isEmpty(mimeType)) { data.postValue(error("Unknown MediaType", null)) return data } val isPhoto = mimeType != null && mimeType.startsWith("image") if (isPhoto) { sendPhoto(data, uri, scope) return data } if (mimeType != null && mimeType.startsWith("video")) { sendVideo(data, uri, scope) } return data } fun sendAnimatedMedia(giphyGif: GiphyGif, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() val userId = getCurrentUserId(data) ?: return data val clientContext = UUID.randomUUID().toString() val directItem = createAnimatedMedia(userId, clientContext, giphyGif) directItem.isPending = true addItems(0, listOf(directItem)) data.postValue(loading(directItem)) scope.launch(Dispatchers.IO) { try { val request = directMessagesRepository.broadcastAnimatedMedia( csrfToken, userId, deviceUuid, clientContext, threadIdsOrUserIds, giphyGif ) parseResponse(request, data, directItem) } catch (e: Exception) { data.postValue(error(e.message, directItem)) Log.e(TAG, "sendAnimatedMedia: ", e) } } return data } fun sendVoice( data: MutableLiveData>, uri: Uri, waveform: List, samplingFreq: Int, duration: Long, byteLength: Long, scope: CoroutineScope, ) { if (duration > 60000) { // instagram does not allow uploading audio longer than 60 secs for Direct messages data.postValue(error(R.string.dms_ERROR_AUDIO_TOO_LONG, null)) return } val userId = getCurrentUserId(data) ?: return val clientContext = UUID.randomUUID().toString() val directItem = createVoice(userId, clientContext, uri, duration, waveform, samplingFreq) directItem.isPending = true addItems(0, listOf(directItem)) data.postValue(loading(directItem)) val uploadDmVoiceOptions = createUploadDmVoiceOptions(byteLength, duration) scope.launch(Dispatchers.IO) { try { val response = uploadVideo(uri, contentResolver, uploadDmVoiceOptions) // Log.d(TAG, "onUploadComplete: " + response); if (handleInvalidResponse(data, response)) return@launch val uploadFinishOptions = UploadFinishOptions( uploadDmVoiceOptions.uploadId, "4", null ) mediaRepository.uploadFinish(csrfToken, userId, deviceUuid, uploadFinishOptions) val broadcastResponse = directMessagesRepository.broadcastVoice( csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, uploadDmVoiceOptions.uploadId, waveform, samplingFreq ) parseResponse(broadcastResponse, data, directItem) } catch (e: Exception) { data.postValue(error(e.message, directItem)) Log.e(TAG, "sendVoice: ", e) } } } fun sendReaction( item: DirectItem, emoji: Emoji, scope: CoroutineScope, ): LiveData> { val data = MutableLiveData>() val userId = getCurrentUserId(data) if (userId == null) { data.postValue(error("userId is null", null)) return data } val clientContext = UUID.randomUUID().toString() // Log.d(TAG, "sendText: sending: itemId: " + directItem.getItemId()); data.postValue(loading(item)) addReaction(item, emoji) var emojiUnicode: String? = null if (emoji.unicode != "❤️") { emojiUnicode = emoji.unicode } val itemId = item.itemId if (itemId == null) { data.postValue(error("itemId is null", null)) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.broadcastReaction( csrfToken, userId, deviceUuid, clientContext, threadIdsOrUserIds, itemId, emojiUnicode, false ) } catch (e: Exception) { data.postValue(error(e.message, null)) Log.e(TAG, "sendReaction: ", e) } } return data } fun sendDeleteReaction(itemId: String, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() val item = getItem(itemId) if (item == null) { data.postValue(error("Invalid item", null)) return data } val reactions = item.reactions if (reactions == null) { // already removed? data.postValue(success(item)) return data } removeReaction(item) val clientContext = UUID.randomUUID().toString() val itemId1 = item.itemId if (itemId1 == null) { data.postValue(error("itemId is null", null)) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.broadcastReaction( csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, itemId1, null, true ) } catch (e: Exception) { data.postValue(error(e.message, null)) Log.e(TAG, "sendDeleteReaction: ", e) } } return data } fun unsend(item: DirectItem, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() val index = removeItem(item) val itemId = item.itemId if (itemId == null) { data.postValue(error("itemId is null", null)) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.deleteItem(csrfToken, deviceUuid, threadId, itemId) } catch (e: Exception) { // add the item back if unsuccessful addItems(index, listOf(item)) data.postValue(error(e.message, item)) Log.e(TAG, "unsend: ", e) } } return data } fun forward( recipients: Set, itemToForward: DirectItem, scope: CoroutineScope, ) { for (recipient in recipients) { forward(recipient, itemToForward, scope) } } fun forward( recipient: RankedRecipient, itemToForward: DirectItem, scope: CoroutineScope, ) { if (recipient.thread == null && recipient.user != null) { scope.launch(Dispatchers.IO) { // create thread and forward val thread = DirectMessagesManager.createThread(recipient.user.pk) forward(thread, itemToForward, scope) } return } if (recipient.thread != null) { // just forward val thread = recipient.thread forward(thread, itemToForward, scope) } } fun setReplyToItem(item: DirectItem?) { // Log.d(TAG, "setReplyToItem: " + item); _replyToItem.postValue(item) } private fun forward( thread: DirectThread, itemToForward: DirectItem, scope: CoroutineScope, ): LiveData> { val data = MutableLiveData>() val forwardItemId = itemToForward.itemId if (forwardItemId == null) { data.postValue(error("item id is null", null)) return data } val itemType = itemToForward.itemType if (itemType == null) { data.postValue(error("item type is null", null)) return data } val itemTypeName = DirectItemType.getName(itemType) if (itemTypeName == null) { Log.e(TAG, "forward: itemTypeName was null!") data.postValue(error("itemTypeName is null", null)) return data } data.postValue(loading(null)) if (thread.threadId == null) { Log.e(TAG, "forward: threadId was null!") data.postValue(error("threadId is null", null)) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.forward( thread.threadId, itemTypeName, threadId, forwardItemId ) data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "forward: ", e) data.postValue(error(e.message, null)) } } return data } fun acceptRequest(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { directMessagesRepository.approveRequest(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "acceptRequest: ", e) data.postValue(error(e.message, null)) } } return data } fun declineRequest(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { directMessagesRepository.declineRequest(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "declineRequest: ", e) data.postValue(error(e.message, null)) } } return data } fun refreshChats(scope: CoroutineScope) { val isFetching = _fetching.value if (isFetching != null && isFetching.status === Resource.Status.LOADING) { stopCurrentRequest() } cursor = null hasOlder = true fetchChats(scope) } private fun sendPhoto( data: MutableLiveData>, uri: Uri, scope: CoroutineScope, ) { try { val dimensions = BitmapUtils.decodeDimensions(contentResolver, uri) if (dimensions == null) { data.postValue(error("Decoding dimensions failed", null)) return } sendPhoto(data, uri, dimensions.first, dimensions.second, scope) } catch (e: IOException) { data.postValue(error(e.message, null)) Log.e(TAG, "sendPhoto: ", e) } } private fun sendPhoto( data: MutableLiveData>, uri: Uri, width: Int, height: Int, scope: CoroutineScope, ) { val clientContext = UUID.randomUUID().toString() val directItem = createImageOrVideo(viewerId, clientContext, uri, width, height, false) directItem.isPending = true addItems(0, listOf(directItem)) data.postValue(loading(directItem)) scope.launch(Dispatchers.IO) { try { val response = uploadPhoto(uri, contentResolver) if (handleInvalidResponse(data, response)) return@launch val response1 = response.response ?: return@launch val uploadId = response1.optString("upload_id") val response2 = directMessagesRepository.broadcastPhoto(csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, uploadId) parseResponse(response2, data, directItem) } catch (e: Exception) { data.postValue(error(e.message, null)) Log.e(TAG, "sendPhoto: ", e) } } } private fun sendVideo( data: MutableLiveData>, uri: Uri, scope: CoroutineScope, ) { MediaUtils.getVideoInfo(contentResolver, uri, object : OnInfoLoadListener { override fun onLoad(info: VideoInfo?) { if (info == null) { data.postValue(error("Could not get the video info", null)) return } sendVideo(data, uri, info.size, info.duration, info.width, info.height, scope) } override fun onFailure(t: Throwable) { data.postValue(error(t.message, null)) } }) } private fun sendVideo( data: MutableLiveData>, uri: Uri, byteLength: Long, duration: Long, width: Int, height: Int, scope: CoroutineScope, ) { if (duration > 60000) { // instagram does not allow uploading videos longer than 60 secs for Direct messages data.postValue(error(R.string.dms_ERROR_VIDEO_TOO_LONG, null)) return } val userId = getCurrentUserId(data) ?: return val clientContext = UUID.randomUUID().toString() val directItem = createImageOrVideo(userId, clientContext, uri, width, height, true) directItem.isPending = true addItems(0, listOf(directItem)) data.postValue(loading(directItem)) val uploadDmVideoOptions = createUploadDmVideoOptions(byteLength, duration, width, height) scope.launch(Dispatchers.IO) { try { val response = uploadVideo(uri, contentResolver, uploadDmVideoOptions) // Log.d(TAG, "onUploadComplete: " + response); if (handleInvalidResponse(data, response)) return@launch val uploadFinishOptions = UploadFinishOptions( uploadDmVideoOptions.uploadId, "2", VideoOptions(duration / 1000f, emptyList(), 0, false) ) mediaRepository.uploadFinish(csrfToken, userId, deviceUuid, uploadFinishOptions) val broadcastResponse = directMessagesRepository.broadcastVideo( csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, uploadDmVideoOptions.uploadId, "", true ) parseResponse(broadcastResponse, data, directItem) } catch (e: Exception) { data.postValue(error(e.message, directItem)) Log.e(TAG, "sendVideo: ", e) } } } private fun parseResponse( response: DirectThreadBroadcastResponse, data: MutableLiveData>, directItem: DirectItem, ) { val payloadClientContext: String? val timestamp: Long val itemId: String? val payload = response.payload if (payload == null) { val messageMetadata = response.messageMetadata if (messageMetadata == null || messageMetadata.isEmpty()) { data.postValue(success(directItem)) return } val (clientContext, itemId1, timestamp1) = messageMetadata[0] payloadClientContext = clientContext itemId = itemId1 timestamp = timestamp1 } else { payloadClientContext = payload.clientContext timestamp = payload.timestamp itemId = payload.itemId } if (payloadClientContext == null) { data.postValue(error("clientContext in response was null", null)) return } updateItemSent(payloadClientContext, timestamp, itemId) data.postValue(success(directItem)) } private fun updateItemSent( clientContext: String, timestamp: Long, itemId: String?, ) { val items = items.value val list = if (items == null) LinkedList() else LinkedList(items) val index = list.indexOfFirst { it?.clientContext == clientContext } if (index < 0) return val directItem = list[index] try { val itemClone = directItem.clone() as DirectItem itemClone.itemId = itemId itemClone.isPending = false itemClone.setTimestamp(timestamp) list[index] = itemClone inboxManager.setItemsToThread(threadId, list) } catch (e: CloneNotSupportedException) { Log.e(TAG, "updateItemSent: ", e) } } private fun handleInvalidResponse( data: MutableLiveData>, response: MediaUploadResponse, ): Boolean { val responseJson = response.response if (responseJson == null || response.responseCode != HttpURLConnection.HTTP_OK) { data.postValue(error(R.string.generic_not_ok_response, null)) return true } val status = responseJson.optString("status") if (isEmpty(status) || status != "ok") { data.postValue(error(R.string.generic_not_ok_response, null)) return true } return false } private fun getItemIndex(item: DirectItem, list: List): Int { return Iterables.indexOf(list) { i: DirectItem? -> i != null && i.itemId == item.itemId } } private fun getItem(itemId: String): DirectItem? { val items = items.value ?: return null return items.asSequence() .filter { it.itemId == itemId } .firstOrNull() } private fun stopCurrentRequest() { chatsRequest?.let { if (it.isExecuted || it.isCanceled) return it.cancel() } _fetching.postValue(success(Any())) } private fun getCurrentUserId(data: MutableLiveData>): Long? { if (currentUser == null || currentUser.pk <= 0) { data.postValue(error(R.string.dms_ERROR_INVALID_USER, null)) return null } return currentUser.pk } fun removeThread() { val pendingValue = isPending.value val threadInPending = pendingValue != null && pendingValue inboxManager.removeThread(threadId) if (threadInPending) { val totalValue = inboxManager.getPendingRequestsTotal().value ?: return inboxManager.setPendingRequestsTotal(totalValue - 1) } } fun updateTitle(newTitle: String, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.updateTitle(csrfToken, deviceUuid, threadId, newTitle.trim()) handleDetailsChangeResponse(data, response) } catch (e: Exception) { } } return data } fun addMembers(users: Set, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.addUsers( csrfToken, deviceUuid, threadId, users.map { obj: User -> obj.pk } ) handleDetailsChangeResponse(data, response) } catch (e: Exception) { Log.e(TAG, "addMembers: ", e) data.postValue(error(e.message, null)) } } return data } fun removeMember(user: User, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { directMessagesRepository.removeUsers(csrfToken, deviceUuid, threadId, setOf(user.pk)) data.postValue(success(Any())) var activeUsers = users.value var leftUsersValue = leftUsers.value if (activeUsers == null) { activeUsers = emptyList() } if (leftUsersValue == null) { leftUsersValue = emptyList() } val updatedActiveUsers = activeUsers.filter { u: User -> u.pk != user.pk } val updatedLeftUsersBuilder = ImmutableList.builder().addAll(leftUsersValue) if (!leftUsersValue.contains(user)) { updatedLeftUsersBuilder.add(user) } val updatedLeftUsers = updatedLeftUsersBuilder.build() setThreadUsers(updatedActiveUsers, updatedLeftUsers) } catch (e: Exception) { Log.e(TAG, "removeMember: ", e) data.postValue(error(e.message, null)) } } return data } fun isAdmin(user: User): Boolean { val adminUserIdsValue = adminUserIds.value return adminUserIdsValue != null && adminUserIdsValue.contains(user.pk) } fun makeAdmin(user: User, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() if (isAdmin(user)) return data scope.launch(Dispatchers.IO) { try { directMessagesRepository.addAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) val currentAdminIds = adminUserIds.value val updatedAdminIds = ImmutableList.builder() .addAll(currentAdminIds ?: emptyList()) .add(user.pk) .build() val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.adminUserIds = updatedAdminIds inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "makeAdmin: ", e) } data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "makeAdmin: ", e) data.postValue(error(e.message, null)) } } return data } fun removeAdmin(user: User, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() if (!isAdmin(user)) return data scope.launch(Dispatchers.IO) { try { directMessagesRepository.removeAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) val currentAdmins = adminUserIds.value ?: return@launch val updatedAdminUserIds = currentAdmins.filter { userId1: Long -> userId1 != user.pk } val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.adminUserIds = updatedAdminUserIds inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "removeAdmin: ", e) } data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "removeAdmin: ", e) data.postValue(error(e.message, null)) } } return data } fun mute(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val muted = isMuted.value if (muted != null && muted) { data.postValue(success(Any())) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.mute(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.muted = true inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "mute: ", e) } } catch (e: Exception) { Log.e(TAG, "mute: ", e) data.postValue(error(e.message, null)) } } return data } fun unmute(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val muted = isMuted.value if (muted != null && !muted) { data.postValue(success(Any())) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.unmute(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.muted = false inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "unmute: ", e) } } catch (e: Exception) { Log.e(TAG, "unmute: ", e) data.postValue(error(e.message, null)) } } return data } fun muteMentions(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val mentionsMuted = isMentionsMuted.value if (mentionsMuted != null && mentionsMuted) { data.postValue(success(Any())) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.muteMentions(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.mentionsMuted = true inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "muteMentions: ", e) } } catch (e: Exception) { Log.e(TAG, "muteMentions: ", e) data.postValue(error(e.message, null)) } } return data } fun unmuteMentions(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val mentionsMuted = isMentionsMuted.value if (mentionsMuted != null && !mentionsMuted) { data.postValue(success(Any())) return data } scope.launch(Dispatchers.IO) { try { directMessagesRepository.unmuteMentions(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.mentionsMuted = false inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "unmuteMentions: ", e) } } catch (e: Exception) { Log.e(TAG, "unmuteMentions: ", e) data.postValue(error(e.message, null)) } } return data } fun blockUser(user: User, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { friendshipRepository.changeBlock(csrfToken, viewerId, deviceUuid, false, user.pk) refreshChats(scope) } catch (e: Exception) { Log.e(TAG, "onFailure: ", e) data.postValue(error(e.message, null)) } } return data } fun unblockUser(user: User, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { friendshipRepository.changeBlock(csrfToken, viewerId, deviceUuid, true, user.pk) refreshChats(scope) } catch (e: Exception) { Log.e(TAG, "onFailure: ", e) data.postValue(error(e.message, null)) } } return data } fun restrictUser(user: User, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { friendshipRepository.toggleRestrict(csrfToken, deviceUuid, user.pk, true) refreshChats(scope) } catch (e: Exception) { Log.e(TAG, "onFailure: ", e) data.postValue(error(e.message, null)) } } return data } fun unRestrictUser(user: User, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { friendshipRepository.toggleRestrict(csrfToken, deviceUuid, user.pk, false) refreshChats(scope) } catch (e: Exception) { Log.e(TAG, "onFailure: ", e) data.postValue(error(e.message, null)) } } return data } fun approveUsers(users: List, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.approveParticipantRequests( csrfToken, deviceUuid, threadId, users.map { obj: User -> obj.pk } ) handleDetailsChangeResponse(data, response) pendingUserApproveDenySuccessAction(users) } catch (e: Exception) { Log.e(TAG, "approveUsers: ", e) data.postValue(error(e.message, null)) } } return data } fun denyUsers(users: List, scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.declineParticipantRequests( csrfToken, deviceUuid, threadId, users.map { obj: User -> obj.pk } ) handleDetailsChangeResponse(data, response) pendingUserApproveDenySuccessAction(users) } catch (e: Exception) { Log.e(TAG, "denyUsers: ", e) data.postValue(error(e.message, null)) } } return data } private fun pendingUserApproveDenySuccessAction(users: List) { val pendingRequestsValue = _pendingRequests.value ?: return val pendingUsers = pendingRequestsValue.users if (pendingUsers == null || pendingUsers.isEmpty()) return val filtered = pendingUsers.filter { o: User -> !users.contains(o) } try { val clone = pendingRequestsValue.clone() as DirectThreadParticipantRequestsResponse clone.users = filtered val totalParticipantRequests = clone.totalParticipantRequests clone.totalParticipantRequests = if (totalParticipantRequests > 0) totalParticipantRequests - 1 else 0 _pendingRequests.postValue(clone) } catch (e: CloneNotSupportedException) { Log.e(TAG, "pendingUserApproveDenySuccessAction: ", e) } } fun approvalRequired(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val approvalRequiredToJoin = isApprovalRequiredToJoin.value if (approvalRequiredToJoin != null && approvalRequiredToJoin) { data.postValue(success(Any())) return data } scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.approvalRequired(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, response) val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.approvalRequiredForNewMembers = true inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "onResponse: ", e) } } catch (e: Exception) { Log.e(TAG, "approvalRequired: ", e) data.postValue(error(e.message, null)) } } return data } fun approvalNotRequired(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val approvalRequiredToJoin = isApprovalRequiredToJoin.value if (approvalRequiredToJoin != null && !approvalRequiredToJoin) { data.postValue(success(Any())) return data } scope.launch(Dispatchers.IO) { try { val request = directMessagesRepository.approvalNotRequired(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, request) val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.approvalRequiredForNewMembers = false inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "onResponse: ", e) } } catch (e: Exception) { Log.e(TAG, "approvalNotRequired: ", e) data.postValue(error(e.message, null)) } } return data } fun leave(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { val request = directMessagesRepository.leave(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, request) } catch (e: Exception) { Log.e(TAG, "leave: ", e) data.postValue(error(e.message, null)) } } return data } fun end(scope: CoroutineScope): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { val request = directMessagesRepository.end(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, request) val currentThread = thread.value ?: return@launch try { val thread = currentThread.clone() as DirectThread thread.inputMode = 1 inboxManager.setThread(threadId, thread) } catch (e: CloneNotSupportedException) { Log.e(TAG, "onResponse: ", e) } } catch (e: Exception) { Log.e(TAG, "leave: ", e) data.postValue(error(e.message, null)) } } return data } private fun handleDetailsChangeResponse( data: MutableLiveData>, response: DirectThreadDetailsChangeResponse, ) { data.postValue(success(Any())) val thread = response.thread if (thread != null) { setThread(thread, true) } } fun markAsSeen( directItem: DirectItem, scope: CoroutineScope, ): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { val response = directMessagesRepository.markAsSeen(csrfToken, deviceUuid, threadId, directItem) if (response == null) { data.postValue(error(R.string.generic_null_response, null)) return@launch } if (currentUser == null) return@launch inboxManager.fetchUnseenCount(scope) val payload = response.payload ?: return@launch val timestamp = payload.timestamp val thread = thread.value ?: return@launch val currentLastSeenAt = thread.lastSeenAt val lastSeenAt = if (currentLastSeenAt == null) HashMap() else HashMap(currentLastSeenAt) lastSeenAt[currentUser.pk] = DirectThreadLastSeenAt(timestamp, directItem.itemId) thread.lastSeenAt = lastSeenAt setThread(thread, true) data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "markAsSeen: ", e) data.postValue(error(e.message, null)) } } return data } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/Comment.kt ================================================ package awais.instagrabber.models import awais.instagrabber.repositories.responses.User import awais.instagrabber.utils.TextUtils import java.io.Serializable import java.util.* class Comment( val pk: String, val text: String, val createdAt: Long, var commentLikeCount: Long, private var hasLikedComment: Boolean, val user: User, val childCommentCount: Int ) : Serializable, Cloneable { val dateTime: String get() = TextUtils.epochSecondToString(createdAt) fun getLiked(): Boolean { return hasLikedComment } fun setLiked(hasLikedComment: Boolean) { commentLikeCount = if (hasLikedComment) commentLikeCount + 1 else commentLikeCount - 1 this.hasLikedComment = hasLikedComment } @Throws(CloneNotSupportedException::class) public override fun clone(): Any { return super.clone() } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as Comment if (pk != other.pk) return false if (text != other.text) return false if (createdAt != other.createdAt) return false if (commentLikeCount != other.commentLikeCount) return false if (hasLikedComment != other.hasLikedComment) return false if (user != other.user) return false if (childCommentCount != other.childCommentCount) return false return true } override fun hashCode(): Int { var result = pk.hashCode() result = 31 * result + text.hashCode() result = 31 * result + createdAt.hashCode() result = 31 * result + commentLikeCount.hashCode() result = 31 * result + hasLikedComment.hashCode() result = 31 * result + user.hashCode() result = 31 * result + childCommentCount return result } override fun toString(): String { return "Comment(pk='$pk', text='$text', createdAt=$createdAt, commentLikeCount=$commentLikeCount, hasLikedComment=$hasLikedComment, user=$user, childCommentCount=$childCommentCount)" } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/IntentModel.kt ================================================ package awais.instagrabber.models import awais.instagrabber.models.enums.IntentModelType data class IntentModel(val type: IntentModelType, val text: String) ================================================ FILE: app/src/main/java/awais/instagrabber/models/PostsLayoutPreferences.java ================================================ package awais.instagrabber.models; import com.google.gson.Gson; import java.util.Objects; public final class PostsLayoutPreferences { private final PostsLayoutType type; private final int colCount; private final boolean isAvatarVisible; private final boolean isNameVisible; private final ProfilePicSize profilePicSize; private final boolean hasRoundedCorners; private final boolean hasGap; private final boolean animationDisabled; public static class Builder { private PostsLayoutType type = PostsLayoutType.GRID; private int colCount = 3; private boolean isAvatarVisible = true; private boolean isNameVisible = false; private ProfilePicSize profilePicSize = ProfilePicSize.SMALL; private boolean hasRoundedCorners = true; private boolean hasGap = true; private boolean animationDisabled = false; public Builder setType(final PostsLayoutType type) { this.type = type; return this; } public Builder setColCount(final int colCount) { this.colCount = (colCount <= 0 || colCount > 3) ? 1 : colCount; return this; } public Builder setAvatarVisible(final boolean avatarVisible) { this.isAvatarVisible = avatarVisible; return this; } public Builder setNameVisible(final boolean nameVisible) { this.isNameVisible = nameVisible; return this; } public Builder setProfilePicSize(final ProfilePicSize profilePicSize) { this.profilePicSize = profilePicSize; return this; } public Builder setHasRoundedCorners(final boolean hasRoundedCorners) { this.hasRoundedCorners = hasRoundedCorners; return this; } public Builder setHasGap(final boolean hasGap) { this.hasGap = hasGap; return this; } public Builder setAnimationDisabled(final boolean animationDisabled) { this.animationDisabled = animationDisabled; return this; } // Breaking builder pattern and adding getters to avoid too many object creations in PostsLayoutPreferencesDialogFragment public PostsLayoutType getType() { return type; } public int getColCount() { return colCount; } public boolean isAvatarVisible() { return isAvatarVisible; } public boolean isNameVisible() { return isNameVisible; } public ProfilePicSize getProfilePicSize() { return profilePicSize; } public boolean getHasRoundedCorners() { return hasRoundedCorners; } public boolean getHasGap() { return hasGap; } public boolean isAnimationDisabled() { return animationDisabled; } public Builder mergeFrom(final PostsLayoutPreferences preferences) { if (preferences == null) { return this; } setColCount(preferences.getColCount()); setAvatarVisible(preferences.isAvatarVisible()); setNameVisible(preferences.isNameVisible()); setType(preferences.getType()); setProfilePicSize(preferences.getProfilePicSize()); setHasRoundedCorners(preferences.getHasRoundedCorners()); setHasGap(preferences.getHasGap()); setAnimationDisabled(preferences.isAnimationDisabled()); return this; } public PostsLayoutPreferences build() { return new PostsLayoutPreferences(type, colCount, isAvatarVisible, isNameVisible, profilePicSize, hasRoundedCorners, hasGap, animationDisabled); } } public static Builder builder() { return new Builder(); } private PostsLayoutPreferences(final PostsLayoutType type, final int colCount, final boolean isAvatarVisible, final boolean isNameVisible, final ProfilePicSize profilePicSize, final boolean hasRoundedCorners, final boolean hasGap, final boolean animationDisabled) { this.type = type; this.colCount = colCount; this.isAvatarVisible = isAvatarVisible; this.isNameVisible = isNameVisible; this.profilePicSize = profilePicSize; this.hasRoundedCorners = hasRoundedCorners; this.hasGap = hasGap; this.animationDisabled = animationDisabled; } public PostsLayoutType getType() { return type; } public int getColCount() { return colCount; } public boolean isAvatarVisible() { return isAvatarVisible; } public boolean isNameVisible() { return isNameVisible; } public ProfilePicSize getProfilePicSize() { return profilePicSize; } public boolean getHasRoundedCorners() { return hasRoundedCorners; } public boolean getHasGap() { return hasGap; } public String getJson() { return new Gson().toJson(this); } public static PostsLayoutPreferences fromJson(final String json) { if (json == null) return null; return new Gson().fromJson(json, PostsLayoutPreferences.class); } public boolean isAnimationDisabled() { return animationDisabled; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final PostsLayoutPreferences that = (PostsLayoutPreferences) o; return colCount == that.colCount && isAvatarVisible == that.isAvatarVisible && isNameVisible == that.isNameVisible && type == that.type && profilePicSize == that.profilePicSize && animationDisabled == that.animationDisabled; } @Override public int hashCode() { return Objects.hash(type, colCount, isAvatarVisible, isNameVisible, profilePicSize, animationDisabled); } @Override public String toString() { return "PostsLayoutPreferences{" + "type=" + type + ", colCount=" + colCount + ", isAvatarVisible=" + isAvatarVisible + ", isNameVisible=" + isNameVisible + ", profilePicSize=" + profilePicSize + ", hasRoundedCorners=" + hasRoundedCorners + ", hasGap=" + hasGap + ", animationDisabled=" + animationDisabled + '}'; } public enum PostsLayoutType { GRID, STAGGERED_GRID, LINEAR } public enum ProfilePicSize { REGULAR, SMALL, TINY } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/Resource.kt ================================================ package awais.instagrabber.models import androidx.annotation.StringRes data class Resource( @JvmField val status: Status, @JvmField val data: T? = null, @JvmField val message: String? = null, @JvmField @StringRes val resId: Int = 0, ) { enum class Status { SUCCESS, ERROR, LOADING } companion object { @JvmStatic fun success(data: T): Resource { return Resource(Status.SUCCESS, data, null, 0) } @JvmStatic fun error(msg: String?, data: T?): Resource { return Resource(Status.ERROR, data, msg, 0) } @JvmStatic fun error(resId: Int, data: T?): Resource { return Resource(Status.ERROR, data, null, resId) } @JvmStatic fun loading(data: T?): Resource { return Resource(Status.LOADING, data, null, 0) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/SavedImageEditState.kt ================================================ package awais.instagrabber.models import android.graphics.RectF import awais.instagrabber.fragments.imageedit.filters.FiltersHelper import awais.instagrabber.utils.SerializablePair import java.util.* data class SavedImageEditState(val sessionId: String, val originalPath: String) { var cropImageMatrixValues: FloatArray? = null // 9 values of matrix var cropRect: RectF? = null var appliedTuningFilters: HashMap>? = null var appliedFilter: SerializablePair>? = null } ================================================ FILE: app/src/main/java/awais/instagrabber/models/Tab.kt ================================================ package awais.instagrabber.models import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.NavigationRes data class Tab( @param:DrawableRes val iconResId: Int, val title: String, val isRemovable: Boolean, /** * This is the actual resource id of the navigation resource (R.navigation.graphName = navigationResId) */ @param:NavigationRes val navigationResId: Int, /** * This is the resource id of the root navigation tag of the navigation resource. * * eg: inside R.navigation.direct_messages_nav_graph, the id of the root tag is R.id.direct_messages_nav_graph. * * So this field would equal to the value of R.id.direct_messages_nav_graph */ @param:IdRes val navigationRootId: Int, /** * This is the start destination of the nav graph */ @param:IdRes val startDestinationFragmentId: Int, ) ================================================ FILE: app/src/main/java/awais/instagrabber/models/UploadPhotoOptions.kt ================================================ package awais.instagrabber.models data class UploadPhotoOptions( val uploadId: String? = null, val name: String, val byteLength: Long = 0, val isSideCar: Boolean = false, val waterfallId: String? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/models/UploadVideoOptions.kt ================================================ package awais.instagrabber.models import awais.instagrabber.models.enums.MediaItemType data class UploadVideoOptions( val uploadId: String, val name: String, val byteLength: Long = 0, val duration: Long = 0, val width: Int = 0, val height: Int = 0, val isSideCar: Boolean = false, // Stories val forAlbum: Boolean = false, val isDirect: Boolean = false, val isDirectVoice: Boolean = false, val isForDirectStory: Boolean = false, val isIgtvVideo: Boolean = false, val waterfallId: String? = null, val offset: Long = 0, val mediaType: MediaItemType? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/BroadcastItemType.kt ================================================ package awais.instagrabber.models.enums enum class BroadcastItemType(val value: String) { TEXT("text"), REACTION("reaction"), REELSHARE("reel_share"), IMAGE("configure_photo"), LINK("link"), VIDEO("configure_video"), VOICE("share_voice"), ANIMATED_MEDIA("animated_media"), MEDIA_SHARE("media_share"), PROFILE("profile"), STORY("story_share"), // not reply } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/DirectItemType.kt ================================================ package awais.instagrabber.models.enums import com.google.gson.annotations.SerializedName import java.io.Serializable enum class DirectItemType(val id: Int) : Serializable { UNKNOWN(0), @SerializedName("text") TEXT(1), @SerializedName("like") LIKE(2), @SerializedName("link") LINK(3), @SerializedName("media") MEDIA(4), @SerializedName("raven_media") RAVEN_MEDIA(5), @SerializedName("profile") PROFILE(6), @SerializedName("video_call_event") VIDEO_CALL_EVENT(7), @SerializedName("animated_media") ANIMATED_MEDIA(8), @SerializedName("voice_media") VOICE_MEDIA(9), @SerializedName("media_share") MEDIA_SHARE(10), @SerializedName("reel_share") REEL_SHARE(11), @SerializedName("action_log") ACTION_LOG(12), @SerializedName("placeholder") PLACEHOLDER(13), @SerializedName("story_share") STORY_SHARE(14), @SerializedName("clip") CLIP(15), // media_share but reel @SerializedName("felix_share") FELIX_SHARE(16), // media_share but igtv @SerializedName("location") LOCATION(17), @SerializedName("xma") XMA(18); // self avatar stickers companion object { private val map: MutableMap = mutableMapOf() @JvmStatic fun getTypeFromId(id: Int): DirectItemType { return map[id] ?: UNKNOWN } fun getName(directItemType: DirectItemType): String? { when (directItemType) { TEXT -> return "text" LIKE -> return "like" LINK -> return "link" MEDIA -> return "media" RAVEN_MEDIA -> return "raven_media" PROFILE -> return "profile" VIDEO_CALL_EVENT -> return "video_call_event" ANIMATED_MEDIA -> return "animated_media" VOICE_MEDIA -> return "voice_media" MEDIA_SHARE -> return "media_share" REEL_SHARE -> return "reel_share" ACTION_LOG -> return "action_log" PLACEHOLDER -> return "placeholder" STORY_SHARE -> return "story_share" CLIP -> return "clip" FELIX_SHARE -> return "felix_share" LOCATION -> return "location" else -> return null } } init { for (type in values()) { map[type.id] = type } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/FavoriteType.kt ================================================ package awais.instagrabber.models.enums enum class FavoriteType { TOP, // used just for searching USER, HASHTAG, LOCATION, } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/FollowingType.kt ================================================ package awais.instagrabber.models.enums import java.io.Serializable import java.util.* enum class FollowingType(val id: Int) : Serializable { FOLLOWING(1), NOT_FOLLOWING(0); companion object { private val map: MutableMap = mutableMapOf() @JvmStatic fun valueOf(id: Int): FollowingType? { return map[id] } init { for (type in values()) { map[type.id] = type } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/IntentModelType.kt ================================================ package awais.instagrabber.models.enums enum class IntentModelType { UNKNOWN, USERNAME, POST, HASHTAG, LOCATION, } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/MediaItemType.kt ================================================ package awais.instagrabber.models.enums import java.io.Serializable enum class MediaItemType(val id: Int) : Serializable { MEDIA_TYPE_IMAGE(1), MEDIA_TYPE_VIDEO(2), MEDIA_TYPE_SLIDER(8), MEDIA_TYPE_VOICE(11), MEDIA_TYPE_LIVE(5); // arbitrary companion object { private val map: MutableMap = mutableMapOf() @JvmStatic fun valueOf(id: Int): MediaItemType? { return map[id] } init { for (type in values()) { map[type.id] = type } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/NotificationType.kt ================================================ package awais.instagrabber.models.enums import java.io.Serializable enum class NotificationType(val itemType: Int) : Serializable { LIKE(60), FOLLOW(101), COMMENT(12), // NOT TESTED COMMENT_MENTION(66), TAGGED(102), // NOT TESTED COMMENT_LIKE(13), TAGGED_COMMENT(14), RESPONDED_STORY(213), REQUEST(75), AYML(9999); companion object { private val map: MutableMap = mutableMapOf() @JvmStatic fun valueOfType(itemType: Int): NotificationType? { return map[itemType] } init { for (type in values()) { map[type.itemType] = type } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/PostItemType.kt ================================================ package awais.instagrabber.models.enums import java.io.Serializable enum class PostItemType : Serializable { MAIN, DISCOVER, FEED, SAVED, COLLECTION, LIKED, TAGGED, HASHTAG, LOCATION } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/RavenMediaViewMode.kt ================================================ package awais.instagrabber.models.enums import com.google.gson.annotations.SerializedName enum class RavenMediaViewMode { @SerializedName("permanent") PERMANENT, @SerializedName("replayable") REPLAYABLE, @SerializedName("once") ONCE, } ================================================ FILE: app/src/main/java/awais/instagrabber/models/enums/StoryPaginationType.kt ================================================ package awais.instagrabber.models.enums import java.io.Serializable enum class StoryPaginationType : Serializable { FORWARD, BACKWARD, DO_NOTHING, ERROR } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/CollectionRepository.java ================================================ package awais.instagrabber.repositories; import java.util.Map; import awais.instagrabber.repositories.responses.UserFeedResponse; import awais.instagrabber.repositories.responses.WrappedFeedResponse; import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; import retrofit2.Call; import retrofit2.http.FieldMap; import retrofit2.http.FormUrlEncoded; import retrofit2.http.GET; import retrofit2.http.POST; import retrofit2.http.Path; import retrofit2.http.QueryMap; public interface CollectionRepository { @FormUrlEncoded @POST("/api/v1/collections/{id}/{action}/") Call changeCollection(@Path("id") String id, @Path("action") String action, @FieldMap Map signedForm); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/CommentRepository.java ================================================ package awais.instagrabber.repositories; import java.util.Map; import awais.instagrabber.repositories.responses.CommentsFetchResponse; import awais.instagrabber.repositories.responses.ChildCommentsFetchResponse; import retrofit2.Call; import retrofit2.http.FieldMap; import retrofit2.http.FormUrlEncoded; import retrofit2.http.GET; import retrofit2.http.Header; import retrofit2.http.POST; import retrofit2.http.Path; import retrofit2.http.Query; import retrofit2.http.QueryMap; public interface CommentRepository { @GET("/api/v1/media/{mediaId}/comments/") Call fetchComments(@Path("mediaId") final String mediaId, @QueryMap final Map queryMap); @GET("/api/v1/media/{mediaId}/comments/{commentId}/inline_child_comments/") Call fetchChildComments(@Path("mediaId") final String mediaId, @Path("commentId") final String commentId, @QueryMap final Map queryMap); @FormUrlEncoded @POST("/api/v1/media/{mediaId}/comment/") Call comment(@Path("mediaId") final String mediaId, @FieldMap final Map signedForm); @FormUrlEncoded @POST("/api/v1/media/{mediaId}/comment/bulk_delete/") Call commentsBulkDelete(@Path("mediaId") final String mediaId, @FieldMap final Map signedForm); @FormUrlEncoded @POST("/api/v1/media/{commentId}/comment_like/") Call commentLike(@Path("commentId") final String commentId, @FieldMap final Map signedForm); @FormUrlEncoded @POST("/api/v1/media/{commentId}/comment_unlike/") Call commentUnlike(@Path("commentId") final String commentId, @FieldMap final Map signedForm); @GET("/api/v1/language/translate/") Call translate(@QueryMap final Map form); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/DirectMessagesService.kt ================================================ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.directmessages.* import retrofit2.http.* interface DirectMessagesService { @GET("/api/v1/direct_v2/inbox/") suspend fun fetchInbox(@QueryMap queryMap: Map): DirectInboxResponse @GET("/api/v1/direct_v2/pending_inbox/") suspend fun fetchPendingInbox(@QueryMap queryMap: Map): DirectInboxResponse @GET("/api/v1/direct_v2/threads/{threadId}/") suspend fun fetchThread( @Path("threadId") threadId: String, @QueryMap queryMap: Map, ): DirectThreadFeedResponse @GET("/api/v1/direct_v2/get_badge_count/?no_raven=1") suspend fun fetchUnseenCount(): DirectBadgeCount @FormUrlEncoded @POST("/api/v1/direct_v2/threads/broadcast/{item}/") suspend fun broadcast( @Path("item") item: String, @FieldMap signedForm: Map, ): DirectThreadBroadcastResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/add_user/") suspend fun addUsers( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/remove_users/") suspend fun removeUsers( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/update_title/") suspend fun updateTitle( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/add_admins/") suspend fun addAdmins( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/remove_admins/") suspend fun removeAdmins( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/items/{itemId}/delete/") suspend fun deleteItem( @Path("threadId") threadId: String, @Path("itemId") itemId: String, @FieldMap form: Map, ): String @GET("/api/v1/direct_v2/ranked_recipients/") suspend fun rankedRecipients(@QueryMap queryMap: Map): RankedRecipientsResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/broadcast/forward/") suspend fun forward(@FieldMap form: Map): DirectThreadBroadcastResponse @FormUrlEncoded @POST("/api/v1/direct_v2/create_group_thread/") suspend fun createThread(@FieldMap signedForm: Map): DirectThread @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/mute/") suspend fun mute( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/unmute/") suspend fun unmute( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/mute_mentions/") suspend fun muteMentions( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/unmute_mentions/") suspend fun unmuteMentions( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @GET("/api/v1/direct_v2/threads/{threadId}/participant_requests/") suspend fun participantRequests( @Path("threadId") threadId: String, @Query("page_size") pageSize: Int, @Query("cursor") cursor: String?, ): DirectThreadParticipantRequestsResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/approve_participant_requests/") suspend fun approveParticipantRequests( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/deny_participant_requests/") suspend fun declineParticipantRequests( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/approval_required_for_new_members/") suspend fun approvalRequired( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/approval_not_required_for_new_members/") suspend fun approvalNotRequired( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/leave/") suspend fun leave( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/remove_all_users/") suspend fun end( @Path("threadId") threadId: String, @FieldMap form: Map, ): DirectThreadDetailsChangeResponse @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/approve/") suspend fun approveRequest( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/decline/") suspend fun declineRequest( @Path("threadId") threadId: String, @FieldMap form: Map, ): String @FormUrlEncoded @POST("/api/v1/direct_v2/threads/{threadId}/items/{itemId}/seen/") suspend fun markItemSeen( @Path("threadId") threadId: String, @Path("itemId") itemId: String, @FieldMap form: Map, ): DirectItemSeenResponse } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/DiscoverRepository.java ================================================ package awais.instagrabber.repositories; import java.util.Map; import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; import retrofit2.Call; import retrofit2.http.GET; import retrofit2.http.QueryMap; public interface DiscoverRepository { @GET("/api/v1/discover/topical_explore/") Call topicalExplore(@QueryMap Map queryParams); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/FeedRepository.java ================================================ package awais.instagrabber.repositories; import java.util.Map; import awais.instagrabber.repositories.responses.feed.FeedFetchResponse; import retrofit2.Call; import retrofit2.http.FieldMap; import retrofit2.http.FormUrlEncoded; import retrofit2.http.POST; public interface FeedRepository { @FormUrlEncoded @POST("/api/v1/feed/timeline/") Call fetch(@FieldMap final Map signedForm); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/FriendshipService.kt ================================================ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.FriendshipChangeResponse import awais.instagrabber.repositories.responses.FriendshipListFetchResponse import awais.instagrabber.repositories.responses.FriendshipRestrictResponse import retrofit2.http.* interface FriendshipService { @FormUrlEncoded @POST("/api/v1/friendships/{action}/{id}/") suspend fun change( @Path("action") action: String, @Path("id") id: Long, @FieldMap form: Map, ): FriendshipChangeResponse @FormUrlEncoded @POST("/api/v1/restrict_action/{action}/") suspend fun toggleRestrict( @Path("action") action: String, @FieldMap form: Map, ): FriendshipRestrictResponse @GET("/api/v1/friendships/{userId}/{type}/") suspend fun getList( @Path("userId") userId: Long, @Path("type") type: String, // following or followers @QueryMap(encoded = true) queryParams: Map, ): FriendshipListFetchResponse @FormUrlEncoded @POST("/api/v1/friendships/{action}/") suspend fun changeMute( @Path("action") action: String, @FieldMap form: Map, ): FriendshipChangeResponse } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/GifRepository.java ================================================ package awais.instagrabber.repositories; import awais.instagrabber.repositories.responses.giphy.GiphyGifResponse; import retrofit2.Call; import retrofit2.http.GET; import retrofit2.http.Query; public interface GifRepository { @GET("/api/v1/creatives/story_media_search_keyed_format/") Call searchGiphyGifs(@Query("request_surface") final String requestSurface, @Query("q") final String query, @Query("media_types") final String mediaTypes); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/GraphQLService.kt ================================================ package awais.instagrabber.repositories import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface GraphQLService { @GET("/graphql/query/") suspend fun fetch(@QueryMap(encoded = true) queryParams: Map): String @GET("/{username}/") suspend fun getUser(@Path("username") username: String): String @GET("/p/{shortcode}/?__a=1") suspend fun getPost(@Path("shortcode") shortcode: String): String @GET("/explore/tags/{tag}/") suspend fun getTag(@Path("tag") tag: String): String @GET("/explore/locations/{locationId}/") suspend fun getLocation(@Path("locationId") locationId: Long): String } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/LocationRepository.java ================================================ package awais.instagrabber.repositories; import java.util.Map; import awais.instagrabber.repositories.responses.LocationFeedResponse; import awais.instagrabber.repositories.responses.Place; import retrofit2.Call; import retrofit2.http.GET; import retrofit2.http.Path; import retrofit2.http.QueryMap; public interface LocationRepository { @GET("/api/v1/locations/{location}/info/") Call fetch(@Path("location") final long locationId); @GET("/api/v1/feed/location/{location}/") Call fetchPosts(@Path("location") final long locationId, @QueryMap Map queryParams); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/MediaService.kt ================================================ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.LikersResponse import awais.instagrabber.repositories.responses.MediaInfoResponse import retrofit2.http.* interface MediaService { @GET("/api/v1/media/{mediaId}/info/") suspend fun fetch(@Path("mediaId") mediaId: Long): MediaInfoResponse @GET("/api/v1/media/{mediaId}/{action}/") suspend fun fetchLikes( @Path("mediaId") mediaId: String, // one of "likers" or "comment_likers" @Path("action") action: String, ): LikersResponse @FormUrlEncoded @POST("/api/v1/media/{mediaId}/{action}/") suspend fun action( @Path("action") action: String, @Path("mediaId") mediaId: String, @FieldMap signedForm: Map, ): String @FormUrlEncoded @POST("/api/v1/media/{mediaId}/edit_media/") suspend fun editCaption( @Path("mediaId") mediaId: String, @FieldMap signedForm: Map, ): String @GET("/api/v1/language/translate/") suspend fun translate(@QueryMap form: Map): String @FormUrlEncoded @POST("/api/v1/media/upload_finish/") suspend fun uploadFinish( @Header("retry_context") retryContext: String, @QueryMap queryParams: Map, @FieldMap signedForm: Map, ): String @FormUrlEncoded @POST("/api/v1/media/{mediaId}/delete/") suspend fun delete( @Path("mediaId") mediaId: String, @Query("media_type") mediaType: String, @FieldMap signedForm: Map, ): String @FormUrlEncoded @POST("/api/v1/media/{mediaId}/archive/") suspend fun archive( @Path("mediaId") mediaId: String, @FieldMap signedForm: Map, ): String } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/NewsRepository.java ================================================ package awais.instagrabber.repositories; import java.util.Map; import awais.instagrabber.repositories.responses.AymlResponse; import awais.instagrabber.repositories.responses.NewsInboxResponse; import awais.instagrabber.repositories.responses.UserSearchResponse; import retrofit2.Call; import retrofit2.http.FieldMap; import retrofit2.http.FormUrlEncoded; import retrofit2.http.GET; import retrofit2.http.Header; import retrofit2.http.POST; import retrofit2.http.Query; public interface NewsRepository { @GET("/api/v1/news/inbox/") Call appInbox(@Query(value = "mark_as_seen", encoded = true) boolean markAsSeen, @Header(value = "x-ig-app-id") String xIgAppId); @FormUrlEncoded @POST("/api/v1/discover/ayml/") Call getAyml(@FieldMap final Map form); @GET("/api/v1/discover/chaining/") Call getChaining(@Query(value = "target_id") long targetId); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/ProfileService.kt ================================================ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.UserFeedResponse import awais.instagrabber.repositories.responses.WrappedFeedResponse import awais.instagrabber.repositories.responses.saved.CollectionsListResponse import retrofit2.Call import retrofit2.http.* interface ProfileService { @GET("/api/v1/feed/user/{uid}/") suspend fun fetch( @Path("uid") uid: Long, @QueryMap queryParams: Map? ): UserFeedResponse? @GET("/api/v1/feed/saved/") suspend fun fetchSaved(@QueryMap queryParams: Map?): WrappedFeedResponse? @GET("/api/v1/feed/collection/{collectionId}/") suspend fun fetchSavedCollection( @Path("collectionId") collectionId: String?, @QueryMap queryParams: Map? ): WrappedFeedResponse? @GET("/api/v1/feed/liked/") suspend fun fetchLiked(@QueryMap queryParams: Map?): UserFeedResponse? @GET("/api/v1/usertags/{profileId}/feed/") suspend fun fetchTagged( @Path("profileId") profileId: Long, @QueryMap queryParams: Map? ): UserFeedResponse? @GET("/api/v1/collections/list/") suspend fun fetchCollections(@QueryMap queryParams: Map?): CollectionsListResponse? @FormUrlEncoded @POST("/api/v1/collections/create/") suspend fun createCollection(@FieldMap signedForm: Map?): String? } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/SearchService.kt ================================================ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.search.SearchResponse import retrofit2.Call import retrofit2.http.GET import retrofit2.http.QueryMap import retrofit2.http.Url interface SearchService { @GET suspend fun search( @Url url: String?, @QueryMap(encoded = true) queryParams: Map? ): SearchResponse } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/StoriesService.kt ================================================ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.stories.ArchiveResponse import awais.instagrabber.repositories.responses.stories.ReelsMediaResponse import awais.instagrabber.repositories.responses.stories.ReelsResponse import awais.instagrabber.repositories.responses.stories.ReelsTrayResponse import awais.instagrabber.repositories.responses.stories.StoryMediaResponse import awais.instagrabber.repositories.responses.stories.StoryStickerResponse import retrofit2.http.* interface StoriesService { // this one is the same as MediaRepository.fetch BUT you need to make sure it's a story @GET("/api/v1/media/{mediaId}/info/") suspend fun fetch(@Path("mediaId") mediaId: Long): StoryMediaResponse @GET("/api/v1/feed/reels_tray/") suspend fun getFeedStories(): ReelsTrayResponse? @GET("/api/v1/highlights/{uid}/highlights_tray/") suspend fun fetchHighlights(@Path("uid") uid: Long): ReelsTrayResponse? @GET("/api/v1/archive/reel/day_shells/") suspend fun fetchArchive(@QueryMap queryParams: Map): ArchiveResponse? @GET("/api/v1/feed/reels_media/") suspend fun getReelsMedia(@Query("user_ids") id: String): ReelsMediaResponse @GET("/api/v1/{type}/{id}/story/") suspend fun getStories(@Path("type") type: String, @Path("id") id: String): ReelsResponse @GET("/api/v1/feed/user/{id}/story/") suspend fun getUserStories(@Path("id") id: Long): ReelsResponse @FormUrlEncoded @POST("/api/v1/media/{storyId}/{stickerId}/{action}/") suspend fun respondToSticker( @Path("storyId") storyId: Long, @Path("stickerId") stickerId: Long, @Path("action") action: String, // story_poll_vote, story_question_response, story_slider_vote, story_quiz_answer @FieldMap form: Map, ): StoryStickerResponse @FormUrlEncoded @POST("/api/v2/media/seen/") suspend fun seen( @QueryMap queryParams: Map, @FieldMap form: Map, ): String } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/TagsRepository.java ================================================ package awais.instagrabber.repositories; import java.util.Map; import awais.instagrabber.repositories.responses.Hashtag; import awais.instagrabber.repositories.responses.TagFeedResponse; import retrofit2.Call; import retrofit2.http.FieldMap; import retrofit2.http.FormUrlEncoded; import retrofit2.http.GET; import retrofit2.http.Header; import retrofit2.http.POST; import retrofit2.http.Path; import retrofit2.http.QueryMap; public interface TagsRepository { @GET("/api/v1/tags/{tag}/info/") Call fetch(@Path("tag") final String tag); @FormUrlEncoded @POST("/api/v1/tags/{action}/{tag}/") Call changeFollow(@FieldMap final Map signedForm, @Path("action") String action, @Path("tag") String tag); @GET("/api/v1/feed/tag/{tag}/") Call fetchPosts(@Path("tag") final String tag, @QueryMap Map queryParams); } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/UserService.kt ================================================ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.FriendshipStatus import awais.instagrabber.repositories.responses.UserSearchResponse import awais.instagrabber.repositories.responses.WrappedUser import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query interface UserService { @GET("/api/v1/users/{uid}/info/") suspend fun getUserInfo(@Path("uid") uid: Long): WrappedUser @GET("/api/v1/users/{username}/usernameinfo/") suspend fun getUsernameInfo(@Path("username") username: String): WrappedUser @GET("/api/v1/friendships/show/{uid}/") suspend fun getUserFriendship(@Path("uid") uid: Long): FriendshipStatus @GET("/api/v1/users/search/") suspend fun search( @Query("timezone_offset") timezoneOffset: Float, @Query("q") query: String, ): UserSearchResponse } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/StoryViewerOptions.java ================================================ package awais.instagrabber.repositories.requests; import java.io.Serializable; public class StoryViewerOptions implements Serializable { private final long id; private final String name; private final Type type; private int currentFeedStoryIndex; private StoryViewerOptions(final int position, final Type type) { id = 0; name = null; this.currentFeedStoryIndex = position; this.type = type; } private StoryViewerOptions(final String name, final Type type) { this.name = name; this.id = 0; this.type = type; } private StoryViewerOptions(final long id, final Type type) { this.name = null; this.id = id; this.type = type; } private StoryViewerOptions(final long id, final String name, final Type type) { this.id = id; this.name = name; this.type = type; } public static StoryViewerOptions forHashtag(final String name) { return new StoryViewerOptions(name, Type.HASHTAG); } public static StoryViewerOptions forLocation(final long id, final String name) { return new StoryViewerOptions(id, name, Type.LOCATION); } public static StoryViewerOptions forUser(final long id, final String name) { return new StoryViewerOptions(id, name, Type.USER); } public static StoryViewerOptions forHighlight(final long id, final String highlight) { return new StoryViewerOptions(id, highlight, Type.HIGHLIGHT); } public static StoryViewerOptions forStory(final long mediaId, final String username) { return new StoryViewerOptions(mediaId, username, Type.STORY); } public static StoryViewerOptions forFeedStoryPosition(final int position) { return new StoryViewerOptions(position, Type.FEED_STORY_POSITION); } public static StoryViewerOptions forStoryArchive(final String id) { return new StoryViewerOptions(id, Type.STORY_ARCHIVE); } public static StoryViewerOptions forStoryArchive(final int position) { return new StoryViewerOptions(position, Type.STORY_ARCHIVE); } public long getId() { return id; } public String getName() { return name; } public Type getType() { return type; } public int getCurrentFeedStoryIndex() { return currentFeedStoryIndex; } public void setCurrentFeedStoryIndex(final int index) { this.currentFeedStoryIndex = index; } public enum Type { HASHTAG, LOCATION, USER, HIGHLIGHT, STORY, FEED_STORY_POSITION, STORY_ARCHIVE } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/UploadFinishOptions.kt ================================================ package awais.instagrabber.repositories.requests data class UploadFinishOptions( val uploadId: String, val sourceType: String, val videoOptions: VideoOptions? = null ) data class VideoOptions( val length: Float = 0f, var clips: List = emptyList(), val posterFrameIndex: Int = 0, val isAudioMuted: Boolean = false ) { val map: Map get() = mapOf( "length" to length, "clips" to clips, "poster_frame_index" to posterFrameIndex, "audio_muted" to isAudioMuted ) } data class Clip( val length: Float = 0f, val sourceType: String ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/AnimatedMediaBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.repositories.responses.giphy.GiphyGif class AnimatedMediaBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val giphyGif: GiphyGif ) : BroadcastOptions( clientContext, threadIdsOrUserIds, BroadcastItemType.ANIMATED_MEDIA ) { override val formMap: Map get() = mapOf( "is_sticker" to giphyGif.isSticker.toString(), "id" to giphyGif.id ) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/BroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType sealed class BroadcastOptions( val clientContext: String, private val threadIdsOrUserIds: ThreadIdsOrUserIds, val itemType: BroadcastItemType ) { var repliedToItemId: String? = null var repliedToClientContext: String? = null val threadIds: List? get() = threadIdsOrUserIds.threadIds val userIds: List>? get() = threadIdsOrUserIds.userIds abstract val formMap: Map } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/LinkBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType import org.json.JSONArray class LinkBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val linkText: String, val urls: List ) : BroadcastOptions( clientContext, threadIdsOrUserIds, BroadcastItemType.LINK ) { override val formMap: Map get() = mapOf( "link_text" to linkText, "link_urls" to JSONArray(urls).toString() ) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/MediaShareBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class MediaShareBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val mediaId: String, val childId: String? ) : BroadcastOptions( clientContext, threadIdsOrUserIds, BroadcastItemType.MEDIA_SHARE ) { override val formMap: Map get() = listOfNotNull( "media_id" to mediaId, if (childId != null) "carousel_share_child_media_id" to childId else null ).toMap() } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/PhotoBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class PhotoBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val allowFullAspectRatio: Boolean, val uploadId: String ) : BroadcastOptions( clientContext, threadIdsOrUserIds, BroadcastItemType.IMAGE ) { override val formMap: Map get() = mapOf( "allow_full_aspect_ratio" to allowFullAspectRatio.toString(), "upload_id" to uploadId ) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ProfileBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class ProfileBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val profileId: String ) : BroadcastOptions( clientContext, threadIdsOrUserIds, BroadcastItemType.PROFILE ) { override val formMap: Map get() = mapOf("profile_user_id" to profileId) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ReactionBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class ReactionBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val itemId: String, val emoji: String?, val delete: Boolean ) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.REACTION) { override val formMap: Map get() = listOfNotNull( "item_id" to itemId, "reaction_status" to if (delete) "deleted" else "created", "reaction_type" to "like", if (!emoji.isNullOrBlank()) "emoji" to emoji else null, ).toMap() } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class StoryBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val mediaId: String, val reelId: String ) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.STORY) { override val formMap: Map get() = mapOf( "story_media_id" to mediaId, "reel_id" to reelId, ) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/StoryReplyBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class StoryReplyBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val text: String, val mediaId: String, val reelId: String ) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.REELSHARE) { override val formMap: Map get() = mapOf( "text" to text, "media_id" to mediaId, "reel_id" to reelId, "entry" to "reel", ) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/TextBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class TextBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val text: String ) : BroadcastOptions( clientContext, threadIdsOrUserIds, BroadcastItemType.TEXT ) { override val formMap: Map get() = mapOf("text" to text) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/ThreadIdsOrUserIds.kt ================================================ package awais.instagrabber.repositories.requests.directmessages data class ThreadIdsOrUserIds(val threadIds: List? = null, val userIds: List>? = null) { companion object { @JvmStatic fun of(threadId: String): ThreadIdsOrUserIds { return ThreadIdsOrUserIds(listOf(threadId), null) } fun ofOneUser(userId: String): ThreadIdsOrUserIds { return ThreadIdsOrUserIds(null, listOf(listOf(userId))) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VideoBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType class VideoBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val videoResult: String, val uploadId: String, val sampled: Boolean ) : BroadcastOptions( clientContext, threadIdsOrUserIds, BroadcastItemType.VIDEO ) { override val formMap: Map get() = mapOf( "video_result" to videoResult, "upload_id" to uploadId, "sampled" to sampled.toString() ) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/requests/directmessages/VoiceBroadcastOptions.kt ================================================ package awais.instagrabber.repositories.requests.directmessages import awais.instagrabber.models.enums.BroadcastItemType import org.json.JSONArray class VoiceBroadcastOptions( clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, val uploadId: String, val waveform: List, val waveformSamplingFrequencyHz: Int ) : BroadcastOptions(clientContext, threadIdsOrUserIds, BroadcastItemType.VOICE) { override val formMap: Map get() = mapOf( "waveform" to JSONArray(waveform).toString(), "upload_id" to uploadId, "waveform_sampling_frequency_hz" to waveformSamplingFrequencyHz.toString() ) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaFixedHeight.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class AnimatedMediaFixedHeight(val height: Int, val width: Int, val mp4: String?, val url: String?, val webp: String?) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/AnimatedMediaImages.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class AnimatedMediaImages(val fixedHeight: AnimatedMediaFixedHeight?) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/Audio.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class Audio( val audioSrc: String?, val duration: Long, val waveformData: List?, val waveformSamplingFrequencyHz: Int, val audioSrcExpirationTimestampUs: Long ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/AymlResponse.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class AymlResponse(val newSuggestedUsers: AymlUserList?, val suggestedUsers: AymlUserList?) : Serializable data class AymlUser( val user: User?, val algorithm: String?, val socialContext: String?, val uuid: String? ) : Serializable data class AymlUserList(val suggestions: List?) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/Caption.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class Caption( val userId: Long = 0, var text: String? = null, ) : Serializable { var pk: String? = null } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/ChildCommentsFetchResponse.kt ================================================ package awais.instagrabber.repositories.responses import awais.instagrabber.models.Comment data class ChildCommentsFetchResponse( val childCommentCount: Int, val nextMaxChildCursor: String?, val childComments: List?, val hasMoreTailChildComments: Boolean? ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/CommentsFetchResponse.kt ================================================ package awais.instagrabber.repositories.responses import awais.instagrabber.models.Comment data class CommentsFetchResponse( val commentCount: Int, val nextMinId: String?, val comments: List? ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/FriendshipChangeResponse.kt ================================================ package awais.instagrabber.repositories.responses data class FriendshipChangeResponse(val friendshipStatus: FriendshipStatus?, val status: String?) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/FriendshipListFetchResponse.kt ================================================ package awais.instagrabber.repositories.responses data class FriendshipListFetchResponse( var nextMaxId: String?, var status: String?, var users: List? ) { val isMoreAvailable: Boolean get() = !nextMaxId.isNullOrBlank() } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/FriendshipRestrictResponse.kt ================================================ package awais.instagrabber.repositories.responses data class FriendshipRestrictResponse(val users: List?, val status: String?) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/FriendshipStatus.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class FriendshipStatus( val following: Boolean = false, val followedBy: Boolean = false, val blocking: Boolean = false, val muting: Boolean = false, val isPrivate: Boolean = false, val incomingRequest: Boolean = false, val outgoingRequest: Boolean = false, val isBestie: Boolean = false, val isRestricted: Boolean = false, val isMutingReel: Boolean = false, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/GraphQLUserListFetchResponse.java ================================================ package awais.instagrabber.repositories.responses; import androidx.annotation.NonNull; import java.util.List; import java.util.Objects; import awais.instagrabber.utils.TextUtils; public class GraphQLUserListFetchResponse { private String nextMaxId; private String status; private List items; public GraphQLUserListFetchResponse(final String nextMaxId, final String status, final List items) { this.nextMaxId = nextMaxId; this.status = status; this.items = items; } public boolean isMoreAvailable() { return !TextUtils.isEmpty(nextMaxId); } public String getNextMaxId() { return nextMaxId; } public GraphQLUserListFetchResponse setNextMaxId(final String nextMaxId) { this.nextMaxId = nextMaxId; return this; } public String getStatus() { return status; } public GraphQLUserListFetchResponse setStatus(final String status) { this.status = status; return this; } public List getItems() { return items; } public GraphQLUserListFetchResponse setItems(final List items) { this.items = items; return this; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final GraphQLUserListFetchResponse that = (GraphQLUserListFetchResponse) o; return Objects.equals(nextMaxId, that.nextMaxId) && Objects.equals(status, that.status) && Objects.equals(items, that.items); } @Override public int hashCode() { return Objects.hash(nextMaxId, status, items); } @NonNull @Override public String toString() { return "GraphQLUserListFetchResponse{" + "nextMaxId='" + nextMaxId + '\'' + ", status='" + status + '\'' + ", items=" + items + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/Hashtag.kt ================================================ package awais.instagrabber.repositories.responses import awais.instagrabber.models.enums.FollowingType import java.io.Serializable data class Hashtag( val id: String, val name: String, val mediaCount: Long, val following: FollowingType?, // 0 false 1 true; not on search results val searchResultSubtitle: String? // shows how many posts there are on search results ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/ImageUrl.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class ImageUrl(val url: String, private val width: Int, private val height: Int) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/ImageVersions2.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class ImageVersions2(val candidates: List) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/LikersResponse.kt ================================================ package awais.instagrabber.repositories.responses data class LikersResponse(val users: List, val userCount: Long, val status: String) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/Location.java ================================================ package awais.instagrabber.repositories.responses; import java.io.Serializable; import java.util.Objects; public class Location implements Serializable { private final long pk; private final String shortName; private final String name; private final String address; private final String city; private final double lng; private final double lat; public Location(final long pk, final String shortName, final String name, final String address, final String city, final double lng, final double lat) { this.pk = pk; this.shortName = shortName; this.name = name; this.address = address; this.city = city; this.lng = lng; this.lat = lat; } public long getPk() { return pk; } public String getShortName() { return shortName; } public String getName() { return name; } public String getAddress() { return address; } public String getCity() { return city; } public double getLng() { return lng; } public double getLat() { return lat; } public String getGeo() { return "geo:" + lat + "," + lng + "?z=17&q=" + lat + "," + lng + "(" + name + ")"; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final Location location = (Location) o; return pk == location.pk && Double.compare(location.lng, lng) == 0 && Double.compare(location.lat, lat) == 0 && Objects.equals(shortName, location.shortName) && Objects.equals(name, location.name) && Objects.equals(address, location.address) && Objects.equals(city, location.city); } @Override public int hashCode() { return Objects.hash(pk, shortName, name, address, city, lng, lat); } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/LocationFeedResponse.kt ================================================ package awais.instagrabber.repositories.responses data class LocationFeedResponse( val numResults: Int, val nextMaxId: String?, val moreAvailable: Boolean?, val mediaCount: Long?, val status: String, val items: List?, val location: Location ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/Media.kt ================================================ package awais.instagrabber.repositories.responses import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.models.enums.MediaItemType.Companion.valueOf import awais.instagrabber.repositories.responses.feed.EndOfFeedDemarcator import awais.instagrabber.utils.TextUtils import java.io.Serializable data class Media( val pk: String? = null, val id: String? = null, val code: String? = null, val takenAt: Long = -1, val user: User? = null, val canViewerReshare: Boolean = false, val imageVersions2: ImageVersions2? = null, val originalWidth: Int = 0, val originalHeight: Int = 0, val mediaType: Int = 0, val commentLikesEnabled: Boolean = false, val commentsDisabled: Boolean = false, val nextMaxId: Long = -1, val commentCount: Long = 0, var likeCount: Long = 0, var hasLiked: Boolean = false, val isReelMedia: Boolean = false, val videoVersions: List? = null, val hasAudio: Boolean = false, val videoDuration: Double = 0.0, val viewCount: Long = 0, var caption: Caption? = null, val canViewerSave: Boolean = false, val audio: Audio? = null, val title: String? = null, val carouselMedia: List? = null, val location: Location? = null, val usertags: Usertags? = null, var isSidecarChild: Boolean = false, var hasViewerSaved: Boolean = false, private val injected: Map? = null, val endOfFeedDemarcator: EndOfFeedDemarcator? = null, val carouselShareChildMediaId: String? = null // which specific child should dm show first ) : Serializable { private var dateString: String? = null fun isInjected(): Boolean { return injected != null } // TODO use extension once all usages are converted to kotlin // val date: String by lazy { // if (takenAt <= 0) "" else Utils.datetimeParser.format(Date(takenAt * 1000L)) // } val date: String get() { if (takenAt <= 0) return "" if (dateString != null) return dateString ?: "" dateString = TextUtils.epochSecondToString(takenAt) return dateString ?: "" } val type: MediaItemType? get() = valueOf(mediaType) fun setPostCaption(caption: String?) { var caption1: Caption? = this.caption if (caption1 == null) { user ?: return caption1 = Caption(userId = user.pk, text = caption ?: "") this.caption = caption1 return } caption1.text = caption ?: "" } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/MediaCandidate.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class MediaCandidate(val width: Int, val height: Int, val url: String) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/MediaInfoResponse.kt ================================================ package awais.instagrabber.repositories.responses data class MediaInfoResponse(val items: List) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/NewsInboxResponse.kt ================================================ package awais.instagrabber.repositories.responses import awais.instagrabber.repositories.responses.notification.Notification import awais.instagrabber.repositories.responses.notification.NotificationCounts data class NewsInboxResponse( val counts: NotificationCounts, val newStories: List, val oldStories: List ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/Place.kt ================================================ package awais.instagrabber.repositories.responses data class Place( val location: Location, // for search val title: String, // those are repeated within location val subtitle: String?, // address // browser only; for end of address val slug: String?, // for location info val status: String? ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/PostsFetchResponse.kt ================================================ package awais.instagrabber.repositories.responses class PostsFetchResponse( val feedModels: List, val hasNextPage: Boolean, val nextCursor: String? ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/TagFeedResponse.kt ================================================ package awais.instagrabber.repositories.responses class TagFeedResponse( val numResults: Int, val nextMaxId: String?, val moreAvailable: Boolean, val status: String, val items: List ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/User.kt ================================================ package awais.instagrabber.repositories.responses import java.io.Serializable data class User @JvmOverloads constructor( val pk: Long = 0, val username: String = "", val fullName: String? = "", val isPrivate: Boolean = false, val profilePicUrl: String? = null, val isVerified: Boolean = false, val profilePicId: String? = null, var friendshipStatus: FriendshipStatus? = null, val hasAnonymousProfilePicture: Boolean = false, val isUnpublished: Boolean = false, val isFavorite: Boolean = false, val isDirectappInstalled: Boolean = false, val hasChaining: Boolean = false, val reelAutoArchive: String? = null, val allowedCommenterType: String? = null, val mediaCount: Long = 0, val followerCount: Long = 0, val followingCount: Long = 0, val followingTagCount: Long = 0, val biography: String? = null, val externalUrl: String? = null, val usertagsCount: Long = 0, val publicEmail: String? = null, val hdProfilePicUrlInfo: ImageUrl? = null, val profileContext: String? = null, // "also followed by" your friends val profileContextLinksWithUserIds: List? = null, // ^ val socialContext: String? = null, // AYML val interopMessagingUserFbid: String? = null, // in DMs only: Facebook user ID ) : Serializable { val hDProfilePicUrl: String get() = hdProfilePicUrlInfo?.url ?: profilePicUrl ?: "" } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/UserFeedResponse.kt ================================================ package awais.instagrabber.repositories.responses class UserFeedResponse( val numResults: Int, val nextMaxId: String?, val moreAvailable: Boolean, val status: String, val items: List ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt ================================================ package awais.instagrabber.repositories.responses data class UserProfileContextLink( val username: String? = null, val start: Int = 0, val end: Int = 0, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/UserSearchResponse.kt ================================================ package awais.instagrabber.repositories.responses data class UserSearchResponse( val numResults: Int, val users: List?, val status: String ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/UsertagIn.java ================================================ package awais.instagrabber.repositories.responses; import java.io.Serializable; import java.util.List; import java.util.Objects; public class UsertagIn implements Serializable { private final User user; private final List position; public UsertagIn(final User user, final List position) { this.user = user; this.position = position; } public User getUser() { return user; } public List getPosition() { return position; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final UsertagIn usertagIn = (UsertagIn) o; return Objects.equals(user, usertagIn.user) && Objects.equals(position, usertagIn.position); } @Override public int hashCode() { return Objects.hash(user, position); } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/Usertags.java ================================================ package awais.instagrabber.repositories.responses; import java.io.Serializable; import java.util.List; import java.util.Objects; public class Usertags implements Serializable { private final List in; public Usertags(final List in) { this.in = in; } public List getIn() { return in; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final Usertags usertags = (Usertags) o; return Objects.equals(in, usertags.in); } @Override public int hashCode() { return Objects.hash(in); } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/WrappedFeedResponse.java ================================================ package awais.instagrabber.repositories.responses; import java.util.List; public class WrappedFeedResponse { private final int numResults; private final String nextMaxId; private final boolean moreAvailable; private final String status; private final List items; public WrappedFeedResponse(final int numResults, final String nextMaxId, final boolean moreAvailable, final String status, final List items) { this.numResults = numResults; this.nextMaxId = nextMaxId; this.moreAvailable = moreAvailable; this.status = status; this.items = items; } public int getNumResults() { return numResults; } public String getNextMaxId() { return nextMaxId; } public boolean isMoreAvailable() { return moreAvailable; } public String getStatus() { return status; } public List getItems() { return items; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/WrappedMedia.kt ================================================ package awais.instagrabber.repositories.responses class WrappedMedia(val media: Media) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/WrappedUser.kt ================================================ package awais.instagrabber.repositories.responses class WrappedUser(val user: User) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectBadgeCount.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectBadgeCount( val userId: Long = 0, val badgeCount: Int = 0, val badgeCountAtMs: Long = 0, val status: String? = null ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInbox.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectInbox( var threads: List? = emptyList(), val hasOlder: Boolean = false, val unseenCount: Int = 0, val unseenCountTs: String? = null, val oldestCursor: String? = null, val blendedInboxEnabled: Boolean ) : Cloneable { @Throws(CloneNotSupportedException::class) public override fun clone(): Any { return super.clone() } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectInboxResponse.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.User data class DirectInboxResponse( val viewer: User? = null, val inbox: DirectInbox? = null, val seqId: Long = 0, val snapshotAtMs: Long = 0, val pendingRequestsTotal: Int = 0, val hasPendingTopRequests: Boolean = false, val mostRecentInviter: User? = null, val status: String? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItem.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.models.enums.DirectItemType import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.Media import awais.instagrabber.repositories.responses.User import java.io.Serializable import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId data class DirectItem( var itemId: String? = null, val userId: Long = 0, private var timestamp: Long = 0, val itemType: DirectItemType? = null, val text: String? = null, val like: String? = null, val link: DirectItemLink? = null, val clientContext: String? = null, val reelShare: DirectItemReelShare? = null, val storyShare: DirectItemStoryShare? = null, val mediaShare: Media? = null, val profile: User? = null, val placeholder: DirectItemPlaceholder? = null, val media: Media? = null, val previewMedias: List? = null, val actionLog: DirectItemActionLog? = null, val videoCallEvent: DirectItemVideoCallEvent? = null, val clip: DirectItemClip? = null, val felixShare: DirectItemFelixShare? = null, val visualMedia: DirectItemVisualMedia? = null, val animatedMedia: DirectItemAnimatedMedia? = null, var reactions: DirectItemReactions? = null, val repliedToMessage: DirectItem? = null, val voiceMedia: DirectItemVoiceMedia? = null, val location: Location? = null, val xma: DirectItemXma? = null, val hideInThread: Int? = 0, val showForwardAttribution: Boolean = false ) : Cloneable, Serializable { var isPending = false var date: LocalDateTime? = null get() { if (field == null) { field = Instant.ofEpochMilli(timestamp / 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() } return field } private set fun getTimestamp(): Long { return timestamp } fun setTimestamp(timestamp: Long) { this.timestamp = timestamp date = null } @Throws(CloneNotSupportedException::class) public override fun clone(): Any { return super.clone() } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemActionLog.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemActionLog( val description: String? = null, val bold: List? = null, val textAttributes: List? = null ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemAnimatedMedia.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.AnimatedMediaImages import java.io.Serializable data class DirectItemAnimatedMedia( val id: String? = null, val images: AnimatedMediaImages? = null, val isRandom: Boolean = false, val isSticker: Boolean = false, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemClip.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.Media import java.io.Serializable data class DirectItemClip(val clip: Media? = null) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemEmojiReaction.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemEmojiReaction( val senderId: Long = 0, val timestamp: Long = 0, val emoji: String? = null, val superReactType: String? = null ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemFelixShare.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.Media import java.io.Serializable data class DirectItemFelixShare(val video: Media? = null) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLink.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemLink( val text: String? = null, val linkContext: DirectItemLinkContext? = null, val clientContext: String? = null, val mutationToken: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemLinkContext.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemLinkContext( val linkUrl: String? = null, val linkTitle: String? = null, val linkSummary: String? = null, val linkImageUrl: String? = null ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemPlaceholder.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemPlaceholder( val isLinked: Boolean = false, val title: String? = null, val message: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReactions.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemReactions( var emojis: List? = null, var likes: List? = null, ) : Cloneable, Serializable { @Throws(CloneNotSupportedException::class) public override fun clone(): Any { return super.clone() } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShare.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.Media import java.io.Serializable data class DirectItemReelShare( val text: String? = null, val type: String? = null, val reelOwnerId: Long = 0, val mentionedUserId: Long = 0, val isReelPersisted: Boolean = false, val reelType: String? = null, val media: Media? = null, val reactionInfo: DirectItemReelShareReactionInfo? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemReelShareReactionInfo.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemReelShareReactionInfo( val emoji: String? = null, val intensity: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponse.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectItemSeenResponse( val action: String? = null, val payload: DirectItemSeenResponsePayload? = null, val status: String? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemSeenResponsePayload.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectItemSeenResponsePayload(val count: Int = 0, val timestamp: String? = null) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemStoryShare.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.Media import java.io.Serializable data class DirectItemStoryShare( val reelId: String? = null, val reelType: String? = null, val text: String? = null, val isReelPersisted: Boolean = false, val media: Media? = null, val title: String? = null, val message: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVideoCallEvent.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemVideoCallEvent( val action: String? = null, val encodedServerDataInfo: String? = null, val description: String? = null, val threadHasAudioOnlyCall: Boolean = false, val textAttributes: List? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVisualMedia.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.models.enums.RavenMediaViewMode import awais.instagrabber.repositories.responses.Media import java.io.Serializable data class DirectItemVisualMedia( val urlExpireAtSecs: Long = 0, val playbackDurationSecs: Int = 0, val seenUserIds: List? = null, val viewMode: RavenMediaViewMode? = null, val seenCount: Int = 0, val replayExpiringAtUs: Long = 0, val expiringMediaActionSummary: RavenExpiringMediaActionSummary? = null, val media: Media? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemVoiceMedia.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.Media import java.io.Serializable data class DirectItemVoiceMedia( val media: Media? = null, val seenCount: Int = 0, val viewMode: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectItemXma.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectItemXma( val previewUrlInfo: XmaUrlInfo? = null, val playableUrlInfo: XmaUrlInfo? = null, ) : Serializable data class XmaUrlInfo( val url: String? = null, val urlExpirationTimestampUs: Long = 0, val width: Int = 0, val height: Int = 0, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThread.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.User import java.io.Serializable data class DirectThread( val threadId: String? = null, val threadV2Id: String? = null, var users: List? = null, var leftUsers: List? = null, var adminUserIds: List? = null, var items: List? = null, val lastActivityAt: Long = 0, var muted: Boolean = false, val isPin: Boolean = false, val named: Boolean = false, val canonical: Boolean = false, var pending: Boolean = false, val archived: Boolean = false, val valuedRequest: Boolean = false, val threadType: String? = null, val viewerId: Long = 0, val threadTitle: String? = null, val pendingScore: String? = null, val folder: Long = 0, val vcMuted: Boolean = false, val isGroup: Boolean = false, var mentionsMuted: Boolean = false, val inviter: User? = null, val hasOlder: Boolean = false, val hasNewer: Boolean = false, var lastSeenAt: Map? = null, val newestCursor: String? = null, val oldestCursor: String? = null, val isSpam: Boolean = false, val lastPermanentItem: DirectItem? = null, val directStory: DirectThreadDirectStory? = null, var approvalRequiredForNewMembers: Boolean = false, var inputMode: Int = 0, val threadContextItems: List? = null ) : Serializable, Cloneable { var isTemp = false val firstDirectItem: DirectItem? get() = items?.firstNotNullOfOrNull { it } @Throws(CloneNotSupportedException::class) public override fun clone(): Any = super.clone() } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponse.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectThreadBroadcastResponse( val action: String? = null, val statusCode: String? = null, val payload: DirectThreadBroadcastResponsePayload? = null, val messageMetadata: List? = null, val status: String? = null ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponseMessageMetadata.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectThreadBroadcastResponseMessageMetadata( val clientContext: String? = null, val itemId: String? = null, val timestamp: Long = 0, val threadId: String? = null, val participantIds: List? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadBroadcastResponsePayload.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectThreadBroadcastResponsePayload( val clientContext: String? = null, val itemId: String? = null, val timestamp: Long = 0, val threadId: String? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDetailsChangeResponse.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectThreadDetailsChangeResponse( val thread: DirectThread? = null, val status: String? = null ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadDirectStory.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectThreadDirectStory( val items: List? = null, val unseenCount: Int = 0, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadFeedResponse.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class DirectThreadFeedResponse( val thread: DirectThread? = null, val status: String? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadLastSeenAt.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class DirectThreadLastSeenAt( val timestamp: String? = null, val itemId: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/DirectThreadParticipantRequestsResponse.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.User import java.io.Serializable data class DirectThreadParticipantRequestsResponse( var users: List? = null, val requesterUsernames: Map? = null, val cursor: String? = null, val totalThreadParticipants: Int = 0, var totalParticipantRequests: Int = 0, val status: String? = null, ) : Serializable, Cloneable { @Throws(CloneNotSupportedException::class) public override fun clone(): Any = super.clone() } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipient.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import awais.instagrabber.repositories.responses.User import java.io.Serializable data class RankedRecipient( val user: User? = null, val thread: DirectThread? = null, ) : Serializable { companion object { @JvmStatic fun of(user: User): RankedRecipient { return RankedRecipient(user = user) } @JvmStatic fun of(thread: DirectThread): RankedRecipient { return RankedRecipient(thread = thread) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RankedRecipientsResponse.kt ================================================ package awais.instagrabber.repositories.responses.directmessages data class RankedRecipientsResponse( val rankedRecipients: List? = null, val expires: Long = 0, val filtered: Boolean = false, val requestId: String? = null, val rankToken: String? = null, val status: String? = null, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/RavenExpiringMediaActionSummary.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import com.google.gson.annotations.SerializedName import java.io.Serializable data class RavenExpiringMediaActionSummary( val timestamp: Long = 0, val count: Int = 0, val type: ActionType? = null, ) : Serializable // thanks to http://github.com/warifp/InstagramAutoPostImageUrl/blob/master/vendor/mgp25/instagram-php/src/Response/Model/ActionBadge.php enum class ActionType { @SerializedName("raven_delivered") DELIVERED, @SerializedName("raven_sent") SENT, @SerializedName("raven_opened") OPENED, @SerializedName("raven_screenshot") SCREENSHOT, @SerializedName("raven_replayed") REPLAYED, @SerializedName("raven_cannot_deliver") CANNOT_DELIVER, @SerializedName("raven_sending") SENDING, @SerializedName("raven_blocked") BLOCKED, @SerializedName("raven_unknown") UNKNOWN, @SerializedName("raven_suggested") SUGGESTED, } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/TextRange.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class TextRange( val start: Int = 0, val end: Int = 0, val color: String? = null, val intent: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/directmessages/ThreadContext.kt ================================================ package awais.instagrabber.repositories.responses.directmessages import java.io.Serializable data class ThreadContext( val type: Int = 0, val text: String? = null, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicCluster.kt ================================================ package awais.instagrabber.repositories.responses.discover import awais.instagrabber.repositories.responses.Media import java.io.Serializable data class TopicCluster( val id: String, val title: String, val type: String?, val canMute: Boolean?, val isMuted: Boolean?, val rankedPosition: Int, var coverMedia: Media? ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/discover/TopicalExploreFeedResponse.kt ================================================ package awais.instagrabber.repositories.responses.discover import awais.instagrabber.repositories.responses.WrappedMedia data class TopicalExploreFeedResponse( val moreAvailable: Boolean, val nextMaxId: String?, val maxId: String?, val status: String, val numResults: Int, val clusters: List?, val items: List? ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedDemarcator.java ================================================ package awais.instagrabber.repositories.responses.feed; import java.io.Serializable; import java.util.Objects; public class EndOfFeedDemarcator implements Serializable { private final long id; private final EndOfFeedGroupSet groupSet; public EndOfFeedDemarcator(final long id, final EndOfFeedGroupSet groupSet) { this.id = id; this.groupSet = groupSet; } public long getId() { return id; } public EndOfFeedGroupSet getGroupSet() { return groupSet; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final EndOfFeedDemarcator that = (EndOfFeedDemarcator) o; return id == that.id && Objects.equals(groupSet, that.groupSet); } @Override public int hashCode() { return Objects.hash(id, groupSet); } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroup.java ================================================ package awais.instagrabber.repositories.responses.feed; import java.io.Serializable; import java.util.List; import java.util.Objects; import awais.instagrabber.repositories.responses.Media; public class EndOfFeedGroup implements Serializable { private final String id; private final String title; private final String nextMaxId; private final List feedItems; public EndOfFeedGroup(final String id, final String title, final String nextMaxId, final List feedItems) { this.id = id; this.title = title; this.nextMaxId = nextMaxId; this.feedItems = feedItems; } public String getId() { return id; } public String getTitle() { return title; } public String getNextMaxId() { return nextMaxId; } public List getFeedItems() { return feedItems; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final EndOfFeedGroup that = (EndOfFeedGroup) o; return Objects.equals(id, that.id) && Objects.equals(title, that.title) && Objects.equals(nextMaxId, that.nextMaxId) && Objects.equals(feedItems, that.feedItems); } @Override public int hashCode() { return Objects.hash(id, title, nextMaxId, feedItems); } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/feed/EndOfFeedGroupSet.java ================================================ package awais.instagrabber.repositories.responses.feed; import java.io.Serializable; import java.util.List; import java.util.Objects; public class EndOfFeedGroupSet implements Serializable { private final long id; private final String activeGroupId; private final String connectedGroupId; private final String nextMaxId; private final String paginationSource; private final List groups; public EndOfFeedGroupSet(final long id, final String activeGroupId, final String connectedGroupId, final String nextMaxId, final String paginationSource, final List groups) { this.id = id; this.activeGroupId = activeGroupId; this.connectedGroupId = connectedGroupId; this.nextMaxId = nextMaxId; this.paginationSource = paginationSource; this.groups = groups; } public long getId() { return id; } public String getActiveGroupId() { return activeGroupId; } public String getConnectedGroupId() { return connectedGroupId; } public String getNextMaxId() { return nextMaxId; } public String getPaginationSource() { return paginationSource; } public List getGroups() { return groups; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final EndOfFeedGroupSet that = (EndOfFeedGroupSet) o; return id == that.id && Objects.equals(activeGroupId, that.activeGroupId) && Objects.equals(connectedGroupId, that.connectedGroupId) && Objects.equals(nextMaxId, that.nextMaxId) && Objects.equals(paginationSource, that.paginationSource) && Objects.equals(groups, that.groups); } @Override public int hashCode() { return Objects.hash(id, activeGroupId, connectedGroupId, nextMaxId, paginationSource, groups); } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/feed/FeedFetchResponse.java ================================================ package awais.instagrabber.repositories.responses.feed; import java.util.List; import awais.instagrabber.repositories.responses.Media; public class FeedFetchResponse { private final List items; private final int numResults; private final boolean moreAvailable; private final String nextMaxId; private final String status; public FeedFetchResponse(final List items, final int numResults, final boolean moreAvailable, final String nextMaxId, final String status) { this.items = items; this.numResults = numResults; this.moreAvailable = moreAvailable; this.nextMaxId = nextMaxId; this.status = status; } public List getItems() { return items; } public int getNumResults() { return numResults; } public boolean isMoreAvailable() { return moreAvailable; } public String getNextMaxId() { return nextMaxId; } public String getStatus() { return status; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGif.java ================================================ package awais.instagrabber.repositories.responses.giphy; import androidx.annotation.NonNull; import java.util.Objects; public class GiphyGif { private final String type; private final String id; private final String title; private final int isSticker; private final GiphyGifImages images; public GiphyGif(final String type, final String id, final String title, final int isSticker, final GiphyGifImages images) { this.type = type; this.id = id; this.title = title; this.isSticker = isSticker; this.images = images; } public String getType() { return type; } public String getId() { return id; } public String getTitle() { return title; } public boolean isSticker() { return isSticker == 1; } public GiphyGifImages getImages() { return images; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final GiphyGif giphyGif = (GiphyGif) o; return isSticker == giphyGif.isSticker && Objects.equals(type, giphyGif.type) && Objects.equals(id, giphyGif.id) && Objects.equals(title, giphyGif.title) && Objects.equals(images, giphyGif.images); } @Override public int hashCode() { return Objects.hash(type, id, title, isSticker, images); } @NonNull @Override public String toString() { return "GiphyGif{" + "type='" + type + '\'' + ", id='" + id + '\'' + ", title='" + title + '\'' + ", isSticker=" + isSticker() + ", images=" + images + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImage.java ================================================ package awais.instagrabber.repositories.responses.giphy; import java.util.Objects; public class GiphyGifImage { private final int height; private final int width; private final long webpSize; private final String webp; public GiphyGifImage(final int height, final int width, final long webpSize, final String webp) { this.height = height; this.width = width; this.webpSize = webpSize; this.webp = webp; } public int getHeight() { return height; } public int getWidth() { return width; } public long getWebpSize() { return webpSize; } public String getWebp() { return webp; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final GiphyGifImage that = (GiphyGifImage) o; return height == that.height && width == that.width && webpSize == that.webpSize && Objects.equals(webp, that.webp); } @Override public int hashCode() { return Objects.hash(height, width, webpSize, webp); } @Override public String toString() { return "GiphyGifImage{" + "height=" + height + ", width=" + width + ", webpSize=" + webpSize + ", webp='" + webp + '\'' + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifImages.java ================================================ package awais.instagrabber.repositories.responses.giphy; import androidx.annotation.NonNull; import java.util.Objects; import awais.instagrabber.repositories.responses.AnimatedMediaFixedHeight; public class GiphyGifImages { private final AnimatedMediaFixedHeight fixedHeight; public GiphyGifImages(final AnimatedMediaFixedHeight fixedHeight) { this.fixedHeight = fixedHeight; } public AnimatedMediaFixedHeight getFixedHeight() { return fixedHeight; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final GiphyGifImages that = (GiphyGifImages) o; return Objects.equals(fixedHeight, that.fixedHeight); } @Override public int hashCode() { return Objects.hash(fixedHeight); } @NonNull @Override public String toString() { return "GiphyGifImages{" + "fixedHeight=" + fixedHeight + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResponse.java ================================================ package awais.instagrabber.repositories.responses.giphy; import androidx.annotation.NonNull; import java.util.Objects; public class GiphyGifResponse { private final GiphyGifResults results; private final String status; public GiphyGifResponse(final GiphyGifResults results, final String status) { this.results = results; this.status = status; } public GiphyGifResults getResults() { return results; } public String getStatus() { return status; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final GiphyGifResponse that = (GiphyGifResponse) o; return Objects.equals(results, that.results) && Objects.equals(status, that.status); } @Override public int hashCode() { return Objects.hash(results, status); } @NonNull @Override public String toString() { return "GiphyGifResponse{" + "results=" + results + ", status='" + status + '\'' + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/giphy/GiphyGifResults.java ================================================ package awais.instagrabber.repositories.responses.giphy; import androidx.annotation.NonNull; import java.util.List; import java.util.Objects; public class GiphyGifResults { private final List giphyGifs; private final List giphy; public GiphyGifResults(final List giphyGifs, final List giphy) { this.giphyGifs = giphyGifs; this.giphy = giphy; } public List getGiphyGifs() { return giphyGifs; } public List getGiphy() { return giphy; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final GiphyGifResults that = (GiphyGifResults) o; return Objects.equals(giphyGifs, that.giphyGifs) && Objects.equals(giphy, that.giphy); } @Override public int hashCode() { return Objects.hash(giphyGifs, giphy); } @NonNull @Override public String toString() { return "GiphyGifResults{" + "giphyGifs=" + giphyGifs + ", giphy=" + giphy + '}'; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/notification/Notification.kt ================================================ package awais.instagrabber.repositories.responses.notification import awais.instagrabber.models.enums.NotificationType import awais.instagrabber.models.enums.NotificationType.Companion.valueOfType class Notification(val args: NotificationArgs, private val storyType: Int, val pk: String?) { val type: NotificationType? get() = valueOfType(storyType) } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationArgs.java ================================================ package awais.instagrabber.repositories.responses.notification; import androidx.annotation.NonNull; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import awais.instagrabber.utils.TextUtils; public class NotificationArgs { private final String text; private final String richText; private final long profileId; private final String profileImage; private final List media; private final double timestamp; private final String profileName; private final String fullName; // for AYML, not naturally generated private final boolean isVerified; // mostly for AYML, not sure about notif public NotificationArgs(final String text, final String richText, // for AYML, this is the algorithm final long profileId, final String profileImage, final List media, final double timestamp, final String profileName, final String fullName, final boolean isVerified) { this.text = text; this.richText = richText; this.profileId = profileId; this.profileImage = profileImage; this.media = media; this.timestamp = timestamp; this.profileName = profileName; this.fullName = fullName; this.isVerified = isVerified; } public String getText() { return text == null ? cleanRichText(richText) : text; } public long getUserId() { return profileId; } public String getProfilePic() { return profileImage; } public String getUsername() { return profileName; } public String getFullName() { return fullName; } public List getMedia() { return media; } public double getTimestamp() { return timestamp; } public boolean isVerified() { return isVerified; } @NonNull public String getDateTime() { return TextUtils.epochSecondToString(Math.round(timestamp)); } private String cleanRichText(final String raw) { if (raw == null) return null; final Matcher matcher = Pattern.compile("\\{[\\p{L}\\d._]+\\|000000\\|1\\|user\\?id=\\d+\\}").matcher(raw); String result = raw; while (matcher.find()) { final String richObject = raw.substring(matcher.start(), matcher.end()); final String username = richObject.split("\\|")[0].substring(1); result = result.replace(richObject, username); } return result; } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationCounts.kt ================================================ package awais.instagrabber.repositories.responses.notification class NotificationCounts(val commentLikes: Int, val usertags: Int, val likes: Int, val comments: Int, val relationships: Int, val photosOfYou: Int, val requests: Int) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/notification/NotificationImage.kt ================================================ package awais.instagrabber.repositories.responses.notification class NotificationImage(val id: String, val image: String) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/saved/CollectionsListResponse.kt ================================================ package awais.instagrabber.repositories.responses.saved class CollectionsListResponse // this.numResults = numResults; (val isMoreAvailable: Boolean, val nextMaxId: String, val maxId: String, val status: String, // final int numResults, // public int getNumResults() { // return numResults; // } // private final int numResults; val items: List) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/saved/SavedCollection.kt ================================================ package awais.instagrabber.repositories.responses.saved import awais.instagrabber.repositories.responses.Media import java.io.Serializable class SavedCollection(val collectionId: String, val collectionName: String, val collectionType: String, val collectionMediaCount: Int, // coverMedia or coverMediaList: only one is defined val coverMedia: Media?, val coverMediaList: List?) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/search/SearchItem.java ================================================ package awais.instagrabber.repositories.responses.search; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.db.entities.RecentSearch; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.repositories.responses.Hashtag; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Place; import awais.instagrabber.repositories.responses.User; public class SearchItem { private static final String TAG = SearchItem.class.getSimpleName(); private final User user; private final Place place; private final Hashtag hashtag; private final int position; private boolean isRecent = false; private boolean isFavorite = false; public SearchItem(final User user, final Place place, final Hashtag hashtag, final int position) { this.user = user; this.place = place; this.hashtag = hashtag; this.position = position; } public User getUser() { return user; } public Place getPlace() { return place; } public Hashtag getHashtag() { return hashtag; } public int getPosition() { return position; } public boolean isRecent() { return isRecent; } public void setRecent(final boolean recent) { isRecent = recent; } public boolean isFavorite() { return isFavorite; } public void setFavorite(final boolean favorite) { isFavorite = favorite; } @Nullable public FavoriteType getType() { if (user != null) { return FavoriteType.USER; } if (hashtag != null) { return FavoriteType.HASHTAG; } if (place != null) { return FavoriteType.LOCATION; } return null; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final SearchItem that = (SearchItem) o; return Objects.equals(user, that.user) && Objects.equals(place, that.place) && Objects.equals(hashtag, that.hashtag); } @Override public int hashCode() { return Objects.hash(user, place, hashtag); } @NonNull @Override public String toString() { return "SearchItem{" + "user=" + user + ", place=" + place + ", hashtag=" + hashtag + ", position=" + position + ", isRecent=" + isRecent + '}'; } @NonNull public static List fromRecentSearch(final List recentSearches) { if (recentSearches == null) return Collections.emptyList(); return recentSearches.stream() .map(SearchItem::fromRecentSearch) .filter(Objects::nonNull) .collect(Collectors.toList()); } @Nullable private static SearchItem fromRecentSearch(final RecentSearch recentSearch) { if (recentSearch == null) return null; try { final FavoriteType type = recentSearch.getType(); final SearchItem searchItem; switch (type) { case USER: searchItem = new SearchItem(getUser(recentSearch), null, null, 0); break; case HASHTAG: searchItem = new SearchItem(null, null, getHashtag(recentSearch), 0); break; case LOCATION: searchItem = new SearchItem(null, getPlace(recentSearch), null, 0); break; default: return null; } searchItem.setRecent(true); return searchItem; } catch (Exception e) { Log.e(TAG, "fromRecentSearch: ", e); } return null; } public static List fromFavorite(final List favorites) { if (favorites == null) { return Collections.emptyList(); } return favorites.stream() .map(SearchItem::fromFavorite) .filter(Objects::nonNull) .collect(Collectors.toList()); } @Nullable private static SearchItem fromFavorite(final Favorite favorite) { if (favorite == null) return null; final FavoriteType type = favorite.getType(); if (type == null) return null; final SearchItem searchItem; switch (type) { case USER: searchItem = new SearchItem(getUser(favorite), null, null, 0); break; case HASHTAG: searchItem = new SearchItem(null, null, getHashtag(favorite), 0); break; case LOCATION: final Place place = getPlace(favorite); if (place == null) return null; searchItem = new SearchItem(null, place, null, 0); break; default: return null; } searchItem.setFavorite(true); return searchItem; } @NonNull private static User getUser(@NonNull final RecentSearch recentSearch) { return new User( Long.parseLong(recentSearch.getIgId()), recentSearch.getUsername(), recentSearch.getName(), false, recentSearch.getPicUrl(), false ); } @NonNull private static User getUser(@NonNull final Favorite favorite) { return new User( 0, favorite.getQuery(), favorite.getDisplayName(), false, favorite.getPicUrl(), false ); } @NonNull private static Hashtag getHashtag(@NonNull final RecentSearch recentSearch) { return new Hashtag( recentSearch.getIgId(), recentSearch.getName(), 0, null, null ); } @NonNull private static Hashtag getHashtag(@NonNull final Favorite favorite) { return new Hashtag( "0", favorite.getQuery(), 0, null, null ); } @NonNull private static Place getPlace(@NonNull final RecentSearch recentSearch) { final Location location = new Location( Long.parseLong(recentSearch.getIgId()), recentSearch.getName(), recentSearch.getName(), null, null, 0, 0 ); return new Place( location, recentSearch.getName(), null, null, null ); } @Nullable private static Place getPlace(@NonNull final Favorite favorite) { try { final Location location = new Location( Long.parseLong(favorite.getQuery()), favorite.getDisplayName(), favorite.getDisplayName(), null, null, 0, 0 ); return new Place( location, favorite.getDisplayName(), null, null, null ); } catch (Exception e) { Log.e(TAG, "getPlace: ", e); return null; } } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/search/SearchResponse.kt ================================================ package awais.instagrabber.repositories.responses.search data class SearchResponse( // app val list: List?, // browser val users: List?, val places: List?, val hashtags: List?, // universal val status: String?, ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/ArchiveResponse.kt ================================================ package awais.instagrabber.repositories.responses.stories data class ArchiveResponse( val numResults: Int, val maxId: String?, val moreAvailable: Boolean, val status: String, val items: List ) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/Broadcast.kt ================================================ package awais.instagrabber.repositories.responses.stories import awais.instagrabber.repositories.responses.User import java.io.Serializable data class Broadcast( val id: String?, val dashPlaybackUrl: String?, val dashAbrPlaybackUrl: String?, // adaptive quality val viewerCount: Double?, // always .0 val muted: Boolean?, val coverFrameUrl: String?, val broadcastOwner: User?, val publishedTime: Long? ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/CoverMedia.kt ================================================ package awais.instagrabber.repositories.responses.stories import awais.instagrabber.repositories.responses.ImageUrl data class CoverMedia(val croppedImageVersion: ImageUrl) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/PollSticker.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable import awais.instagrabber.repositories.responses.Hashtag import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.User data class PollSticker( val pollId: Long, val question: String?, val tallies: List, var viewerVote: Int? ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/QuestionSticker.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable import awais.instagrabber.repositories.responses.Hashtag import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.User data class QuestionSticker( val questionType: String, val questionId: Long, val question: String ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/QuizSticker.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable import awais.instagrabber.repositories.responses.Hashtag import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.User data class QuizSticker( val quizId: Long, val question: String, val tallies: List, var viewerAnswer: Int?, val correctAnswer: Int ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsMediaResponse.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable data class ReelsMediaResponse( val status: String?, val reels: Map? ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsResponse.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable data class ReelsResponse( val status: String?, val reel: Story?, // users val story: Story?, // hashtag and locations (unused) val broadcast: Broadcast? ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/ReelsTrayResponse.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable data class ReelsTrayResponse( val status: String?, val tray: List?, val broadcasts: List? ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/SliderSticker.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable import awais.instagrabber.repositories.responses.Hashtag import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.User data class SliderSticker( val sliderId: Long, val question: String, val emoji: String?, val viewerCanVote: Boolean?, val viewerVote: Double?, val sliderVoteAverage: Double?, val sliderVoteCount: Int?, ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/Story.kt ================================================ package awais.instagrabber.repositories.responses.stories import awais.instagrabber.repositories.responses.ImageUrl import awais.instagrabber.repositories.responses.User import awais.instagrabber.utils.TextUtils import java.io.Serializable data class Story( // universal val id: String? = null, val latestReelMedia: Long? = null, // = timestamp val mediaCount: Int? = null, // for stories and highlights val seen: Long? = null, val user: User? = null, // for stories val muted: Boolean? = null, val hasBestiesMedia: Boolean? = null, val items: List? = null, // may be null // for highlights val coverMedia: CoverMedia? = null, val title: String? = null, // for archives val coverImageVersion: ImageUrl? = null, // invented fields val broadcast: Broadcast? = null, // does not naturally occur ) : Serializable { val dateTime: String get() = if (latestReelMedia != null) TextUtils.epochSecondToString(latestReelMedia) else "" // note that archives have property "timestamp" but is ignored } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryAppAttribution.kt ================================================ package awais.instagrabber.repositories.responses.stories import android.net.Uri import java.io.Serializable // https://github.com/austinhuang0131/barinsta/issues/1151 data class StoryAppAttribution( val name: String?, val appActionText: String?, val contentUrl: String? ) : Serializable { val url: String? get() { val uri = Uri.parse(contentUrl) return if (uri.getHost().equals("open.spotify.com")) contentUrl?.split("?")?.get(0) else contentUrl } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryCta.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable import awais.instagrabber.repositories.responses.Hashtag import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.User data class StoryCta( val webUri: String? ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMedia.kt ================================================ package awais.instagrabber.repositories.responses.stories import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.models.enums.MediaItemType.Companion.valueOf import awais.instagrabber.repositories.responses.ImageVersions2 import awais.instagrabber.repositories.responses.MediaCandidate import awais.instagrabber.repositories.responses.User import awais.instagrabber.utils.TextUtils import java.io.Serializable data class StoryMedia( // inherited from Media val pk: Long = -1, val id: String = "", val takenAt: Long = -1, val user: User? = null, val canReshare: Boolean = false, val imageVersions2: ImageVersions2? = null, val originalWidth: Int = 0, val originalHeight: Int = 0, val mediaType: Int = 0, val isReelMedia: Boolean = false, val videoVersions: List? = null, val hasAudio: Boolean = false, val videoDuration: Double = 0.0, val viewCount: Long = 0, val title: String? = null, // story-specific val canReply: Boolean = false, val linkText: String? = null, // required for story_cta // stickers val reelMentions: List? = null, val storyHashtags: List? = null, val storyLocations: List? = null, val storyFeedMedia: List? = null, val storyPolls: List? = null, val storyQuestions: List? = null, val storyQuizs: List? = null, val storyCta: List? = null, val storySliders: List? = null, // spotify/soundcloud button, not a sticker val storyAppAttribution: StoryAppAttribution? = null ) : Serializable { private var dateString: String? = null var position = 0 var isCurrentSlide = false // TODO use extension once all usages are converted to kotlin // val date: String by lazy { // if (takenAt <= 0) "" else Utils.datetimeParser.format(Date(takenAt * 1000L)) // } val type: MediaItemType? get() = valueOf(mediaType) val date: String get() { if (takenAt <= 0) return "" if (dateString != null) return dateString ?: "" dateString = TextUtils.epochSecondToString(takenAt) return dateString ?: "" } } ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryMediaResponse.kt ================================================ package awais.instagrabber.repositories.responses.stories import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.repositories.responses.ImageVersions2 import awais.instagrabber.repositories.responses.MediaCandidate import awais.instagrabber.repositories.responses.User import awais.instagrabber.utils.TextUtils import java.io.Serializable data class StoryMediaResponse( val items: List?, // length 1 val status: String? // ignoring pagination properties ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/StorySticker.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable import awais.instagrabber.repositories.responses.Hashtag import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.User data class StorySticker( // only ONE object should exist val user: User?, // reel_mentions val hashtag: Hashtag?, // story_hashtags val location: Location?, // story_locations val mediaId: String?, // story_feed_media val pollSticker: PollSticker?, // story_polls val questionSticker: QuestionSticker?, // story_questions val quizSticker: QuizSticker?, // story_quizs val links: List?, // story_cta, requires link_text from the story val sliderSticker: SliderSticker? // story_sliders ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/StoryStickerResponse.kt ================================================ package awais.instagrabber.repositories.responses.stories data class StoryStickerResponse(val status: String?) ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/responses/stories/Tally.kt ================================================ package awais.instagrabber.repositories.responses.stories import java.io.Serializable import awais.instagrabber.repositories.responses.Hashtag import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.User data class Tally( val text: String, val count: Int ) : Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/repositories/serializers/CaptionDeserializer.java ================================================ package awais.instagrabber.repositories.serializers; import android.util.Log; import com.google.gson.Gson; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import java.lang.reflect.Type; import awais.instagrabber.repositories.responses.Caption; public class CaptionDeserializer implements JsonDeserializer { private static final String TAG = CaptionDeserializer.class.getSimpleName(); @Override public Caption deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { final Caption caption = new Gson().fromJson(json, Caption.class); final JsonObject jsonObject = json.getAsJsonObject(); if (jsonObject.has("pk")) { JsonElement elem = jsonObject.get("pk"); if (elem != null && !elem.isJsonNull()) { if (!elem.isJsonPrimitive()) return caption; String pkString = elem.getAsString(); if (pkString.contains("_")) { pkString = pkString.substring(0, pkString.indexOf("_")); } try { caption.setPk(pkString); } catch (NumberFormatException e) { Log.e(TAG, "deserialize: ", e); } } } return caption; } } ================================================ FILE: app/src/main/java/awais/instagrabber/services/ActivityCheckerService.java ================================================ package awais.instagrabber.services; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.content.Intent; import android.os.Binder; import android.os.Handler; import android.os.IBinder; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import java.util.ArrayList; import java.util.List; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.repositories.responses.notification.NotificationCounts; import awais.instagrabber.utils.Constants; import awais.instagrabber.webservices.NewsService; import awais.instagrabber.webservices.ServiceCallback; public class ActivityCheckerService extends Service { private static final String TAG = "ActivityCheckerService"; private static final int INITIAL_DELAY_MILLIS = 200; private static final int DELAY_MILLIS = 60000; private Handler handler; private NewsService newsService; private ServiceCallback cb; private NotificationManagerCompat notificationManager; private final IBinder binder = new LocalBinder(); private final Runnable runnable = () -> { newsService.fetchActivityCounts(cb); }; public class LocalBinder extends Binder { public ActivityCheckerService getService() { return ActivityCheckerService.this; } } @Override public void onCreate() { notificationManager = NotificationManagerCompat.from(getApplicationContext()); newsService = NewsService.getInstance(); handler = new Handler(); cb = new ServiceCallback() { @Override public void onSuccess(final NotificationCounts result) { try { if (result == null) return; final List notification = getNotificationString(result); if (notification == null) return; showNotification(notification); } finally { handler.postDelayed(runnable, DELAY_MILLIS); } } @Override public void onFailure(final Throwable t) {} }; } @Override public IBinder onBind(Intent intent) { startChecking(); return binder; } @Override public boolean onUnbind(final Intent intent) { stopChecking(); return super.onUnbind(intent); } private void startChecking() { handler.postDelayed(runnable, INITIAL_DELAY_MILLIS); } private void stopChecking() { handler.removeCallbacks(runnable); } private List getNotificationString(final NotificationCounts result) { final List toReturn = new ArrayList<>(2); final List list = new ArrayList<>(); int count = 0; if (result.getRelationships() != 0) { list.add(getString(R.string.activity_count_relationship, result.getRelationships())); count += result.getRelationships(); } if (result.getRequests() != 0) { list.add(getString(R.string.activity_count_requests, result.getRequests())); count += result.getRequests(); } if (result.getUsertags() != 0) { list.add(getString(R.string.activity_count_usertags, result.getUsertags())); count += result.getUsertags(); } if (result.getPhotosOfYou() != 0) { list.add(getString(R.string.activity_count_poy, result.getPhotosOfYou())); count += result.getPhotosOfYou(); } if (result.getComments() != 0) { list.add(getString(R.string.activity_count_comments, result.getComments())); count += result.getComments(); } if (result.getCommentLikes() != 0) { list.add(getString(R.string.activity_count_commentlikes, result.getCommentLikes())); count += result.getCommentLikes(); } if (result.getLikes() != 0) { list.add(getString(R.string.activity_count_likes, result.getLikes())); count += result.getLikes(); } if (list.isEmpty()) return null; toReturn.add(TextUtils.join(", ", list)); toReturn.add(getResources().getQuantityString(R.plurals.activity_count_total, count, count)); return toReturn; } private void showNotification(final List notificationString) { final Notification notification = new NotificationCompat.Builder(this, Constants.ACTIVITY_CHANNEL_ID) .setCategory(NotificationCompat.CATEGORY_STATUS) .setSmallIcon(R.drawable.ic_notif) .setAutoCancel(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentTitle(notificationString.get(1)) .setContentText(notificationString.get(0)) .setStyle(new NotificationCompat.BigTextStyle().bigText(notificationString.get(0))) .setContentIntent(getPendingIntent()) .build(); notificationManager.notify(Constants.ACTIVITY_NOTIFICATION_ID, notification); } @NonNull private PendingIntent getPendingIntent() { final Intent intent = new Intent(getApplicationContext(), MainActivity.class) .setAction(Constants.ACTION_SHOW_ACTIVITY) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); return PendingIntent.getActivity(getApplicationContext(), Constants.SHOW_ACTIVITY_REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT); } } ================================================ FILE: app/src/main/java/awais/instagrabber/services/BootCompletedReceiver.java ================================================ package awais.instagrabber.services; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import java.util.Objects; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.TextUtils; import static awais.instagrabber.utils.Utils.settingsHelper; public class BootCompletedReceiver extends BroadcastReceiver { @Override public void onReceive(final Context context, final Intent intent) { if (!Objects.equals(intent.getAction(), "android.intent.action.BOOT_COMPLETED")) return; final boolean enabled = settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH); if (!enabled) return; final String cookie = settingsHelper.getString(Constants.COOKIE); final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; if (!isLoggedIn) return; DMSyncAlarmReceiver.setAlarm(context); } } ================================================ FILE: app/src/main/java/awais/instagrabber/services/DMSyncAlarmReceiver.java ================================================ package awais.instagrabber.services; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.util.Log; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import java.time.Duration; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalUnit; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.utils.Constants; import static awais.instagrabber.utils.Utils.settingsHelper; public class DMSyncAlarmReceiver extends BroadcastReceiver { private static final String TAG = DMSyncAlarmReceiver.class.getSimpleName(); @Override public void onReceive(final Context context, final Intent intent) { final boolean enabled = settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH); if (!enabled) { // If somehow the alarm was triggered even when auto refresh is disabled cancelAlarm(context); return; } try { final Context applicationContext = context.getApplicationContext(); ContextCompat.startForegroundService(applicationContext, new Intent(applicationContext, DMSyncService.class)); } catch (Exception e) { Log.e(TAG, "onReceive: ", e); } } public static void setAlarm(@NonNull final Context context) { Log.d(TAG, "setting DMSyncService Alarm"); final AlarmManager alarmManager = getAlarmManager(context); if (alarmManager == null) return; final PendingIntent pendingIntent = getPendingIntent(context); alarmManager.setInexactRepeating(AlarmManager.RTC, System.currentTimeMillis(), getIntervalMillis(), pendingIntent); } public static void cancelAlarm(@NonNull final Context context) { Log.d(TAG, "cancelling DMSyncService Alarm"); final AlarmManager alarmManager = getAlarmManager(context); if (alarmManager == null) return; final PendingIntent pendingIntent = getPendingIntent(context); alarmManager.cancel(pendingIntent); } private static AlarmManager getAlarmManager(@NonNull final Context context) { return (AlarmManager) context.getApplicationContext().getSystemService(Context.ALARM_SERVICE); } private static PendingIntent getPendingIntent(@NonNull final Context context) { final Context applicationContext = context.getApplicationContext(); final Intent intent = new Intent(applicationContext, DMSyncAlarmReceiver.class); return PendingIntent.getBroadcast(applicationContext, Constants.DM_SYNC_SERVICE_REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT); } private static long getIntervalMillis() { int amount = settingsHelper.getInteger(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER); if (amount <= 0) { amount = 30; } final String unit = settingsHelper.getString(PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT); final TemporalUnit temporalUnit; switch (unit) { case "mins": temporalUnit = ChronoUnit.MINUTES; break; default: case "secs": temporalUnit = ChronoUnit.SECONDS; } return Duration.of(amount, temporalUnit).toMillis(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/services/DMSyncService.java ================================================ package awais.instagrabber.services; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.os.IBinder; import android.util.Log; import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; import androidx.lifecycle.LifecycleService; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.db.datasources.DMLastNotifiedDataSource; import awais.instagrabber.db.entities.DMLastNotified; import awais.instagrabber.db.repositories.DMLastNotifiedRepository; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.managers.DirectMessagesManager; import awais.instagrabber.managers.InboxManager; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.directmessages.DirectInbox; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.DirectThreadLastSeenAt; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.DMUtils; import awais.instagrabber.utils.DateUtils; import awais.instagrabber.utils.TextUtils; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class DMSyncService extends LifecycleService { private static final String TAG = DMSyncService.class.getSimpleName(); private InboxManager inboxManager; private DMLastNotifiedRepository dmLastNotifiedRepository; private Map dmLastNotifiedMap; @Override public void onCreate() { super.onCreate(); startForeground(Constants.DM_CHECK_NOTIFICATION_ID, buildForegroundNotification()); Log.d(TAG, "onCreate: Service created"); final DirectMessagesManager directMessagesManager = DirectMessagesManager.INSTANCE; inboxManager = directMessagesManager.getInboxManager(); final Context context = getApplicationContext(); if (context == null) return; dmLastNotifiedRepository = DMLastNotifiedRepository.getInstance(DMLastNotifiedDataSource.getInstance(context)); } private void parseUnread(@NonNull final DirectInbox directInbox) { dmLastNotifiedRepository.getAllDMDmLastNotified( CoroutineUtilsKt.getContinuation((result, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "parseUnread: ", throwable); dmLastNotifiedMap = Collections.emptyMap(); parseUnreadActual(directInbox); return; } dmLastNotifiedMap = result != null ? result.stream().collect(Collectors.toMap(DMLastNotified::getThreadId, Function.identity())) : Collections.emptyMap(); parseUnreadActual(directInbox); }), Dispatchers.getIO()) ); // Log.d(TAG, "inbox observer: " + directInbox); } private void parseUnreadActual(@NonNull final DirectInbox directInbox) { final List threads = directInbox.getThreads(); final ImmutableMap.Builder> unreadMessagesMapBuilder = ImmutableMap.builder(); if (threads == null) { stopSelf(); return; } for (final DirectThread thread : threads) { if (thread.getMuted()) continue; final boolean read = DMUtils.isRead(thread); if (read) continue; final List unreadMessages = getUnreadMessages(thread); if (unreadMessages.isEmpty()) continue; unreadMessagesMapBuilder.put(thread.getThreadId(), unreadMessages); } final Map> unreadMessagesMap = unreadMessagesMapBuilder.build(); if (unreadMessagesMap.isEmpty()) { stopSelf(); return; } showNotification(directInbox, unreadMessagesMap); final LocalDateTime now = LocalDateTime.now(); // Update db final ImmutableList.Builder lastNotifiedListBuilder = ImmutableList.builder(); for (final Map.Entry> unreadMessagesEntry : unreadMessagesMap.entrySet()) { final List unreadItems = unreadMessagesEntry.getValue(); final DirectItem latestItem = unreadItems.get(unreadItems.size() - 1); lastNotifiedListBuilder.add(new DMLastNotified(0, unreadMessagesEntry.getKey(), latestItem.getDate(), now)); } dmLastNotifiedRepository.insertOrUpdateDMLastNotified( lastNotifiedListBuilder.build(), CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { try { if (throwable != null) { Log.e(TAG, "parseUnreadActual: ", throwable); } } finally { stopSelf(); } }), Dispatchers.getIO()) ); } @NonNull private List getUnreadMessages(@NonNull final DirectThread thread) { final List items = thread.getItems(); if (items == null) return Collections.emptyList(); final DMLastNotified dmLastNotified = dmLastNotifiedMap.get(thread.getThreadId()); final long viewerId = thread.getViewerId(); final Map lastSeenAt = thread.getLastSeenAt(); final ImmutableList.Builder unreadListBuilder = ImmutableList.builder(); int count = 0; for (final DirectItem item : items) { if (item == null) continue; if (item.getUserId() == viewerId) break; // Reached a message from the viewer, it is assumed the viewer has read the next messages final boolean read = DMUtils.isRead(item, lastSeenAt, Collections.singletonList(viewerId)); if (read) break; if (dmLastNotified != null && dmLastNotified.getLastNotifiedMsgTs() != null && item.getDate() != null) { if (count == 0 && DateUtils.isBeforeOrEqual(item.getDate(), dmLastNotified.getLastNotifiedMsgTs())) { // The first unread item has been notified and hence all subsequent items can be ignored // since the items are in desc timestamp order break; } } unreadListBuilder.add(item); count++; // Inbox style notification only allows 6 lines if (count >= 6) break; } // Reversing, so that oldest messages are on top return unreadListBuilder.build().reverse(); } private void showNotification(final DirectInbox directInbox, final Map> unreadMessagesMap) { final NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (notificationManager == null) return; for (final Map.Entry> unreadMessagesEntry : unreadMessagesMap.entrySet()) { final Optional directThreadOptional = getThread(directInbox, unreadMessagesEntry.getKey()); if (!directThreadOptional.isPresent()) continue; final DirectThread thread = directThreadOptional.get(); final DirectItem firstDirectItem = thread.getFirstDirectItem(); if (firstDirectItem == null) continue; final List unreadMessages = unreadMessagesEntry.getValue(); final NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); inboxStyle.setBigContentTitle(thread.getThreadTitle()); for (final DirectItem item : unreadMessages) { inboxStyle.addLine(DMUtils.getMessageString(thread, getResources(), thread.getViewerId(), item)); } final Notification notification = new NotificationCompat.Builder(this, Constants.DM_UNREAD_CHANNEL_ID) .setStyle(inboxStyle) .setSmallIcon(R.drawable.ic_round_mode_comment_24) .setContentTitle(thread.getThreadTitle()) .setContentText(DMUtils.getMessageString(thread, getResources(), thread.getViewerId(), firstDirectItem)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setDefaults(NotificationCompat.DEFAULT_ALL) .setGroup(Constants.GROUP_KEY_DM) .setAutoCancel(true) .setContentIntent(getThreadPendingIntent(thread.getThreadId(), thread.getThreadTitle())) .build(); notificationManager.notify(Constants.DM_UNREAD_PARENT_NOTIFICATION_ID, notification); } } private Optional getThread(@NonNull final DirectInbox directInbox, final String threadId) { return directInbox.getThreads() .stream() .filter(thread -> Objects.equals(thread.getThreadId(), threadId)) .findFirst(); } @NonNull private PendingIntent getThreadPendingIntent(final String threadId, final String threadTitle) { final Intent intent = new Intent(getApplicationContext(), MainActivity.class) .setAction(Constants.ACTION_SHOW_DM_THREAD) .putExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_ID, threadId) .putExtra(Constants.DM_THREAD_ACTION_EXTRA_THREAD_TITLE, threadTitle) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); return PendingIntent.getActivity(getApplicationContext(), Constants.SHOW_DM_THREAD, intent, PendingIntent.FLAG_UPDATE_CURRENT); } @Override public int onStartCommand(final Intent intent, final int flags, final int startId) { super.onStartCommand(intent, flags, startId); final String cookie = settingsHelper.getString(Constants.COOKIE); final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; if (!isLoggedIn) { stopSelf(); return START_NOT_STICKY; } // Need to setup here if service was started by the boot completed receiver CookieUtils.setupCookies(cookie); final boolean notificationsEnabled = settingsHelper.getBoolean(PreferenceKeys.PREF_ENABLE_DM_NOTIFICATIONS); inboxManager.getInbox().observe(this, inboxResource -> { if (!notificationsEnabled || inboxResource == null || inboxResource.status != Resource.Status.SUCCESS) { stopSelf(); return; } final DirectInbox directInbox = inboxResource.data; if (directInbox == null) { stopSelf(); return; } parseUnread(directInbox); }); Log.d(TAG, "onStartCommand: refreshing inbox"); // inboxManager.refresh(); return START_NOT_STICKY; } @Override public IBinder onBind(@NonNull final Intent intent) { super.onBind(intent); return null; } private Notification buildForegroundNotification() { final Resources resources = getResources(); return new NotificationCompat.Builder(this, Constants.SILENT_NOTIFICATIONS_CHANNEL_ID) .setOngoing(true) .setSound(null) .setContentTitle(resources.getString(R.string.app_name)) .setContentText(resources.getString(R.string.checking_for_new_messages)) .setSmallIcon(R.mipmap.ic_launcher) .setPriority(NotificationCompat.PRIORITY_LOW) .setGroup(Constants.GROUP_KEY_SILENT_NOTIFICATIONS) .build(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/services/DeleteImageIntentService.java ================================================ package awais.instagrabber.services; import android.app.IntentService; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationManagerCompat; import androidx.documentfile.provider.DocumentFile; import java.util.Random; import awais.instagrabber.utils.TextUtils; public class DeleteImageIntentService extends IntentService { private final static String TAG = "DeleteImageIntent"; private static final int DELETE_IMAGE_SERVICE_REQUEST_CODE = 9010; private static final Random random = new Random(); public static final String EXTRA_IMAGE_PATH = "extra_image_path"; public static final String EXTRA_NOTIFICATION_ID = "extra_notification_id"; public static final String DELETE_IMAGE_SERVICE = "delete_image_service"; public DeleteImageIntentService() { super(DELETE_IMAGE_SERVICE); } @Override public void onCreate() { super.onCreate(); startService(new Intent(this, DeleteImageIntentService.class)); } @Override protected void onHandleIntent(@Nullable Intent intent) { if (intent != null && Intent.ACTION_DELETE.equals(intent.getAction()) && intent.hasExtra(EXTRA_IMAGE_PATH)) { final String path = intent.getStringExtra(EXTRA_IMAGE_PATH); if (TextUtils.isEmpty(path)) return; // final File file = new File(path); final Uri parse = Uri.parse(path); if (parse == null) return; final DocumentFile file = DocumentFile.fromSingleUri(getApplicationContext(), parse); boolean deleted; if (file.exists()) { deleted = file.delete(); if (!deleted) { Log.w(TAG, "onHandleIntent: file not deleted!"); } } else { deleted = true; } if (deleted) { final int notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1); NotificationManagerCompat.from(this).cancel(notificationId); } } } @NonNull public static PendingIntent pendingIntent(@NonNull final Context context, @NonNull final DocumentFile imagePath, final int notificationId) { final Intent intent = new Intent(context, DeleteImageIntentService.class); intent.setAction(Intent.ACTION_DELETE); intent.putExtra(EXTRA_IMAGE_PATH, imagePath.getUri().toString()); intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId); return PendingIntent.getService(context, random.nextInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT); } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/AppExecutors.kt ================================================ /* * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package awais.instagrabber.utils import android.os.Handler import android.os.Looper import com.google.common.util.concurrent.ListeningExecutorService import com.google.common.util.concurrent.MoreExecutors import java.util.concurrent.Executor import java.util.concurrent.Executors /** * Global executor pools for the whole application. * * * Grouping tasks like this avoids the effects of task starvation (e.g. disk reads don't wait behind * webservice requests). */ // TODO replace with kotlin coroutines and Dispatchers object AppExecutors { val diskIO: Executor = Executors.newSingleThreadExecutor() val networkIO: Executor = Executors.newFixedThreadPool(3) val mainThread: MainThreadExecutor = MainThreadExecutor() val tasksThread: ListeningExecutorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)) class MainThreadExecutor : Executor { private val mainThreadHandler = Handler(Looper.getMainLooper()) override fun execute(command: Runnable) { mainThreadHandler.post(command) } fun execute(command: Runnable?, delay: Int) { mainThreadHandler.postDelayed(command!!, delay.toLong()) } fun cancel(command: Runnable) { mainThreadHandler.removeCallbacks(command) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/BarinstaDeepLinkHelper.kt ================================================ package awais.instagrabber.utils import android.net.Uri import androidx.core.net.toUri private const val domain = "barinsta" fun getDirectThreadDeepLink(threadId: String, threadTitle: String, isPending: Boolean = false): Uri = "$domain://dm_thread/$threadId/$threadTitle?pending=${isPending}".toUri() fun getProfileDeepLink(username: String): Uri = "$domain://profile/$username".toUri() fun getPostDeepLink(shortCode: String): Uri = "$domain://post/$shortCode".toUri() fun getLocationDeepLink(locationId: Long): Uri = "$domain://location/$locationId".toUri() fun getLocationDeepLink(locationId: String): Uri = "$domain://location/$locationId".toUri() fun getHashtagDeepLink(hashtag: String): Uri = "$domain://hashtag/$hashtag".toUri() fun getNotificationsDeepLink(type: String, targetId: Long = 0): Uri = "$domain://notifications/$type?targetId=$targetId".toUri() fun getSearchDeepLink(): Uri = "$domain://search".toUri() ================================================ FILE: app/src/main/java/awais/instagrabber/utils/BitmapUtils.kt ================================================ package awais.instagrabber.utils import android.content.ContentResolver import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.util.Log import android.util.LruCache import androidx.core.util.Pair import androidx.documentfile.provider.DocumentFile import awais.instagrabber.utils.extensions.TAG import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.io.IOException object BitmapUtils { private val bitmapMemoryCache: LruCache const val THUMBNAIL_SIZE = 200f @JvmStatic fun addBitmapToMemoryCache(key: String, bitmap: Bitmap, force: Boolean) { if (force || getBitmapFromMemCache(key) == null) { bitmapMemoryCache.put(key, bitmap) } } @JvmStatic fun getBitmapFromMemCache(key: String): Bitmap? { return bitmapMemoryCache[key] } @JvmStatic suspend fun getThumbnail(context: Context, uri: Uri): BitmapResult? { val key = uri.toString() val cachedBitmap = getBitmapFromMemCache(key) if (cachedBitmap != null) { return BitmapResult(cachedBitmap, -1, -1) } return loadBitmap(context.contentResolver, uri, THUMBNAIL_SIZE, THUMBNAIL_SIZE, true) } /** * Loads bitmap from given Uri * * @param contentResolver [ContentResolver] to resolve the uri * @param uri Uri from where Bitmap will be loaded * @param reqWidth Required width * @param reqHeight Required height * @param addToCache true if the loaded bitmap should be added to the mem cache */ suspend fun loadBitmap( contentResolver: ContentResolver?, uri: Uri?, reqWidth: Float, reqHeight: Float, addToCache: Boolean, ): BitmapResult? = loadBitmap(contentResolver, uri, reqWidth, reqHeight, -1f, addToCache) /** * Loads bitmap from given Uri * * @param contentResolver [ContentResolver] to resolve the uri * @param uri Uri from where Bitmap will be loaded * @param maxDimenSize Max size of the largest side of the image * @param addToCache true if the loaded bitmap should be added to the mem cache */ suspend fun loadBitmap( contentResolver: ContentResolver?, uri: Uri?, maxDimenSize: Float, addToCache: Boolean, ): BitmapResult? = loadBitmap(contentResolver, uri, -1f, -1f, maxDimenSize, addToCache) /** * Loads bitmap from given Uri * * @param contentResolver [ContentResolver] to resolve the uri * @param uri Uri from where [Bitmap] will be loaded * @param reqWidth Required width (set to -1 if maxDimenSize provided) * @param reqHeight Required height (set to -1 if maxDimenSize provided) * @param maxDimenSize Max size of the largest side of the image (set to -1 if setting reqWidth and reqHeight) * @param addToCache true if the loaded bitmap should be added to the mem cache */ private suspend fun loadBitmap( contentResolver: ContentResolver?, uri: Uri?, reqWidth: Float, reqHeight: Float, maxDimenSize: Float, addToCache: Boolean, ): BitmapResult? = if (contentResolver == null || uri == null) null else withContext(Dispatchers.IO) { getBitmapResult(contentResolver, uri, reqWidth, reqHeight, maxDimenSize, addToCache) } fun getBitmapResult( contentResolver: ContentResolver, uri: Uri, reqWidth: Float, reqHeight: Float, maxDimenSize: Float, addToCache: Boolean, ): BitmapResult? { var bitmapOptions: BitmapFactory.Options var actualReqWidth = reqWidth var actualReqHeight = reqHeight try { contentResolver.openInputStream(uri).use { input -> val outBounds = BitmapFactory.Options() outBounds.inJustDecodeBounds = true outBounds.inPreferredConfig = Bitmap.Config.ARGB_8888 BitmapFactory.decodeStream(input, null, outBounds) if (outBounds.outWidth == -1 || outBounds.outHeight == -1) return null bitmapOptions = BitmapFactory.Options() if (maxDimenSize > 0) { // Raw height and width of image val height = outBounds.outHeight val width = outBounds.outWidth val ratio = width.toFloat() / height if (height > width) { actualReqHeight = maxDimenSize actualReqWidth = actualReqHeight * ratio } else { actualReqWidth = maxDimenSize actualReqHeight = actualReqWidth / ratio } } bitmapOptions.inSampleSize = calculateInSampleSize(outBounds, actualReqWidth, actualReqHeight) } } catch (e: Exception) { Log.e(TAG, "loadBitmap: ", e) return null } try { contentResolver.openInputStream(uri).use { input -> bitmapOptions.inPreferredConfig = Bitmap.Config.ARGB_8888 val bitmap = BitmapFactory.decodeStream(input, null, bitmapOptions) if (addToCache && bitmap != null) { addBitmapToMemoryCache(uri.toString(), bitmap, true) } return BitmapResult(bitmap, actualReqWidth.toInt(), actualReqHeight.toInt()) } } catch (e: Exception) { Log.e(TAG, "loadBitmap: ", e) } return null } private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Float, reqHeight: Float): Int { // Raw height and width of image val height = options.outHeight val width = options.outWidth var inSampleSize = 1 if (height > reqHeight || width > reqWidth) { val halfHeight = height / 2f val halfWidth = width / 2f // Calculate the largest inSampleSize value that is a power of 2 and keeps both // height and width larger than the requested height and width. while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth ) { inSampleSize *= 2 } } return inSampleSize } /** * Decodes the bounds of an image from its Uri and returns a pair of the dimensions * * @param uri the Uri of the image * @return dimensions of the image */ @Throws(IOException::class) fun decodeDimensions( contentResolver: ContentResolver, uri: Uri, ): Pair? { val options = BitmapFactory.Options() options.inJustDecodeBounds = true contentResolver.openInputStream(uri).use { stream -> BitmapFactory.decodeStream(stream, null, options) return if (options.outWidth == -1 || options.outHeight == -1) null else Pair(options.outWidth, options.outHeight) } } @Throws(IOException::class) fun convertToJpegAndSaveToFile(contentResolver: ContentResolver, bitmap: Bitmap, file: DocumentFile?): DocumentFile? { val tempFile = file ?: DownloadUtils.getTempFile(null, "jpg") contentResolver.openOutputStream(tempFile!!.uri).use { output -> val compressResult = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output) if (!compressResult) { throw RuntimeException("Compression failed!") } } return tempFile } @JvmStatic @Throws(Exception::class) fun convertToJpegAndSaveToUri( context: Context, bitmap: Bitmap, uri: Uri, ) { context.contentResolver.openOutputStream(uri).use { output -> val compressResult = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output) if (!compressResult) { throw RuntimeException("Compression failed!") } } } class BitmapResult(var bitmap: Bitmap?, var width: Int, var height: Int) init { // Get max available VM memory, exceeding this amount will throw an // OutOfMemory exception. Stored in kilobytes as LruCache takes an // int in its constructor. val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() // Use 1/8th of the available memory for this memory cache. val cacheSize: Int = maxMemory / 8 bitmapMemoryCache = object : LruCache(cacheSize) { override fun sizeOf(key: String, bitmap: Bitmap): Int { // The cache size will be measured in kilobytes rather than // number of items. return bitmap.byteCount / 1024 } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/CombinedDrawable.kt ================================================ /* * This is the source code of Telegram for Android v. 5.x.x. * It is licensed under GNU GPL v. 2 or later. * You should have received a copy of the license in this archive (see LICENSE). *

* Copyright Nikolai Kudashov, 2013-2018. */ package awais.instagrabber.utils import android.graphics.Canvas import android.graphics.ColorFilter import android.graphics.PixelFormat import android.graphics.drawable.Drawable class CombinedDrawable : Drawable, Drawable.Callback { val background: Drawable val icon: Drawable? private var left = 0 private var top = 0 private var iconWidth = 0 private var iconHeight = 0 private var backWidth = 0 private var backHeight = 0 private var offsetX = 0 private var offsetY = 0 private var fullSize = false constructor(backgroundDrawable: Drawable, iconDrawable: Drawable?, leftOffset: Int, topOffset: Int) { background = backgroundDrawable icon = iconDrawable left = leftOffset top = topOffset if (iconDrawable != null) { iconDrawable.callback = this } } constructor(backgroundDrawable: Drawable, iconDrawable: Drawable?) { background = backgroundDrawable icon = iconDrawable if (iconDrawable != null) { iconDrawable.callback = this } } fun setIconSize(width: Int, height: Int) { iconWidth = width iconHeight = height } fun setCustomSize(width: Int, height: Int) { backWidth = width backHeight = height } fun setIconOffset(x: Int, y: Int) { offsetX = x offsetY = y } fun setFullsize(value: Boolean) { fullSize = value } override fun setColorFilter(colorFilter: ColorFilter?) { icon?.colorFilter = colorFilter } override fun isStateful(): Boolean { return icon?.isStateful ?: false } override fun setState(stateSet: IntArray): Boolean { icon?.state = stateSet return true } override fun getState(): IntArray { return icon?.state ?: super.getState() } override fun onStateChange(state: IntArray): Boolean { return true } override fun jumpToCurrentState() { icon?.jumpToCurrentState() } override fun getConstantState(): ConstantState? { return icon?.constantState } override fun draw(canvas: Canvas) { background.bounds = bounds background.draw(canvas) if (icon == null) return if (fullSize) { val bounds = bounds if (left != 0) { icon.setBounds(bounds.left + left, bounds.top + top, bounds.right - left, bounds.bottom - top) } else { icon.bounds = bounds } } else { val x: Int val y: Int if (iconWidth != 0) { x = bounds.centerX() - iconWidth / 2 + left + offsetX y = bounds.centerY() - iconHeight / 2 + top + offsetY icon.setBounds(x, y, x + iconWidth, y + iconHeight) } else { x = bounds.centerX() - icon.intrinsicWidth / 2 + left y = bounds.centerY() - icon.intrinsicHeight / 2 + top icon.setBounds(x, y, x + icon.intrinsicWidth, y + icon.intrinsicHeight) } } icon.draw(canvas) } override fun setAlpha(alpha: Int) { icon?.alpha = alpha background.alpha = alpha } override fun getIntrinsicWidth(): Int { return if (backWidth != 0) backWidth else background.intrinsicWidth } override fun getIntrinsicHeight(): Int { return if (backHeight != 0) backHeight else background.intrinsicHeight } override fun getMinimumWidth(): Int { return if (backWidth != 0) backWidth else background.minimumWidth } override fun getMinimumHeight(): Int { return if (backHeight != 0) backHeight else background.minimumHeight } override fun getOpacity(): Int { return icon?.opacity ?: PixelFormat.UNKNOWN } override fun invalidateDrawable(who: Drawable) { invalidateSelf() } override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { scheduleSelf(what, `when`) } override fun unscheduleDrawable(who: Drawable, what: Runnable) { unscheduleSelf(what) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/ConcurrencyHelpers.kt ================================================ package awais.instagrabber.utils import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineStart.LAZY import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.atomic.AtomicReference /** * * From https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7 * * A helper class to execute tasks sequentially in coroutines. * * Calling [afterPrevious] will always ensure that all previously requested work completes prior to * calling the block passed. Any future calls to [afterPrevious] while the current block is running * will wait for the current block to complete before starting. */ class SingleRunner { /** * A coroutine mutex implements a lock that may only be taken by one coroutine at a time. */ private val mutex = Mutex() /** * Ensure that the block will only be executed after all previous work has completed. * * When several coroutines call afterPrevious at the same time, they will queue up in the order * that they call afterPrevious. Then, one coroutine will enter the block at a time. * * In the following example, only one save operation (user or song) will be executing at a time. * * ``` * class UserAndSongSaver { * val singleRunner = SingleRunner() * * fun saveUser(user: User) { * singleRunner.afterPrevious { api.post(user) } * } * * fun saveSong(song: Song) { * singleRunner.afterPrevious { api.post(song) } * } * } * ``` * * @param block the code to run after previous work is complete. */ suspend fun afterPrevious(block: suspend () -> T): T { // Before running the block, ensure that no other blocks are running by taking a lock on the // mutex. // The mutex will be released automatically when we return. // If any other block were already running when we get here, it will wait for it to complete // before entering the `withLock` block. mutex.withLock { return block() } } } /** * A controlled runner decides what to do when new tasks are run. * * By calling [joinPreviousOrRun], the new task will be discarded and the result of the previous task * will be returned. This is useful when you want to ensure that a network request to the same * resource does not flood. * * By calling [cancelPreviousThenRun], the old task will *always* be cancelled and then the new task will * be run. This is useful in situations where a new event implies that the previous work is no * longer relevant such as sorting or filtering a list. */ class ControlledRunner { /** * The currently active task. * * This uses an atomic reference to ensure that it's safe to update activeTask on both * Dispatchers.Default and Dispatchers.Main which will execute coroutines on multiple threads at * the same time. */ private val activeTask = AtomicReference?>(null) /** * Cancel all previous tasks before calling block. * * When several coroutines call cancelPreviousThenRun at the same time, only one will run and * the others will be cancelled. * * In the following example, only one sort operation will execute and any previous sorts will be * cancelled. * * ``` * class Products { * val controlledRunner = ControlledRunner() * * fun sortAscending(): List { * return controlledRunner.cancelPreviousThenRun { dao.loadSortedAscending() } * } * * fun sortDescending(): List { * return controlledRunner.cancelPreviousThenRun { dao.loadSortedDescending() } * } * } * ``` * * @param block the code to run after previous work is cancelled. * @return the result of block, if this call was not cancelled prior to returning. */ suspend fun cancelPreviousThenRun(block: suspend () -> T): T { // fast path: if we already know about an active task, just cancel it right away. activeTask.get()?.cancelAndJoin() return coroutineScope { // Create a new coroutine, but don't start it until it's decided that this block should // execute. In the code below, calling await() on newTask will cause this coroutine to // start. val newTask = async(start = LAZY) { block() } // When newTask completes, ensure that it resets activeTask to null (if it was the // current activeTask). newTask.invokeOnCompletion { activeTask.compareAndSet(newTask, null) } // Kotlin ensures that we only set result once since it's a val, even though it's set // inside the while(true) loop. val result: T // Loop until we are sure that newTask is ready to execute (all previous tasks are // cancelled) while (true) { if (!activeTask.compareAndSet(null, newTask)) { // some other task started before newTask got set to activeTask, so see if it's // still running when we call get() here. If so, we can cancel it. // we will always start the loop again to see if we can set activeTask before // starting newTask. activeTask.get()?.cancelAndJoin() // yield here to avoid a possible tight loop on a single threaded dispatcher yield() } else { // happy path - we set activeTask so we are ready to run newTask result = newTask.await() break } } // Kotlin ensures that the above loop always sets result exactly once, so we can return // it here! result } } /** * Don't run the new block if a previous block is running, instead wait for the previous block * and return it's result. * * When several coroutines call jonPreviousOrRun at the same time, only one will run and * the others will return the result from the winner. * * In the following example, only one network operation will execute at a time and any other * requests will return the result from the "in flight" request. * * ``` * class Products { * val controlledRunner = ControlledRunner() * * fun fetchProducts(): List { * return controlledRunner.joinPreviousOrRun { * val results = api.fetchProducts() * dao.insert(results) * results * } * } * } * ``` * * @param block the code to run if and only if no other task is currently running * @return the result of block, or if another task was running the result of that task instead. */ suspend fun joinPreviousOrRun(block: suspend () -> T): T { // fast path: if there's already an active task, just wait for it and return the result activeTask.get()?.let { return it.await() } return coroutineScope { // Create a new coroutine, but don't start it until it's decided that this block should // execute. In the code below, calling await() on newTask will cause this coroutine to // start. val newTask = async(start = LAZY) { block() } newTask.invokeOnCompletion { activeTask.compareAndSet(newTask, null) } // Kotlin ensures that we only set result once since it's a val, even though it's set // inside the while(true) loop. val result: T // Loop until we figure out if we need to run newTask, or if there is a task that's // already running we can join. while (true) { if (!activeTask.compareAndSet(null, newTask)) { // some other task started before newTask got set to activeTask, so see if it's // still running when we call get() here. There is a chance that it's already // been completed before the call to get, in which case we need to start the // loop over and try again. val currentTask = activeTask.get() if (currentTask != null) { // happy path - we found the other task so use that one instead of newTask newTask.cancel() result = currentTask.await() break } else { // retry path - the other task completed before we could get it, loop to try // setting activeTask again. // call yield here in case we're executing on a single threaded dispatcher // like Dispatchers.Main to allow other work to happen. yield() } } else { // happy path - we were able to set activeTask, so start newTask and return its // result result = newTask.await() break } } // Kotlin ensures that the above loop always sets result exactly once, so we can return // it here! result } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/Constants.kt ================================================ package awais.instagrabber.utils object Constants { const val CRASH_REPORT_EMAIL = "barinsta@austinhuang.me" // int prefs, do not export const val PREV_INSTALL_VERSION = "prevVersion" const val BROWSER_UA_CODE = "browser_ua_code" const val APP_UA_CODE = "app_ua_code" // never Export const val COOKIE = "cookie" // deprecated: public static final String SHOW_QUICK_ACCESS_DIALOG = "show_quick_dlg"; const val DEVICE_UUID = "device_uuid" const val BROWSER_UA = "browser_ua" const val APP_UA = "app_ua" //////////////////////// EXTRAS //////////////////////// const val EXTRAS_USER = "user" const val EXTRAS_HASHTAG = "hashtag" const val EXTRAS_LOCATION = "location" const val EXTRAS_USERNAME = "username" const val EXTRAS_ID = "id" const val EXTRAS_POST = "post" const val EXTRAS_PROFILE = "profile" const val EXTRAS_TYPE = "type" const val EXTRAS_NAME = "name" const val EXTRAS_STORIES = "stories" const val EXTRAS_HIGHLIGHT = "highlight" const val EXTRAS_INDEX = "index" const val EXTRAS_THREAD_MODEL = "threadModel" const val EXTRAS_FOLLOWERS = "followers" const val EXTRAS_SHORTCODE = "shortcode" const val EXTRAS_END_CURSOR = "endCursor" const val FEED = "feed" const val FEED_ORDER = "feedOrder" // Notification ids const val ACTIVITY_NOTIFICATION_ID = 10 const val DM_UNREAD_PARENT_NOTIFICATION_ID = 20 const val DM_CHECK_NOTIFICATION_ID = 11 // see https://github.com/dilame/instagram-private-api/blob/master/src/core/constants.ts // public static final String SUPPORTED_CAPABILITIES = "[ { \"name\": \"SUPPORTED_SDK_VERSIONS\", \"value\":" + // " \"13.0,14.0,15.0,16.0,17.0,18.0,19.0,20.0,21.0,22.0,23.0,24.0,25.0,26.0,27.0,28.0,29.0,30.0,31.0," + // "32.0,33.0,34.0,35.0,36.0,37.0,38.0,39.0,40.0,41.0,42.0,43.0,44.0,45.0,46.0,47.0,48.0,49.0,50.0,51.0," + // "52.0,53.0,54.0,55.0,56.0,57.0,58.0,59.0,60.0,61.0,62.0,63.0,64.0,65.0,66.0\" }, { \"name\": \"FACE_TRACKER_VERSION\", " + // "\"value\": 12 }, { \"name\": \"segmentation\", \"value\": \"segmentation_enabled\" }, { \"name\": \"COMPRESSION\", " + // "\"value\": \"ETC2_COMPRESSION\" }, { \"name\": \"world_tracker\", \"value\": \"world_tracker_enabled\" }, { \"name\": " + // "\"gyroscope\", \"value\": \"gyroscope_enabled\" } ]"; // public static final String SIGNATURE_VERSION = "4"; // public static final String SIGNATURE_KEY = "9193488027538fd3450b83b7d05286d4ca9599a0f7eeed90d8c85925698a05dc"; const val BREADCRUMB_KEY = "iN4\$aGr0m" const val LOGIN_RESULT_CODE = 5000 const val SKIPPED_VERSION = "skipped_version" const val DEFAULT_TAB = "default_tab" const val PREF_DARK_THEME = "dark_theme" const val PREF_LIGHT_THEME = "light_theme" const val DEFAULT_HASH_TAG_PIC = "https://www.instagram.com/static/images/hashtag/search-hashtag-default-avatar.png/1d8417c9a4f5.png" const val SHARED_PREFERENCES_NAME = "settings" const val PREF_POSTS_LAYOUT = "posts_layout" const val PREF_PROFILE_POSTS_LAYOUT = "profile_posts_layout" const val PREF_TOPIC_POSTS_LAYOUT = "topic_posts_layout" const val PREF_HASHTAG_POSTS_LAYOUT = "hashtag_posts_layout" const val PREF_LOCATION_POSTS_LAYOUT = "location_posts_layout" const val PREF_LIKED_POSTS_LAYOUT = "liked_posts_layout" const val PREF_TAGGED_POSTS_LAYOUT = "tagged_posts_layout" const val PREF_SAVED_POSTS_LAYOUT = "saved_posts_layout" const val PREF_EMOJI_VARIANTS = "emoji_variants" const val PREF_REACTIONS = "reactions" const val ACTIVITY_CHANNEL_ID = "activity" const val ACTIVITY_CHANNEL_NAME = "Activity" const val DOWNLOAD_CHANNEL_ID = "download" const val DOWNLOAD_CHANNEL_NAME = "Downloads" const val DM_UNREAD_CHANNEL_ID = "dmUnread" const val DM_UNREAD_CHANNEL_NAME = "Messages" const val SILENT_NOTIFICATIONS_CHANNEL_ID = "silentNotifications" const val SILENT_NOTIFICATIONS_CHANNEL_NAME = "Silent notifications" const val NOTIF_GROUP_NAME = "awais.instagrabber.InstaNotif" const val GROUP_KEY_DM = "awais.instagrabber.MESSAGES" const val GROUP_KEY_SILENT_NOTIFICATIONS = "awais.instagrabber.SILENT_NOTIFICATIONS" const val SHOW_ACTIVITY_REQUEST_CODE = 1738 const val SHOW_DM_THREAD = 2000 const val DM_SYNC_SERVICE_REQUEST_CODE = 3000 const val GLOBAL_NETWORK_ERROR_DIALOG_REQUEST_CODE = 7777 const val ACTION_SHOW_ACTIVITY = "show_activity" const val ACTION_SHOW_DM_THREAD = "show_dm_thread" const val DM_THREAD_ACTION_EXTRA_THREAD_ID = "thread_id" const val DM_THREAD_ACTION_EXTRA_THREAD_TITLE = "thread_title" const val X_IG_APP_ID = "936619743392459" const val EXTRA_INITIAL_URI = "initial_uri" const val defaultDateTimeFormat = "hh:mm:ss a 'on' dd-MM-yyyy" } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/CookieUtils.kt ================================================ @file:JvmName("CookieUtils") package awais.instagrabber.utils import android.content.Context import android.util.Log import android.webkit.CookieManager import awais.instagrabber.db.repositories.AccountRepository import java.net.CookiePolicy import java.net.HttpCookie import java.net.URI import java.net.URISyntaxException import java.util.regex.Pattern private const val TAG = "CookieUtils" private val COOKIE_MANAGER = CookieManager.getInstance() @JvmField val NET_COOKIE_MANAGER = java.net.CookieManager(null, CookiePolicy.ACCEPT_ALL) fun setupCookies(cookieRaw: String) { val cookieStore = NET_COOKIE_MANAGER.cookieStore if (cookieStore == null || TextUtils.isEmpty(cookieRaw)) { return } if (cookieRaw == "LOGOUT") { cookieStore.removeAll() return } try { val uri1 = URI("https://instagram.com") val uri2 = URI("https://instagram.com/") val uri3 = URI("https://i.instagram.com/") for (cookie in cookieRaw.split("; ")) { val strings = cookie.split("=", limit = 2) val httpCookie = HttpCookie(strings[0].trim { it <= ' ' }, strings[1].trim { it <= ' ' }) httpCookie.domain = ".instagram.com" httpCookie.path = "/" httpCookie.version = 0 cookieStore.add(uri1, httpCookie) cookieStore.add(uri2, httpCookie) cookieStore.add(uri3, httpCookie) } } catch (e: URISyntaxException) { Log.e(TAG, "", e) } } suspend fun removeAllAccounts(context: Context) { NET_COOKIE_MANAGER.cookieStore.removeAll() AccountRepository.getInstance(context).deleteAllAccounts() } fun getUserIdFromCookie(cookies: String?): Long { cookies ?: return 0 val dsUserId = getCookieValue(cookies, "ds_user_id") ?: return 0 try { return dsUserId.toLong() } catch (e: NumberFormatException) { Log.e(TAG, "getUserIdFromCookie: ", e) } return 0 } fun getCsrfTokenFromCookie(cookies: String): String? { return getCookieValue(cookies, "csrftoken") } private fun getCookieValue(cookies: String, name: String): String? { val pattern = Pattern.compile("$name=(.+?);") val matcher = pattern.matcher(cookies) return if (matcher.find()) { matcher.group(1) } else null } fun getCookie(webViewUrl: String?): String? { val domains: List = listOfNotNull( if (!TextUtils.isEmpty(webViewUrl)) webViewUrl else null, "https://instagram.com", "https://instagram.com/", "http://instagram.com", "http://instagram.com", "https://www.instagram.com", "https://www.instagram.com/", "http://www.instagram.com", "http://www.instagram.com/", ) return getLongestCookie(domains) } private fun getLongestCookie(domains: List): String? { var longestLength = 0 var longestCookie: String? = null for (domain in domains) { val cookie = COOKIE_MANAGER.getCookie(domain) if (cookie != null) { val cookieLength = cookie.length if (cookieLength > longestLength) { longestCookie = cookie longestLength = cookieLength } } } return longestCookie } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/CoroutineUtils.kt ================================================ package awais.instagrabber.utils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import java.util.function.BiConsumer import kotlin.coroutines.Continuation import kotlin.coroutines.CoroutineContext @JvmOverloads fun getContinuation(onFinished: BiConsumer, dispatcher: CoroutineDispatcher = Dispatchers.Default): Continuation { return object : Continuation { override val context: CoroutineContext get() = dispatcher override fun resumeWith(result: Result) { onFinished.accept(result.getOrNull(), result.exceptionOrNull()) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/CubicInterpolation.kt ================================================ package awais.instagrabber.utils import java.util.* class CubicInterpolation @JvmOverloads constructor(array: FloatArray, cubicTension: Int = 0) { private val array: FloatArray private val tangentFactor: Int private val length: Int private fun getTangent(k: Int): Float { return tangentFactor * (getClippedInput(k + 1) - getClippedInput(k - 1)) / 2 } fun interpolate(t: Float): Float { val k = Math.floor(t.toDouble()).toInt() val m = floatArrayOf(getTangent(k), getTangent(k + 1)) val p = floatArrayOf(getClippedInput(k), getClippedInput(k + 1)) val t1 = t - k val t2 = t1 * t1 val t3 = t1 * t2 return (2 * t3 - 3 * t2 + 1) * p[0] + (t3 - 2 * t2 + t1) * m[0] + (-2 * t3 + 3 * t2) * p[1] + (t3 - t2) * m[1] } private fun getClippedInput(i: Int): Float { return if (i >= 0 && i < length) { array[i] } else array[clipClamp(i, length)] } private fun clipClamp(i: Int, n: Int): Int { return Math.max(0, Math.min(i, n - 1)) } init { this.array = Arrays.copyOf(array, array.size) length = array.size tangentFactor = 1 - Math.max(0, Math.min(1, cubicTension)) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/DMUtils.java ================================================ package awais.instagrabber.utils; import android.content.res.Resources; import androidx.annotation.NonNull; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import awais.instagrabber.R; import awais.instagrabber.models.enums.DirectItemType; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.ActionType; import awais.instagrabber.repositories.responses.directmessages.DirectItem; import awais.instagrabber.repositories.responses.directmessages.DirectItemAnimatedMedia; import awais.instagrabber.repositories.responses.directmessages.DirectItemReelShare; import awais.instagrabber.repositories.responses.directmessages.DirectItemVisualMedia; import awais.instagrabber.repositories.responses.directmessages.DirectThread; import awais.instagrabber.repositories.responses.directmessages.DirectThreadLastSeenAt; import awais.instagrabber.repositories.responses.directmessages.RavenExpiringMediaActionSummary; public final class DMUtils { public static boolean isRead(@NonNull final DirectItem item, @NonNull final Map lastSeenAt, @NonNull final List userIdsToCheck) { return lastSeenAt.entrySet() .stream() .filter(entry -> userIdsToCheck.contains(entry.getKey())) .anyMatch(entry -> { final DirectThreadLastSeenAt threadLastSeenAt = entry.getValue(); if (threadLastSeenAt == null) return false; final String userLastSeenTsString = threadLastSeenAt.getTimestamp(); if (userLastSeenTsString == null) return false; final long userTs = Long.parseLong(userLastSeenTsString); final long itemTs = item.getTimestamp(); return userTs >= itemTs; }); } public static boolean isRead(@NonNull final DirectThread thread) { final boolean read; // if (thread.getDirectStory() != null) { // return false; // } final DirectItem item = thread.getFirstDirectItem(); final long viewerId = thread.getViewerId(); if (item != null && item.getUserId() == viewerId) { // if last item was sent by user, then it is read (even though we have auto read unchecked?) read = true; } else { final Map lastSeenAtMap = thread.getLastSeenAt(); read = item != null && isRead(item, lastSeenAtMap, Collections.singletonList(viewerId)); } return read; } public static String getMessageString(@NonNull final DirectThread thread, final Resources resources, final long viewerId, final DirectItem item) { final long senderId = item.getUserId(); final DirectItemType itemType = item.getItemType(); String subtitle = null; final String username = getUsername(thread.getUsers(), senderId, viewerId, resources); String message = ""; if (itemType == null) { message = resources.getString(R.string.dms_inbox_raven_message_unknown); } else { switch (itemType) { case TEXT: message = item.getText(); break; case LIKE: message = item.getLike(); break; case LINK: message = item.getLink().getText(); break; case PLACEHOLDER: message = item.getPlaceholder().getMessage(); break; case MEDIA_SHARE: final Media mediaShare = item.getMediaShare(); User mediaShareUser = null; if (mediaShare != null) { mediaShareUser = mediaShare.getUser(); } subtitle = resources.getString(R.string.dms_inbox_shared_post, username != null ? username : "", mediaShareUser == null ? "" : mediaShareUser.getUsername()); break; case ANIMATED_MEDIA: final DirectItemAnimatedMedia animatedMedia = item.getAnimatedMedia(); subtitle = resources.getString(animatedMedia.isSticker() ? R.string.dms_inbox_shared_sticker : R.string.dms_inbox_shared_gif, username != null ? username : ""); break; case PROFILE: subtitle = resources .getString(R.string.dms_inbox_shared_profile, username != null ? username : "", item.getProfile().getUsername()); break; case LOCATION: subtitle = resources .getString(R.string.dms_inbox_shared_location, username != null ? username : "", item.getLocation().getName()); break; case MEDIA: { final MediaItemType mediaType = item.getMedia().getType(); subtitle = getMediaSpecificSubtitle(username, resources, mediaType); break; } case STORY_SHARE: { final String reelType = item.getStoryShare().getReelType(); if (reelType == null) { subtitle = item.getStoryShare().getTitle(); } else { final int format = reelType.equals("highlight_reel") ? R.string.dms_inbox_shared_highlight : R.string.dms_inbox_shared_story; final Media media = item.getStoryShare().getMedia(); User storyShareMediaUser = null; if (media != null) { storyShareMediaUser = media.getUser(); } subtitle = resources.getString(format, username != null ? username : "", storyShareMediaUser == null ? "" : storyShareMediaUser.getUsername()); } break; } case VOICE_MEDIA: subtitle = resources.getString(R.string.dms_inbox_shared_voice, username != null ? username : ""); break; case ACTION_LOG: subtitle = item.getActionLog().getDescription(); break; case VIDEO_CALL_EVENT: subtitle = item.getVideoCallEvent().getDescription(); break; case CLIP: final Media clip = item.getClip().getClip(); User clipUser = null; if (clip != null) { clipUser = clip.getUser(); } subtitle = resources.getString(R.string.dms_inbox_shared_clip, username != null ? username : "", clipUser == null ? "" : clipUser.getUsername()); break; case FELIX_SHARE: final Media video = item.getFelixShare().getVideo(); User felixShareVideoUser = null; if (video != null) { felixShareVideoUser = video.getUser(); } subtitle = resources.getString(R.string.dms_inbox_shared_igtv, username != null ? username : "", felixShareVideoUser == null ? "" : felixShareVideoUser.getUsername()); break; case RAVEN_MEDIA: subtitle = getRavenMediaSubtitle(item, resources, username); break; case REEL_SHARE: final DirectItemReelShare reelShare = item.getReelShare(); if (reelShare == null) { subtitle = ""; break; } final String reelType = reelShare.getType(); switch (reelType) { case "reply": if (viewerId == item.getUserId()) { subtitle = resources.getString(R.string.dms_inbox_replied_story_outgoing, reelShare.getText()); } else { subtitle = resources .getString(R.string.dms_inbox_replied_story_incoming, username != null ? username : "", reelShare.getText()); } break; case "mention": if (viewerId == item.getUserId()) { // You mentioned the other person final long mentionedUserId = item.getReelShare().getMentionedUserId(); final String otherUsername = getUsername(thread.getUsers(), mentionedUserId, viewerId, resources); subtitle = resources.getString(R.string.dms_inbox_mentioned_story_outgoing, otherUsername); } else { // They mentioned you subtitle = resources.getString(R.string.dms_inbox_mentioned_story_incoming, username != null ? username : ""); } break; case "reaction": if (viewerId == item.getUserId()) { subtitle = resources.getString(R.string.dms_inbox_reacted_story_outgoing, reelShare.getText()); } else { subtitle = resources .getString(R.string.dms_inbox_reacted_story_incoming, username != null ? username : "", reelShare.getText()); } break; default: subtitle = ""; break; } break; case XMA: subtitle = resources.getString(R.string.dms_inbox_shared_sticker, username != null ? username : ""); break; default: message = resources.getString(R.string.dms_inbox_raven_message_unknown); } } if (subtitle == null) { if (thread.isGroup() || (!thread.isGroup() && senderId == viewerId)) { subtitle = String.format("%s: %s", username != null ? username : "", message); } else { subtitle = message; } } return subtitle; } public static String getUsername(final List users, final long userId, final long viewerId, final Resources resources) { if (userId == viewerId) { return resources.getString(R.string.you); } final Optional senderOptional = users.stream() .filter(Objects::nonNull) .filter(user -> user.getPk() == userId) .findFirst(); return senderOptional.map(user -> { // return full name for fb users final String username = user.getUsername(); if (TextUtils.isEmpty(username)) { return user.getFullName(); } return username; }).orElse(null); } public static String getMediaSpecificSubtitle(final String username, final Resources resources, final MediaItemType mediaType) { final String userSharedAnImage = resources.getString(R.string.dms_inbox_shared_image, username != null ? username : ""); final String userSharedAVideo = resources.getString(R.string.dms_inbox_shared_video, username != null ? username : ""); final String userSentAMessage = resources.getString(R.string.dms_inbox_shared_message, username != null ? username : ""); String subtitle; switch (mediaType) { case MEDIA_TYPE_IMAGE: subtitle = userSharedAnImage; break; case MEDIA_TYPE_VIDEO: subtitle = userSharedAVideo; break; default: subtitle = userSentAMessage; break; } return subtitle; } private static String getRavenMediaSubtitle(final DirectItem item, final Resources resources, final String username) { String subtitle = "↗ "; final DirectItemVisualMedia visualMedia = item.getVisualMedia(); final RavenExpiringMediaActionSummary summary = visualMedia.getExpiringMediaActionSummary(); if (summary != null) { final ActionType expiringMediaType = summary.getType(); int textRes = 0; switch (expiringMediaType) { case DELIVERED: textRes = R.string.dms_inbox_raven_media_delivered; break; case SENT: textRes = R.string.dms_inbox_raven_media_sent; break; case OPENED: textRes = R.string.dms_inbox_raven_media_opened; break; case REPLAYED: textRes = R.string.dms_inbox_raven_media_replayed; break; case SENDING: textRes = R.string.dms_inbox_raven_media_sending; break; case BLOCKED: textRes = R.string.dms_inbox_raven_media_blocked; break; case SUGGESTED: textRes = R.string.dms_inbox_raven_media_suggested; break; case SCREENSHOT: textRes = R.string.dms_inbox_raven_media_screenshot; break; case CANNOT_DELIVER: textRes = R.string.dms_inbox_raven_media_cant_deliver; break; } if (textRes > 0) { subtitle += resources.getString(textRes); } return subtitle; } final MediaItemType mediaType = visualMedia.getMedia().getType(); subtitle = getMediaSpecificSubtitle(username, resources, mediaType); return subtitle; } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/DateUtils.kt ================================================ package awais.instagrabber.utils import android.util.Log import awais.instagrabber.utils.extensions.TAG import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.* object DateUtils { val timezoneOffset: Int get() { val calendar = Calendar.getInstance(Locale.getDefault()) return -(calendar[Calendar.ZONE_OFFSET] + calendar[Calendar.DST_OFFSET]) / (60 * 1000) } @JvmStatic fun isBeforeOrEqual(localDateTime: LocalDateTime, comparedTo: LocalDateTime): Boolean { return localDateTime.isBefore(comparedTo) || localDateTime.isEqual(comparedTo) } @JvmStatic fun checkFormatterValid(datetimeParser: DateTimeFormatter): Boolean = try { LocalDateTime.now().format(datetimeParser) true } catch (e: Exception) { Log.e(TAG, "checkFormatterValid: ", e) false } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/Debouncer.java ================================================ package awais.instagrabber.utils; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; // TODO replace with kotlinx-coroutines debounce public class Debouncer { private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private final ConcurrentHashMap delayedMap = new ConcurrentHashMap<>(); private final ConcurrentHashMap> futureMap = new ConcurrentHashMap<>(); private final Callback callback; private final int interval; public Debouncer(Callback c, int interval) { this.callback = c; this.interval = interval; } public void call(T key) { TimerTask task = new TimerTask(key); TimerTask prev; do { prev = delayedMap.putIfAbsent(key, task); if (prev == null) { final ScheduledFuture future = scheduler.schedule(task, interval, TimeUnit.MILLISECONDS); futureMap.put(key, future); } } while (prev != null && !prev.extend()); // Exit only if new task was added to map, or existing task was extended successfully } public void terminate() { scheduler.shutdownNow(); } public void cancel(final T key) { delayedMap.remove(key); final ScheduledFuture future = futureMap.get(key); if (future != null) { future.cancel(true); } } // The task that wakes up when the wait time elapses private class TimerTask implements Runnable { private final T key; private long dueTime; private final Object lock = new Object(); public TimerTask(T key) { this.key = key; extend(); } public boolean extend() { synchronized (lock) { if (dueTime < 0) // Task has been shutdown return false; dueTime = System.currentTimeMillis() + interval; return true; } } public void run() { synchronized (lock) { long remaining = dueTime - System.currentTimeMillis(); if (remaining > 0) { // Re-schedule task scheduler.schedule(this, remaining, TimeUnit.MILLISECONDS); } else { // Mark as terminated and invoke callback dueTime = -1; try { callback.call(key); } catch (Exception e) { callback.onError(e); } finally { delayedMap.remove(key); } } } } } public interface Callback { void call(T key); void onError(Throwable t); } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/DeepLinkParser.kt ================================================ package awais.instagrabber.utils import java.util.regex.Pattern object DeepLinkParser { private val TYPE_PATTERN_MAP: Map = mapOf( DeepLink.Type.USER to DeepLinkPattern("instagram://user?username="), ) @JvmStatic fun parse(text: String): DeepLink? { for ((key, value) in TYPE_PATTERN_MAP) { if (text.startsWith(value.patternText)) { return DeepLink(key, value.pattern.matcher(text).replaceAll("")) } } return null } data class DeepLinkPattern(val patternText: String) { val pattern: Pattern = Pattern.compile(patternText, Pattern.LITERAL) } data class DeepLink(val type: Type, val value: String) { enum class Type { USER } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/DirectItemFactory.kt ================================================ @file:JvmName("DirectItemFactory") package awais.instagrabber.utils import android.net.Uri import awais.instagrabber.models.enums.DirectItemType import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.repositories.responses.* import awais.instagrabber.repositories.responses.directmessages.DirectItem import awais.instagrabber.repositories.responses.directmessages.DirectItemAnimatedMedia import awais.instagrabber.repositories.responses.directmessages.DirectItemVoiceMedia import awais.instagrabber.repositories.responses.giphy.GiphyGif import java.util.* fun createText( userId: Long, clientContext: String?, text: String?, repliedToMessage: DirectItem? ): DirectItem { return DirectItem( itemId = UUID.randomUUID().toString(), userId = userId, timestamp = System.currentTimeMillis() * 1000, itemType = DirectItemType.TEXT, text = text, clientContext = clientContext, repliedToMessage = repliedToMessage, ) } fun createImageOrVideo( userId: Long, clientContext: String?, uri: Uri, width: Int, height: Int, isVideo: Boolean ): DirectItem { val imageVersions2 = ImageVersions2(listOf(MediaCandidate(width, height, uri.toString()))) var videoVersions: List? = null if (isVideo) { val videoVersion = MediaCandidate( width, height, uri.toString() ) videoVersions = listOf(videoVersion) } val media = Media( id = UUID.randomUUID().toString(), imageVersions2 = imageVersions2, originalWidth = width, originalHeight = height, mediaType = if (isVideo) MediaItemType.MEDIA_TYPE_VIDEO.id else MediaItemType.MEDIA_TYPE_IMAGE.id, videoVersions = videoVersions, ) return DirectItem( itemId = UUID.randomUUID().toString(), userId = userId, timestamp = System.currentTimeMillis() * 1000, itemType = DirectItemType.MEDIA, clientContext = clientContext, media = media, ) } fun createVoice( userId: Long, clientContext: String?, uri: Uri, duration: Long, waveform: List?, samplingFreq: Int ): DirectItem { val audio = Audio( uri.toString(), duration, waveform, samplingFreq, 0 ) val media = Media( id = UUID.randomUUID().toString(), mediaType = MediaItemType.MEDIA_TYPE_VOICE.id, audio = audio, ) val voiceMedia = DirectItemVoiceMedia( media, 0, "permanent" ) return DirectItem( itemId = UUID.randomUUID().toString(), userId = userId, timestamp = System.currentTimeMillis() * 1000, itemType = DirectItemType.VOICE_MEDIA, clientContext = clientContext, media = media, voiceMedia = voiceMedia, ) } fun createAnimatedMedia( userId: Long, clientContext: String?, giphyGif: GiphyGif ): DirectItem { val animatedImages = AnimatedMediaImages(giphyGif.images.fixedHeight) val animateMedia = DirectItemAnimatedMedia( giphyGif.id, animatedImages, false, giphyGif.isSticker ) return DirectItem( itemId = UUID.randomUUID().toString(), userId = userId, timestamp = System.currentTimeMillis() * 1000, itemType = DirectItemType.ANIMATED_MEDIA, clientContext = clientContext, animatedMedia = animateMedia, ) } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/DownloadUtils.kt ================================================ package awais.instagrabber.utils import android.content.Context import android.content.UriPermission import android.net.Uri import android.provider.DocumentsContract import android.util.Log import android.view.MenuItem import android.view.View import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.PopupMenu import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.documentfile.provider.DocumentFile import androidx.work.* import awais.instagrabber.R import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.repositories.responses.Media import awais.instagrabber.repositories.responses.stories.StoryMedia import awais.instagrabber.utils.AppExecutors.tasksThread import awais.instagrabber.utils.TextUtils.isEmpty import awais.instagrabber.workers.DownloadWorker import com.google.gson.Gson import java.io.BufferedWriter import java.io.IOException import java.io.OutputStreamWriter import java.util.* import java.util.regex.Pattern import kotlin.math.abs object DownloadUtils { private val TAG = DownloadUtils::class.java.simpleName // private static final String DIR_BARINSTA = "Barinsta"; private const val DIR_DOWNLOADS = "Downloads" private const val DIR_CAMERA = "Camera" private const val DIR_EDIT = "Edit" private const val DIR_RECORDINGS = "Sent Recordings" private const val DIR_TEMP = "Temp" private const val DIR_BACKUPS = "Backups" private const val MIME_DIR = DocumentsContract.Document.MIME_TYPE_DIR private val dirMap: MutableMap = mutableMapOf() private var root: DocumentFile? = null @JvmStatic @Throws(ReselectDocumentTreeException::class) fun init( context: Context, barinstaDirUri: String? ) { if (isEmpty(barinstaDirUri)) { throw ReselectDocumentTreeException("folder path is null or empty") } val uri = Uri.parse(barinstaDirUri) if (!barinstaDirUri!!.startsWith("content://com.android.externalstorage.documents")) { throw ReselectDocumentTreeException(uri) } val existingPermissions = context.contentResolver.persistedUriPermissions if (existingPermissions.isEmpty()) { throw ReselectDocumentTreeException(uri) } val anyMatch = existingPermissions.stream() .anyMatch { uriPermission: UriPermission -> uriPermission.uri == uri } if (!anyMatch) { throw ReselectDocumentTreeException(uri) } root = DocumentFile.fromTreeUri(context, uri) if (root == null || !root!!.exists() || root!!.lastModified() == 0L) { root = null throw ReselectDocumentTreeException(uri) } Utils.settingsHelper.putString(PreferenceKeys.PREF_BARINSTA_DIR_URI, uri.toString()) // set up directories val dirKeys = mapOf( DIR_DOWNLOADS to MIME_DIR, DIR_CAMERA to MIME_DIR, DIR_EDIT to MIME_DIR, DIR_RECORDINGS to MIME_DIR, DIR_TEMP to MIME_DIR, DIR_BACKUPS to MIME_DIR ) dirMap.putAll(checkFiles(context, root, dirKeys, true)) } fun destroy() { root = null dirMap.clear() } fun checkFiles(context: Context, parent: DocumentFile?, queries: Map, // create: Boolean ): Map { // first we'll find existing ones val result: MutableMap = mutableMapOf() if (root == null || parent == null || !parent.isDirectory) return result.toMap() val docId = DocumentsContract.getDocumentId(parent.uri) val docUri = DocumentsContract.buildChildDocumentsUriUsingTree(root!!.uri, docId) val docCursor = context.contentResolver.query( docUri, arrayOf( DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_MIME_TYPE ), null, null, null ) if (docCursor == null) return result.toMap() while (docCursor.moveToNext()) { val q = queries.get(docCursor.getString(0)) if (q == null || !docCursor.getString(2).equals(q)) continue val fileUri = DocumentsContract.buildDocumentUriUsingTree(parent.uri, docCursor.getString(1)) val dir = if (q.equals(MIME_DIR)) DocumentFile.fromTreeUri(context, fileUri) else DocumentFile.fromSingleUri(context, fileUri) result.put(docCursor.getString(0), dir) if (result.size >= queries.size) break } docCursor.close() // next we'll create inexistent ones, if necessary if (create) { for (k in queries) { if (result.get(k.key) == null) { result.put(k.key, if (MIME_DIR.equals(k.value)) parent.createDirectory(k.key) else parent.createFile(k.value, k.key)) } } } return result.toMap() } fun getRootDir(dir: String): DocumentFile? { if (root == null) return null return dirMap.get(dir) } @JvmStatic val downloadDir: DocumentFile? get() = getRootDir(DIR_DOWNLOADS) @JvmStatic val cameraDir: DocumentFile? get() = getRootDir(DIR_CAMERA) @JvmStatic fun getImageEditDir(sessionId: String?, context: Context): DocumentFile? { val editRoot = getRootDir(DIR_EDIT) if (sessionId == null) return editRoot return checkFiles(context, editRoot, mapOf(sessionId to MIME_DIR), true).get(sessionId) } @JvmStatic val recordingsDir: DocumentFile? get() = getRootDir(DIR_RECORDINGS) @JvmStatic val backupsDir: DocumentFile? get() = getRootDir(DIR_BACKUPS) private fun getDownloadDir( context: Context, username: String?, shouldCreate: Boolean ): DocumentFile? { if (!Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_USER_FOLDER) || username.isNullOrEmpty()) return downloadDir return checkFiles(context, downloadDir, mapOf(username to MIME_DIR), shouldCreate).get(username) } private val tempDir: DocumentFile? get() = getRootDir(DIR_TEMP) private fun getDownloadSavePaths( postId: String?, displayUrl: String? ): Pair { return getDownloadFileName(postId, "", displayUrl, "") } private fun getDownloadSavePaths( postId: String?, displayUrl: String, username: String ): Pair { return getDownloadFileName(postId, "", displayUrl, username) } private fun getDownloadChildSavePaths( postId: String?, childPosition: Int, url: String?, username: String ): Pair { val sliderPostfix = "_slide_$childPosition" return getDownloadFileName(postId, sliderPostfix, url, username) } private fun getDownloadFileName( postId: String?, sliderPostfix: String, displayUrl: String?, username: String ): Pair { val extension = getFileExtensionFromUrl(displayUrl) val usernamePrepend = if (isEmpty(username)) "" else username + "_" val fileName = usernamePrepend + postId + sliderPostfix + extension val mimeType = Utils.mimeTypeMap.getMimeTypeFromExtension(extension) return Pair(fileName, mimeType!!) } fun getTempFile(fileName: String?, extension: String): DocumentFile? { val dir = tempDir var name = fileName if (isEmpty(name)) { name = UUID.randomUUID().toString() } var mimeType: String? = "application/octet-stream" if (!isEmpty(extension)) { name += ".$extension" val mimeType1 = Utils.mimeTypeMap.getMimeTypeFromExtension(extension) if (mimeType1 != null) { mimeType = mimeType1 } } var file = dir!!.findFile(name!!) if (file == null) { file = dir.createFile(mimeType!!, name) } return file } /** * Copied from [MimeTypeMap.getFileExtensionFromUrl]) * * * Returns the file extension or an empty string if there is no * extension. This method is a convenience method for obtaining the * extension of a url and has undefined results for other Strings. * * @param url URL * @return The file extension of the given url. */ @JvmStatic fun getFileExtensionFromUrl(url: String?): String { var url = url if (!isEmpty(url)) { val fragment = url!!.lastIndexOf('#') if (fragment > 0) { url = url.substring(0, fragment) } val query = url.lastIndexOf('?') if (query > 0) { url = url.substring(0, query) } val filenamePos = url.lastIndexOf('/') val filename = if (0 <= filenamePos) url.substring(filenamePos + 1) else url // if the filename contains special characters, we don't // consider it valid for our matching purposes: if (!filename.isEmpty() && Pattern.matches("[a-zA-Z_0-9.\\-()%]+", filename) ) { val dotPos = filename.lastIndexOf('.') if (0 <= dotPos) { return filename.substring(dotPos + 1) } } } return "" } @JvmStatic fun checkDownloaded(media: Media, context: Context): List { val checkList: MutableList = LinkedList() val user = media.user var username = "username" if (user != null) { username = user.username } val userFolder = getDownloadDir(context, username, false) if (userFolder == null) return checkList when (media.type) { MediaItemType.MEDIA_TYPE_IMAGE, MediaItemType.MEDIA_TYPE_VIDEO -> { val url = if (media.type == MediaItemType.MEDIA_TYPE_VIDEO) ResponseBodyUtils.getVideoUrl( media ) else ResponseBodyUtils.getImageUrl(media) val fileName = getDownloadSavePaths(media.code, url) val fileNameWithUser = getDownloadSavePaths(media.code, url, username) val files = checkFiles(context, userFolder, mapOf(fileName, fileNameWithUser), false) checkList.add(files.size > 0) } MediaItemType.MEDIA_TYPE_SLIDER -> { val sliderItems = media.carouselMedia val fileNames: MutableMap = mutableMapOf() val filePairs: MutableMap = mutableMapOf() var i = 0 while (i < sliderItems!!.size) { val child = sliderItems[i] val url = if (child.type == MediaItemType.MEDIA_TYPE_VIDEO) ResponseBodyUtils.getVideoUrl( child ) else ResponseBodyUtils.getImageUrl(child) val fileName = getDownloadChildSavePaths(media.code, i+1, url, "") val fileNameWithUser = getDownloadChildSavePaths(media.code, i+1, url, username) fileNames.put(fileName.first, fileName.second) fileNames.put(fileNameWithUser.first, fileNameWithUser.second) filePairs.put(fileName.first, fileNameWithUser.first) i++ } val files = checkFiles(context, userFolder, fileNames, false) for (p in filePairs) { checkList.add(files.get(p.key) != null || files.get(p.value) != null) } } else -> { } } return checkList } @JvmStatic fun showDownloadDialog( context: Context, feedModel: Media, childPosition: Int, popupLocation: View? ) { if (childPosition == -1 || popupLocation == null) { download(context, feedModel) return } val themeWrapper = ContextThemeWrapper(context, R.style.popupMenuStyle) val popupMenu = PopupMenu(themeWrapper, popupLocation) val menu = popupMenu.menu menu.add(0, R.id.download_current, 0, R.string.post_viewer_download_current) menu.add(0, R.id.download_all, 1, R.string.post_viewer_download_album) popupMenu.setOnMenuItemClickListener { item: MenuItem -> val itemId = item.itemId if (itemId == R.id.download_current) { download(context, feedModel, childPosition) } else if (itemId == R.id.download_all) { download(context, feedModel) } false } popupMenu.show() } @JvmStatic fun download( context: Context, storyModel: StoryMedia ) { val downloadDir = getDownloadDir(context, storyModel.user?.username, true) ?: return val url = if (storyModel.type == MediaItemType.MEDIA_TYPE_VIDEO) ResponseBodyUtils.getVideoUrl(storyModel) else ResponseBodyUtils.getImageUrl(storyModel) val extension = getFileExtensionFromUrl(url) val mimeType = Utils.mimeTypeMap.getMimeTypeFromExtension(extension) val baseFileName = storyModel.id + "_" + storyModel.takenAt + extension val usernamePrepend = if (Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME) && storyModel.user?.username != null ) storyModel.user.username + "_" else "" val fileName = usernamePrepend + baseFileName var saveFile = checkFiles(context, downloadDir, mapOf(fileName to mimeType!!), true).get(fileName) download(context, url, saveFile) } @JvmOverloads @JvmStatic fun download( context: Context, feedModel: Media, position: Int = -1 ) { download(context, listOf(feedModel), position) } // this must be used for bulk download, but ONLY bulk download @JvmStatic fun download( context: Context, feedModels: List ) { val builder = NotificationCompat.Builder(context, Constants.DOWNLOAD_CHANNEL_ID) .setCategory(NotificationCompat.CATEGORY_PROGRESS) .setSmallIcon(R.drawable.ic_download) .setOngoing(true) .setProgress(1, 0, true) .setAutoCancel(false) .setOnlyAlertOnce(true) .setContentTitle(context.getString(R.string.downloader_preparing)) val notification = builder.build() val nid = abs(UUID.randomUUID().hashCode()) val nManager = NotificationManagerCompat.from(context.applicationContext) nManager.notify(nid, notification) tasksThread.execute { download(context, feedModels, -1) nManager.cancel(nid) } } private fun download( context: Context, feedModels: List, childPositionIfSingle: Int ) { val map: MutableMap> = HashMap() val fileMap: MutableMap = HashMap() for (media in feedModels) { val mediaUser = media.user val username = mediaUser?.username ?: "" val dir = getDownloadDir(context, username, true) when (media.type) { MediaItemType.MEDIA_TYPE_IMAGE, MediaItemType.MEDIA_TYPE_VIDEO -> { val url = getUrlOfType(media) var fileName = media.id if (mediaUser != null && isEmpty(media.code)) { fileName = mediaUser.username + "_" + fileName } if (!isEmpty(media.code)) { fileName = media.code if (Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME) && mediaUser != null) { fileName = mediaUser.username + "_" + fileName } } val pair = getDownloadSavePaths(fileName, url) map[url!!] = pair } MediaItemType.MEDIA_TYPE_VOICE -> { val url = getUrlOfType(media) var fileName = media.id if (mediaUser != null) { fileName = mediaUser.username + "_" + fileName } val pair = getDownloadSavePaths(fileName, url) map[url!!] = pair } MediaItemType.MEDIA_TYPE_SLIDER -> { val sliderItems = media.carouselMedia var i = 0 while (i < sliderItems!!.size) { if (childPositionIfSingle >= 0 && feedModels.size == 1 && i != childPositionIfSingle) { i++ continue } val child = sliderItems[i] val url = getUrlOfType(child) val usernamePrepend = if (Utils.settingsHelper.getBoolean(PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME) && mediaUser != null) mediaUser.username else "" val pair = getDownloadChildSavePaths(media.code, i + 1, url, usernamePrepend) map[url!!] = pair i++ } } } fileMap.putAll(checkFiles(context, dir, map.values.toMap(), true)) } if (map.isEmpty() || fileMap.isEmpty()) return val resultMap: MutableMap = mutableMapOf() map.mapValuesTo(resultMap) { fileMap.get(it.value.first) } download(context, resultMap) } private fun getUrlOfType(media: Media): String? { when (media.type) { MediaItemType.MEDIA_TYPE_IMAGE -> { return ResponseBodyUtils.getImageUrl(media) } MediaItemType.MEDIA_TYPE_VIDEO -> { val videoVersions = media.videoVersions var url: String? = null if (videoVersions != null && !videoVersions.isEmpty()) { url = videoVersions[0].url } return url } MediaItemType.MEDIA_TYPE_VOICE -> { val audio = media.audio var url: String? = null if (audio != null) { url = audio.audioSrc } return url } } return null } @JvmStatic fun download( context: Context?, url: String?, filePath: DocumentFile? ) { if (context == null || filePath == null) return download(context, Collections.singletonMap(url!!, filePath)) } private fun download(context: Context?, urlFilePathMap: Map) { if (context == null) return val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = DownloadWorker.DownloadRequest.builder() .setUrlToFilePathMap(urlFilePathMap) .build() val requestJson = Gson().toJson(request) val tempFile = getTempFile(null, "json") if (tempFile == null) { Log.e(TAG, "download: temp file is null") return } val uri = tempFile.uri val contentResolver = context.contentResolver ?: return try { BufferedWriter(OutputStreamWriter(contentResolver.openOutputStream(uri))).use { writer -> writer.write( requestJson ) } } catch (e: IOException) { Log.e(TAG, "download: Error writing request to file", e) tempFile.delete() return } val downloadWorkRequest: WorkRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java) .setInputData( Data.Builder() .putString( DownloadWorker.KEY_DOWNLOAD_REQUEST_JSON, tempFile.uri.toString() ) .build() ) .setConstraints(constraints) .addTag("download") .build() WorkManager.getInstance(context) .enqueue(downloadWorkRequest) } @JvmStatic fun getRootDirUri(): Uri? { return if (root != null) root!!.uri else null } class ReselectDocumentTreeException : Exception { val initialUri: Uri? constructor() { initialUri = null } constructor(message: String?) : super(message) { initialUri = null } constructor(initialUri: Uri?) { this.initialUri = initialUri } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/Event.kt ================================================ package awais.instagrabber.utils /** * Used as a wrapper for data that is exposed via a LiveData that represents an event. */ open class Event(private val content: T) { var hasBeenHandled = false private set // Allow external read but not write /** * Returns the content and prevents its use again. */ fun getContentIfNotHandled(): T? { return if (hasBeenHandled) { null } else { hasBeenHandled = true content } } /** * Returns the content, even if it's already been handled. */ fun peekContent(): T = content } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/ExoplayerUtils.kt ================================================ package awais.instagrabber.utils import android.content.Context import com.google.android.exoplayer2.database.ExoDatabaseProvider import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory import com.google.android.exoplayer2.upstream.cache.CacheDataSource import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor import com.google.android.exoplayer2.upstream.cache.SimpleCache object ExoplayerUtils { private const val MAX_CACHE_BYTES: Long = 1048576 private val cacheEvictor = LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES) fun getCachedMediaSourceFactory(context: Context): DefaultMediaSourceFactory { val exoDatabaseProvider = ExoDatabaseProvider(context) val simpleCache = SimpleCache(context.cacheDir, cacheEvictor, exoDatabaseProvider) val cacheDataSourceFactory = CacheDataSource.Factory() .setCache(simpleCache) .setUpstreamDataSourceFactory(DefaultHttpDataSourceFactory()) return DefaultMediaSourceFactory(cacheDataSourceFactory) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/ExportImportUtils.java ================================================ package awais.instagrabber.utils; import android.content.Context; import android.content.SharedPreferences; import android.net.Uri; import android.util.Base64; import android.util.Log; import android.util.Pair; import android.widget.Toast; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.InputStream; import java.io.OutputStream; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import awais.instagrabber.BuildConfig; import awais.instagrabber.db.entities.Account; import awais.instagrabber.db.entities.Favorite; import awais.instagrabber.db.repositories.AccountRepository; import awais.instagrabber.db.repositories.FavoriteRepository; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.models.enums.FavoriteType; import awais.instagrabber.utils.PasswordUtils.IncorrectPasswordException; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; //import awaisomereport.LogCollector.LogFile; //import static awais.instagrabber.utils.Utils.logCollector; public final class ExportImportUtils { private static final String TAG = "ExportImportUtils"; public static final int FLAG_COOKIES = 1; public static final int FLAG_FAVORITES = 1 << 1; public static final int FLAG_SETTINGS = 1 << 2; public static void importData(@NonNull final Context context, @ExportImportFlags final int flags, @NonNull final Uri uri, final String password, final FetchListener fetchListener) throws IncorrectPasswordException { try (final InputStream stream = context.getContentResolver().openInputStream(uri)) { if (stream == null) return; final int configType = stream.read(); final StringBuilder builder = new StringBuilder(); int c; while ((c = stream.read()) != -1) { builder.append((char) c); } if (configType == 'A') { // password if (TextUtils.isEmpty(password)) return; try { final byte[] passwordBytes = password.getBytes(); final byte[] bytes = new byte[32]; System.arraycopy(passwordBytes, 0, bytes, 0, Math.min(passwordBytes.length, 32)); importJson(context, new String(PasswordUtils.dec(builder.toString(), bytes)), flags, fetchListener); } catch (final IncorrectPasswordException e) { throw e; } catch (final Exception e) { if (fetchListener != null) fetchListener.onResult(false); if (BuildConfig.DEBUG) Log.e(TAG, "Error importing backup", e); } } else if (configType == 'Z') { importJson(context, new String(Base64.decode(builder.toString(), Base64.DEFAULT | Base64.NO_PADDING | Base64.NO_WRAP)), flags, fetchListener); } else { Toast.makeText(context, "File is corrupted!", Toast.LENGTH_LONG).show(); if (fetchListener != null) fetchListener.onResult(false); } } catch (IncorrectPasswordException e) { // separately handle incorrect password throw e; } catch (final Exception e) { if (fetchListener != null) fetchListener.onResult(false); if (BuildConfig.DEBUG) Log.e(TAG, "", e); } } private static void importJson(final Context context, @NonNull final String json, @ExportImportFlags final int flags, final FetchListener fetchListener) { try { final JSONObject jsonObject = new JSONObject(json); if ((flags & FLAG_SETTINGS) == FLAG_SETTINGS && jsonObject.has("settings")) { importSettings(jsonObject); } if ((flags & FLAG_COOKIES) == FLAG_COOKIES && jsonObject.has("cookies")) { importAccounts(context, jsonObject); } if ((flags & FLAG_FAVORITES) == FLAG_FAVORITES && jsonObject.has("favs")) { importFavorites(context, jsonObject); } if (fetchListener != null) fetchListener.onResult(true); } catch (final Exception e) { if (fetchListener != null) fetchListener.onResult(false); if (BuildConfig.DEBUG) Log.e(TAG, "", e); } } private static void importFavorites(final Context context, final JSONObject jsonObject) throws JSONException { final JSONArray favs = jsonObject.getJSONArray("favs"); for (int i = 0; i < favs.length(); i++) { final JSONObject favsObject = favs.getJSONObject(i); final String queryText = favsObject.optString("q"); if (TextUtils.isEmpty(queryText)) continue; final Pair favoriteTypeQueryPair; String query = null; FavoriteType favoriteType = null; if (queryText.contains("@") || queryText.contains("#") || queryText.contains("/")) { favoriteTypeQueryPair = Utils.migrateOldFavQuery(queryText); if (favoriteTypeQueryPair != null) { query = favoriteTypeQueryPair.second; favoriteType = favoriteTypeQueryPair.first; } } else { query = queryText; favoriteType = FavoriteType.valueOf(favsObject.optString("type")); } if (query == null || favoriteType == null) { continue; } final long epochMillis = favsObject.getLong("d"); final Favorite favorite = new Favorite( 0, query, favoriteType, favsObject.optString("s"), favoriteType == FavoriteType.USER ? favsObject.optString("pic_url") : null, LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()) ); // Log.d(TAG, "importJson: favoriteModel: " + favoriteModel); final FavoriteRepository favRepo = FavoriteRepository.Companion.getInstance(context); favRepo.getFavorite( query, favoriteType, CoroutineUtilsKt.getContinuation((favorite1, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "importFavorites: ", throwable); return; } if (favorite1 == null) { favRepo.insertOrUpdateFavorite(favorite, CoroutineUtilsKt.getContinuation((unit, throwable1) -> {}, Dispatchers.getIO())); } // local has priority since it's more frequently updated }), Dispatchers.getIO()) ); } } private static void importAccounts(final Context context, final JSONObject jsonObject) { final List accounts = new ArrayList<>(); try { final JSONArray cookies = jsonObject.getJSONArray("cookies"); for (int i = 0; i < cookies.length(); i++) { final JSONObject cookieObject = cookies.getJSONObject(i); final Account account = new Account( -1, cookieObject.optString("i"), cookieObject.optString("u"), cookieObject.optString("c"), cookieObject.optString("full_name"), cookieObject.optString("profile_pic") ); if (!account.isValid()) continue; accounts.add(account); } } catch (Exception e) { Log.e(TAG, "importAccounts: Error parsing json", e); return; } AccountRepository.Companion .getInstance(context) .insertOrUpdateAccounts(accounts, CoroutineUtilsKt.getContinuation((unit, throwable) -> {}, Dispatchers.getIO())); } private static void importSettings(final JSONObject jsonObject) { try { final JSONObject objSettings = jsonObject.getJSONObject("settings"); final Iterator keys = objSettings.keys(); while (keys.hasNext()) { final String key = keys.next(); final Object val = objSettings.opt(key); // Log.d(TAG, "importJson: key: " + key + ", val: " + val); if (val instanceof String) { settingsHelper.putString(key, (String) val); } else if (val instanceof Integer) { settingsHelper.putInteger(key, (int) val); } else if (val instanceof Boolean) { settingsHelper.putBoolean(key, (boolean) val); } } } catch (Exception e) { Log.e(TAG, "importSettings error", e); } } public static boolean isEncrypted(@NonNull final Context context, @NonNull final Uri uri) { try (final InputStream stream = context.getContentResolver().openInputStream(uri)) { if (stream == null) return false; final int configType = stream.read(); if (configType == 'A') { return true; } } catch (final Exception e) { Log.e(TAG, "isEncrypted", e); } return false; } public static void exportData(@NonNull final Context context, @ExportImportFlags final int flags, @NonNull final Uri uri, final String password, final FetchListener fetchListener) { getExportString(flags, context, exportString -> { if (TextUtils.isEmpty(exportString)) return; final boolean isPass = !TextUtils.isEmpty(password); byte[] exportBytes = null; if (isPass) { final byte[] passwordBytes = password.getBytes(); final byte[] bytes = new byte[32]; System.arraycopy(passwordBytes, 0, bytes, 0, Math.min(passwordBytes.length, 32)); try { exportBytes = PasswordUtils.enc(exportString, bytes); } catch (final Exception e) { if (fetchListener != null) fetchListener.onResult(false); if (BuildConfig.DEBUG) Log.e(TAG, "", e); } } else { exportBytes = Base64.encode(exportString.getBytes(), Base64.DEFAULT | Base64.NO_WRAP | Base64.NO_PADDING); } if (exportBytes != null && exportBytes.length > 1) { try (final OutputStream stream = context.getContentResolver().openOutputStream(uri)) { if (stream == null) return; stream.write(isPass ? 'A' : 'Z'); stream.write(exportBytes); if (fetchListener != null) fetchListener.onResult(true); } catch (Exception e) { if (fetchListener != null) fetchListener.onResult(false); if (BuildConfig.DEBUG) Log.e(TAG, "", e); } return; } if (fetchListener != null) { fetchListener.onResult(false); } }); } private static void getExportString(@ExportImportFlags final int flags, @NonNull final Context context, final OnExportStringCreatedCallback callback) { if (callback == null) return; try { final ImmutableList.Builder> futures = ImmutableList.builder(); futures.add((flags & FLAG_SETTINGS) == FLAG_SETTINGS ? getSettings(context) : Futures.immediateFuture(null)); futures.add((flags & FLAG_COOKIES) == FLAG_COOKIES ? getCookies(context) : Futures.immediateFuture(null)); futures.add((flags & FLAG_FAVORITES) == FLAG_FAVORITES ? getFavorites(context) : Futures.immediateFuture(null)); //noinspection UnstableApiUsage final ListenableFuture> allFutures = Futures.allAsList(futures.build()); Futures.addCallback(allFutures, new FutureCallback>() { @Override public void onSuccess(final List result) { final JSONObject jsonObject = new JSONObject(); if (result == null) { callback.onCreated(jsonObject.toString()); return; } try { final JSONObject settings = (JSONObject) result.get(0); if (settings != null) { jsonObject.put("settings", settings); } } catch (Exception e) { Log.e(TAG, "error getting settings: ", e); } try { final JSONArray accounts = (JSONArray) result.get(1); if (accounts != null) { jsonObject.put("cookies", accounts); } } catch (Exception e) { Log.e(TAG, "error getting accounts", e); } try { final JSONArray favorites = (JSONArray) result.get(2); if (favorites != null) { jsonObject.put("favs", favorites); } } catch (Exception e) { Log.e(TAG, "error getting favorites: ", e); } callback.onCreated(jsonObject.toString()); } @Override public void onFailure(@NonNull final Throwable t) { Log.e(TAG, "onFailure: ", t); callback.onCreated(null); } }, AppExecutors.INSTANCE.getTasksThread()); return; } catch (final Exception e) { // if (logCollector != null) logCollector.appendException(e, LogFile.UTILS_EXPORT, "getExportString"); if (BuildConfig.DEBUG) Log.e(TAG, "", e); } callback.onCreated(null); } @NonNull private static ListenableFuture getSettings(@NonNull final Context context) { final SharedPreferences sharedPreferences = context.getSharedPreferences(Constants.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); return AppExecutors.INSTANCE.getTasksThread().submit(() -> { final Map allPrefs = sharedPreferences.getAll(); if (allPrefs == null) { return new JSONObject(); } try { final JSONObject jsonObject = new JSONObject(allPrefs); jsonObject.remove(Constants.COOKIE); jsonObject.remove(Constants.DEVICE_UUID); jsonObject.remove(Constants.PREV_INSTALL_VERSION); jsonObject.remove(Constants.BROWSER_UA_CODE); jsonObject.remove(Constants.BROWSER_UA); jsonObject.remove(Constants.APP_UA_CODE); jsonObject.remove(Constants.APP_UA); return jsonObject; } catch (Exception e) { Log.e(TAG, "Error exporting settings", e); } return new JSONObject(); }); } private static ListenableFuture getFavorites(final Context context) { final SettableFuture future = SettableFuture.create(); final FavoriteRepository favoriteRepository = FavoriteRepository.Companion.getInstance(context); favoriteRepository.getAllFavorites( CoroutineUtilsKt.getContinuation((favorites, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { future.set(new JSONArray()); Log.e(TAG, "getFavorites: ", throwable); return; } final JSONArray jsonArray = new JSONArray(); try { for (final Favorite favorite : favorites) { final JSONObject jsonObject = new JSONObject(); jsonObject.put("q", favorite.getQuery()); jsonObject.put("type", favorite.getType().toString()); jsonObject.put("s", favorite.getDisplayName()); jsonObject.put("pic_url", favorite.getPicUrl()); jsonObject.put("d", favorite.getDateAdded().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); jsonArray.put(jsonObject); } } catch (Exception e) { if (BuildConfig.DEBUG) { Log.e(TAG, "Error exporting favorites", e); } } future.set(jsonArray); }), Dispatchers.getIO()) ); return future; } private static ListenableFuture getCookies(final Context context) { final SettableFuture future = SettableFuture.create(); final AccountRepository accountRepository = AccountRepository.Companion.getInstance(context); accountRepository.getAllAccounts( CoroutineUtilsKt.getContinuation((accounts, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable != null) { Log.e(TAG, "getCookies: ", throwable); future.set(new JSONArray()); return; } final JSONArray jsonArray = new JSONArray(); try { for (final Account cookie : accounts) { final JSONObject jsonObject = new JSONObject(); jsonObject.put("i", cookie.getUid()); jsonObject.put("u", cookie.getUsername()); jsonObject.put("c", cookie.getCookie()); jsonObject.put("full_name", cookie.getFullName()); jsonObject.put("profile_pic", cookie.getProfilePic()); jsonArray.put(jsonObject); } } catch (Exception e) { if (BuildConfig.DEBUG) { Log.e(TAG, "Error exporting accounts", e); } } future.set(jsonArray); }), Dispatchers.getIO()) ); return future; } @IntDef(value = {FLAG_COOKIES, FLAG_FAVORITES, FLAG_SETTINGS}, flag = true) @interface ExportImportFlags {} public interface OnExportStringCreatedCallback { void onCreated(String exportString); } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/FlavorTown.java ================================================ package awais.instagrabber.utils; import android.content.Context; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import java.util.Objects; import java.util.concurrent.ThreadLocalRandom; import java.util.regex.Matcher; import java.util.regex.Pattern; import awais.instagrabber.BuildConfig; import awais.instagrabber.R; import awaisomereport.CrashReporterHelper; import static awais.instagrabber.utils.Utils.settingsHelper; public final class FlavorTown { private static final String TAG = "FlavorTown"; private static final UpdateChecker UPDATE_CHECKER = UpdateChecker.getInstance(); private static final Pattern VERSION_NAME_PATTERN = Pattern.compile("v?(\\d+\\.\\d+\\.\\d+)(?:_?)(\\w*)(?:-?)(\\w*)"); private static boolean checking = false; public static void updateCheck(@NonNull final AppCompatActivity context) { updateCheck(context, false); } public static void updateCheck(@NonNull final AppCompatActivity context, final boolean force) { if (checking) return; checking = true; AppExecutors.INSTANCE.getNetworkIO().execute(() -> { final String onlineVersionName = UPDATE_CHECKER.getLatestVersion(); if (onlineVersionName == null) return; final String onlineVersion = getVersion(onlineVersionName); final String localVersion = getVersion(BuildConfig.VERSION_NAME); if (Objects.equals(onlineVersion, localVersion)) { if (force) { AppExecutors.INSTANCE.getMainThread().execute(() -> { final Context applicationContext = context.getApplicationContext(); // Check if app was closed or crashed before reaching here if (applicationContext == null) return; // Show toast if version number preference was tapped Toast.makeText(applicationContext, R.string.on_latest_version, Toast.LENGTH_SHORT).show(); }); } return; } final boolean shouldShowDialog = UpdateCheckCommon.shouldShowUpdateDialog(force, onlineVersionName); if (!shouldShowDialog) return; UpdateCheckCommon.showUpdateDialog(context, onlineVersionName, (dialog, which) -> { UPDATE_CHECKER.onDownload(context); dialog.dismiss(); }); }); } private static String getVersion(@NonNull final String versionName) { final Matcher matcher = VERSION_NAME_PATTERN.matcher(versionName); if (!matcher.matches()) return versionName; try { return matcher.group(1); } catch (Exception e) { Log.e(TAG, "getVersion: ", e); } return versionName; } public static void changelogCheck(@NonNull final Context context) { if (settingsHelper.getInteger(Constants.PREV_INSTALL_VERSION) >= BuildConfig.VERSION_CODE) return; int appUaCode = settingsHelper.getInteger(Constants.APP_UA_CODE); int browserUaCode = settingsHelper.getInteger(Constants.BROWSER_UA_CODE); if (browserUaCode == -1 || browserUaCode >= UserAgentUtils.browsers.length) { browserUaCode = ThreadLocalRandom.current().nextInt(0, UserAgentUtils.browsers.length); settingsHelper.putInteger(Constants.BROWSER_UA_CODE, browserUaCode); } if (appUaCode == -1 || appUaCode >= UserAgentUtils.devices.length) { appUaCode = ThreadLocalRandom.current().nextInt(0, UserAgentUtils.devices.length); settingsHelper.putInteger(Constants.APP_UA_CODE, appUaCode); } final String appUa = UserAgentUtils.generateAppUA(appUaCode, LocaleUtils.getCurrentLocale().getLanguage()); settingsHelper.putString(Constants.APP_UA, appUa); final String browserUa = UserAgentUtils.generateBrowserUA(browserUaCode); settingsHelper.putString(Constants.BROWSER_UA, browserUa); AppExecutors.INSTANCE.getDiskIO().execute(() -> CrashReporterHelper.deleteAllStacktraceFiles(context)); Toast.makeText(context, R.string.updated, Toast.LENGTH_SHORT).show(); settingsHelper.putInteger(Constants.PREV_INSTALL_VERSION, BuildConfig.VERSION_CODE); } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/IntentUtils.kt ================================================ package awais.instagrabber.utils import android.net.Uri import android.text.TextUtils import awais.instagrabber.models.IntentModel import awais.instagrabber.models.enums.IntentModelType object IntentUtils { @JvmStatic fun parseUrl(url: String): IntentModel? { val parsedUrl = Uri.parse(url).normalizeScheme() // final String domain = parsedUrl.getHost().replaceFirst("^www\\.", ""); // final boolean isHttpsUri = "https".equals(parsedUrl.getScheme()); val paths = parsedUrl.pathSegments if (paths.isEmpty()) { return null } var path = paths[0] var text: String? = null var type = IntentModelType.UNKNOWN if (1 == paths.size) { text = path type = IntentModelType.USERNAME } else if ("_u" == path) { text = paths[1] type = IntentModelType.USERNAME } else if ("p" == path || "reel" == path || "tv" == path) { text = paths[1] type = IntentModelType.POST } else if (2 < paths.size && "explore" == path) { path = paths[1] if ("locations" == path) { text = paths[2] type = IntentModelType.LOCATION } if ("tags" == path) { text = paths[2] type = IntentModelType.HASHTAG } } return if (TextUtils.isEmpty(text)) { null } else IntentModel(type, text!!) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/KeywordsFilterUtils.kt ================================================ package awais.instagrabber.utils import awais.instagrabber.repositories.responses.Media import java.util.* import kotlin.collections.ArrayList // fun filter(caption: String?): Boolean { // if (caption == null) return false // if (keywords.isEmpty()) return false // val temp = caption.toLowerCase() // for (s in keywords) { // if (temp.contains(s)) return true // } // return false // } private fun containsAnyKeyword(keywords: List, media: Media?): Boolean { if (media == null || keywords.isEmpty()) return false val (_, text) = media.caption ?: return false val temp = text!!.lowercase(Locale.getDefault()) return keywords.any { temp.contains(it) } } fun filter(keywords: List, media: List?): List? { if (keywords.isEmpty()) return media if (media == null) return ArrayList() val result: MutableList = ArrayList() media.filterNotTo(result) { containsAnyKeyword(keywords, it) } return result } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/LocaleUtils.kt ================================================ package awais.instagrabber.utils import android.content.Context import android.content.res.Configuration import android.view.ContextThemeWrapper import awais.instagrabber.fragments.settings.PreferenceKeys import java.util.* // taken from my app TESV Console Codes object LocaleUtils { private lateinit var defaultLocale: Locale @JvmStatic lateinit var currentLocale: Locale private set @JvmStatic fun setLocale(baseContext: Context) { var baseContext1 = baseContext defaultLocale = Locale.getDefault() if (baseContext1 is ContextThemeWrapper) baseContext1 = baseContext1.baseContext if (Utils.settingsHelper == null) Utils.settingsHelper = SettingsHelper(baseContext1) val appLanguageSettings = Utils.settingsHelper.getString(PreferenceKeys.APP_LANGUAGE) val lang = getCorrespondingLanguageCode(appLanguageSettings) currentLocale = when { TextUtils.isEmpty(lang) -> defaultLocale lang!!.contains("_") -> { val split = lang.split("_") Locale(split[0], split[1]) } else -> Locale(lang) } currentLocale.let { Locale.setDefault(it) val res = baseContext1.resources val config = res.configuration // config.locale = currentLocale config.setLocale(it) config.setLayoutDirection(it) res.updateConfiguration(config, res.displayMetrics) } } @JvmStatic fun updateConfig(wrapper: ContextThemeWrapper) { if (!this::currentLocale.isInitialized) return val configuration = Configuration() // configuration.locale = currentLocale configuration.setLocale(currentLocale) wrapper.applyOverrideConfiguration(configuration) } fun getCorrespondingLanguageCode(appLanguageSettings: String): String? { if (TextUtils.isEmpty(appLanguageSettings)) return null when (appLanguageSettings.toInt()) { 1 -> return "en" 2 -> return "fr" 3 -> return "es" 4 -> return "zh_CN" 5 -> return "in" 6 -> return "it" 7 -> return "de" 8 -> return "pl" 9 -> return "tr" 10 -> return "pt" 11 -> return "fa" 12 -> return "mk" 13 -> return "vi" 14 -> return "zh_TW" 15 -> return "ca" 16 -> return "ru" 17 -> return "hi" 18 -> return "nl" 19 -> return "sk" 20 -> return "ja" 21 -> return "el" 22 -> return "eu" 23 -> return "sv" 24 -> return "ko" 25 -> return "ar" } return null } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/MediaUploadHelper.kt ================================================ @file:JvmName("MediaUploadHelper") package awais.instagrabber.utils import awais.instagrabber.models.UploadPhotoOptions import awais.instagrabber.models.UploadVideoOptions import awais.instagrabber.models.enums.MediaItemType import org.json.JSONObject import java.time.Instant import java.util.* import kotlin.random.Random private const val LOWER = 1000000000L private const val UPPER = 9999999999L private fun createPhotoRuploadParams(options: UploadPhotoOptions): Map { val imageCompression = mapOf( "lib_name" to "moz", "lib_version" to "3.1.m", "quality" to "80", ) return listOfNotNull( "retry_context" to retryContextString, "media_type" to "1", "upload_id" to (options.uploadId ?: ""), "xsharing_user_ids" to "[]", "image_compression" to JSONObject(imageCompression).toString(), if (options.isSideCar) "is_sidecar" to "1" else null, ).toMap() } private fun createVideoRuploadParams(options: UploadVideoOptions): Map = listOfNotNull( "retry_context" to retryContextString, "media_type" to "2", "xsharing_user_ids" to "[]", "upload_id" to options.uploadId, "upload_media_width" to options.width.toString(), "upload_media_height" to options.height.toString(), "upload_media_duration_ms" to options.duration.toString(), if (options.isSideCar) "is_sidecar" to "1" else null, if (options.forAlbum) "for_album" to "1" else null, if (options.isDirect) "direct_v2" to "1" else null, *(if (options.isForDirectStory) arrayOf( "for_direct_story" to "1", "content_tags" to "" ) else emptyArray()), if (options.isIgtvVideo) "is_igtv_video" to "1" else null, if (options.isDirectVoice) "is_direct_voice" to "1" else null, ).toMap() val retryContextString: String get() { return JSONObject( mapOf( "num_step_auto_retry" to 0, "num_reupload" to 0, "num_step_manual_retry" to 0, ) ).toString() } fun createUploadPhotoOptions(byteLength: Long): UploadPhotoOptions { val uploadId = generateUploadId() return UploadPhotoOptions( uploadId, generateName(uploadId), byteLength, ) } fun createUploadDmVideoOptions( byteLength: Long, duration: Long, width: Int, height: Int ): UploadVideoOptions { val uploadId = generateUploadId() return UploadVideoOptions( uploadId, generateName(uploadId), byteLength, duration, width, height, isDirect = true, mediaType = MediaItemType.MEDIA_TYPE_VIDEO, ) } fun createUploadDmVoiceOptions( byteLength: Long, duration: Long ): UploadVideoOptions { val uploadId = generateUploadId() return UploadVideoOptions( uploadId, generateName(uploadId), byteLength, duration, isDirectVoice = true, mediaType = MediaItemType.MEDIA_TYPE_VOICE, ) } fun generateUploadId(): String { return Instant.now().epochSecond.toString() } fun generateName(uploadId: String): String { val random = Random.nextLong(LOWER, UPPER + 1) return "${uploadId}_0_$random" } fun getUploadPhotoHeaders(options: UploadPhotoOptions): Map { val waterfallId = options.waterfallId ?: UUID.randomUUID().toString() val contentLength = options.byteLength.toString() return mapOf( "X_FB_PHOTO_WATERFALL_ID" to waterfallId, "X-Entity-Type" to "image/jpeg", "Offset" to "0", "X-Instagram-Rupload-Params" to JSONObject(createPhotoRuploadParams(options)).toString(), "X-Entity-Name" to options.name, "X-Entity-Length" to contentLength, "Content-Type" to "application/octet-stream", "Content-Length" to contentLength, "Accept-Encoding" to "gzip", ) } fun getUploadVideoHeaders(options: UploadVideoOptions): Map { val ruploadParams = createVideoRuploadParams(options) val waterfallId = options.waterfallId ?: UUID.randomUUID().toString() val contentLength = options.byteLength.toString() return getBaseUploadVideoHeaders(ruploadParams) + mapOf( "X_FB_PHOTO_WATERFALL_ID" to waterfallId, "X-Entity-Type" to "video/mp4", "Offset" to (if (options.offset > 0) options.offset else 0).toString(), "X-Entity-Name" to options.name, "X-Entity-Length" to contentLength, "Content-Type" to "application/octet-stream", "Content-Length" to contentLength, ) } private fun getBaseUploadVideoHeaders(ruploadParams: Map): Map { return mapOf( "X-IG-Connection-Type" to "WIFI", "X-IG-Capabilities" to "3brTvwE=", "Accept-Encoding" to "gzip", "X-Instagram-Rupload-Params" to JSONObject(ruploadParams).toString() ) } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/MediaUploader.kt ================================================ package awais.instagrabber.utils import android.content.ContentResolver import android.graphics.Bitmap import android.net.Uri import androidx.documentfile.provider.DocumentFile import awais.instagrabber.models.UploadVideoOptions import awais.instagrabber.webservices.interceptors.AddCookiesInterceptor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.* import okio.BufferedSink import okio.Okio import org.json.JSONObject import ru.gildor.coroutines.okhttp.await import java.io.IOException import java.io.InputStream object MediaUploader { private const val HOST = "https://i.instagram.com" private val octetStreamMediaType: MediaType = requireNotNull(MediaType.parse("application/octet-stream")) { "No media type found for application/octet-stream" } suspend fun uploadPhoto( uri: Uri, contentResolver: ContentResolver, ): MediaUploadResponse = withContext(Dispatchers.IO) { val bitmapResult = BitmapUtils.loadBitmap(contentResolver, uri, 1000f, false) val bitmap = bitmapResult?.bitmap ?: throw IOException("bitmap is null") uploadPhoto(contentResolver, bitmap) } @Suppress("BlockingMethodInNonBlockingContext") private suspend fun uploadPhoto( contentResolver: ContentResolver, bitmap: Bitmap, ): MediaUploadResponse = withContext(Dispatchers.IO) { val file: DocumentFile? = BitmapUtils.convertToJpegAndSaveToFile(contentResolver, bitmap, null) val byteLength: Long = file!!.length() val options = createUploadPhotoOptions(byteLength) val headers = getUploadPhotoHeaders(options) val url = HOST + "/rupload_igphoto/" + options.name + "/" try { contentResolver.openInputStream(file.uri).use { input -> upload(input!!, url, headers) } } finally { file.delete() } } @JvmStatic @Suppress("BlockingMethodInNonBlockingContext") // See https://youtrack.jetbrains.com/issue/KTIJ-838 suspend fun uploadVideo( uri: Uri, contentResolver: ContentResolver, options: UploadVideoOptions, ): MediaUploadResponse = withContext(Dispatchers.IO) { val headers = getUploadVideoHeaders(options) val url = HOST + "/rupload_igvideo/" + options.name + "/" contentResolver.openInputStream(uri).use { input -> if (input == null) { // listener.onFailure(RuntimeException("InputStream was null")) throw IllegalStateException("InputStream was null") } upload(input, url, headers) } } @Throws(IOException::class) private suspend fun upload( input: InputStream, url: String, headers: Map, ): MediaUploadResponse { try { val client = OkHttpClient.Builder() // .addInterceptor(new LoggingInterceptor()) .addInterceptor(AddCookiesInterceptor()) .followRedirects(false) .followSslRedirects(false) .build() val request = Request.Builder() .headers(Headers.of(headers)) .url(url) .post(create(octetStreamMediaType, input)) .build() return withContext(Dispatchers.IO) { val response = client.newCall(request).await() val body = response.body() @Suppress("BlockingMethodInNonBlockingContext") // Blocked by https://github.com/square/okio/issues/501 MediaUploadResponse(response.code(), if (body != null) JSONObject(body.string()) else null) } } catch (e: Exception) { // rethrow for proper stacktrace. See https://github.com/gildor/kotlin-coroutines-okhttp/tree/master#wrap-exception-manually throw IOException(e) } } private fun create(mediaType: MediaType, inputStream: InputStream): RequestBody = object : RequestBody() { override fun contentType(): MediaType { return mediaType } override fun contentLength(): Long { return try { inputStream.available().toLong() } catch (e: IOException) { 0 } } @Throws(IOException::class) @Suppress("DEPRECATION_ERROR") override fun writeTo(sink: BufferedSink) { Okio.source(inputStream).use { sink.writeAll(it) } } } data class MediaUploadResponse(val responseCode: Int, val response: JSONObject?) } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/MediaUtils.java ================================================ package awais.instagrabber.utils; import android.content.ContentResolver; import android.database.Cursor; import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.io.FileDescriptor; public final class MediaUtils { private static final String TAG = MediaUtils.class.getSimpleName(); public static void getVideoInfo(@NonNull final ContentResolver contentResolver, @NonNull final Uri uri, @NonNull final OnInfoLoadListener listener) { getInfo(contentResolver, uri, listener, true); } public static void getVoiceInfo(@NonNull final ContentResolver contentResolver, @NonNull final Uri uri, @NonNull final OnInfoLoadListener listener) { getInfo(contentResolver, uri, listener, false); } private static void getInfo(@NonNull final ContentResolver contentResolver, @NonNull final Uri uri, @NonNull final OnInfoLoadListener listener, @NonNull final Boolean isVideo) { AppExecutors.INSTANCE.getTasksThread().submit(() -> { try (ParcelFileDescriptor parcelFileDescriptor = contentResolver.openFileDescriptor(uri, "r")) { if (parcelFileDescriptor == null) { listener.onLoad(null); return; } final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); mediaMetadataRetriever.setDataSource(fileDescriptor); String duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); if (TextUtils.isEmpty(duration)) duration = "0"; if (isVideo) { String width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH); if (TextUtils.isEmpty(width)) width = "1"; String height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT); if (TextUtils.isEmpty(height)) height = "1"; final Cursor cursor = contentResolver.query(uri, new String[]{MediaStore.Video.Media.SIZE}, null, null, null); cursor.moveToFirst(); final long fileSize = cursor.getLong(0); cursor.close(); listener.onLoad(new VideoInfo( Long.parseLong(duration), Integer.valueOf(width), Integer.valueOf(height), fileSize )); return; } listener.onLoad(new VideoInfo( Long.parseLong(duration), 0, 0, 0 )); } catch (Exception e) { Log.e(TAG, "getInfo: ", e); listener.onFailure(e); } }); } public static class VideoInfo { public long duration; public int width; public int height; public long size; public VideoInfo(final long duration, final int width, final int height, final long size) { this.duration = duration; this.width = width; this.height = height; this.size = size; } } public interface OnInfoLoadListener { void onLoad(@Nullable T info); void onFailure(Throwable t); } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/NavigationExtensions.java ================================================ // package awais.instagrabber.utils; // // import android.annotation.SuppressLint; // import android.content.Intent; // import android.util.Log; // import android.util.SparseArray; // // import androidx.annotation.NonNull; // import androidx.fragment.app.Fragment; // import androidx.fragment.app.FragmentManager; // import androidx.fragment.app.FragmentTransaction; // import androidx.lifecycle.LiveData; // import androidx.lifecycle.MutableLiveData; // import androidx.navigation.NavController; // import androidx.navigation.NavDestination; // import androidx.navigation.NavGraph; // import androidx.navigation.fragment.NavHostFragment; // // import com.google.android.material.bottomnavigation.BottomNavigationView; // // import java.util.List; // // import awais.instagrabber.R; // import awais.instagrabber.customviews.NavHostFragmentWithDefaultAnimations; // import awais.instagrabber.fragments.main.FeedFragment; // // /** // * This is a Java rewrite of NavigationExtensions // * from architecture-components-samples. Some modifications have been done, check git history. // */ // public class NavigationExtensions { // private static final String TAG = NavigationExtensions.class.getSimpleName(); // private static String selectedItemTag; // private static boolean isOnFirstFragment; // // @NonNull // public static LiveData setupWithNavController(@NonNull final BottomNavigationView bottomNavigationView, // @NonNull List navGraphIds, // @NonNull final FragmentManager fragmentManager, // final int containerId, // @NonNull Intent intent, // final int firstFragmentGraphIndex) { // final SparseArray graphIdToTagMap = new SparseArray<>(); // final MutableLiveData selectedNavController = new MutableLiveData<>(); // int firstFragmentGraphId = 0; // for (int i = 0; i < navGraphIds.size(); i++) { // final int navGraphId = navGraphIds.get(i); // final String fragmentTag = getFragmentTag(navGraphId); // final NavHostFragment navHostFragment = obtainNavHostFragment(fragmentManager, fragmentTag, navGraphId, containerId); // final NavController navController = navHostFragment.getNavController(); // final int graphId = navController.getGraph().getId(); // if (i == firstFragmentGraphIndex) { // firstFragmentGraphId = graphId; // } // graphIdToTagMap.put(graphId, fragmentTag); // if (bottomNavigationView.getSelectedItemId() == graphId) { // selectedNavController.setValue(navHostFragment.getNavController()); // attachNavHostFragment(fragmentManager, navHostFragment, i == firstFragmentGraphIndex); // } else { // detachNavHostFragment(fragmentManager, navHostFragment); // } // } // selectedItemTag = graphIdToTagMap.get(bottomNavigationView.getSelectedItemId()); // final String firstFragmentTag = graphIdToTagMap.get(firstFragmentGraphId); // isOnFirstFragment = selectedItemTag != null && selectedItemTag.equals(firstFragmentTag); // bottomNavigationView.setOnItemSelectedListener(item -> { // if (fragmentManager.isStateSaved()) { // return false; // } // String newlySelectedItemTag = graphIdToTagMap.get(item.getItemId()); // String tag = selectedItemTag; // if (tag != null && !tag.equals(newlySelectedItemTag)) { // fragmentManager.popBackStack(firstFragmentTag, FragmentManager.POP_BACK_STACK_INCLUSIVE); // Fragment fragment = fragmentManager.findFragmentByTag(newlySelectedItemTag); // if (fragment == null) { // return false; // // throw new RuntimeException("null cannot be cast to non-null NavHostFragment"); // } // final NavHostFragment selectedFragment = (NavHostFragment) fragment; // if (firstFragmentTag != null && !firstFragmentTag.equals(newlySelectedItemTag)) { // FragmentTransaction fragmentTransaction = fragmentManager // .beginTransaction() // .setCustomAnimations( // R.anim.nav_default_enter_anim, // R.anim.nav_default_exit_anim, // R.anim.nav_default_pop_enter_anim, // R.anim.nav_default_pop_exit_anim // ) // .attach(selectedFragment) // .setPrimaryNavigationFragment(selectedFragment); // for (int i = 0; i < graphIdToTagMap.size(); i++) { // final int key = graphIdToTagMap.keyAt(i); // final String fragmentTagForId = graphIdToTagMap.get(key); // if (!fragmentTagForId.equals(newlySelectedItemTag)) { // final Fragment fragmentByTag = fragmentManager.findFragmentByTag(firstFragmentTag); // if (fragmentByTag == null) { // continue; // } // fragmentTransaction.detach(fragmentByTag); // } // } // fragmentTransaction.addToBackStack(firstFragmentTag) // .setReorderingAllowed(true) // .commit(); // } // selectedItemTag = newlySelectedItemTag; // isOnFirstFragment = selectedItemTag.equals(firstFragmentTag); // selectedNavController.setValue(selectedFragment.getNavController()); // return true; // } // return false; // }); // setupItemReselected(bottomNavigationView, graphIdToTagMap, fragmentManager); // setupDeepLinks(bottomNavigationView, navGraphIds, fragmentManager, containerId, intent); // final int finalFirstFragmentGraphId = firstFragmentGraphId; // fragmentManager.addOnBackStackChangedListener(() -> { // if (!isOnFirstFragment) { // if (firstFragmentTag == null) { // return; // } // if (!isOnBackStack(fragmentManager, firstFragmentTag)) { // bottomNavigationView.setSelectedItemId(finalFirstFragmentGraphId); // } // } // // final NavController navController = selectedNavController.getValue(); // if (navController != null && navController.getCurrentDestination() == null) { // final NavGraph navControllerGraph = navController.getGraph(); // navController.navigate(navControllerGraph.getId()); // } // }); // return selectedNavController; // } // // private static NavHostFragment obtainNavHostFragment(final FragmentManager fragmentManager, // final String fragmentTag, // final int navGraphId, // final int containerId) { // final NavHostFragment existingFragment = (NavHostFragment) fragmentManager.findFragmentByTag(fragmentTag); // if (existingFragment != null) { // return existingFragment; // } // final NavHostFragment navHostFragment = NavHostFragmentWithDefaultAnimations.create(navGraphId); // fragmentManager.beginTransaction() // .setReorderingAllowed(true) // .add(containerId, navHostFragment, fragmentTag) // .commitNow(); // return navHostFragment; // } // // private static void attachNavHostFragment(final FragmentManager fragmentManager, // final NavHostFragment navHostFragment, // final boolean isPrimaryNavFragment) { // final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction() // .attach(navHostFragment); // if (isPrimaryNavFragment) { // fragmentTransaction.setPrimaryNavigationFragment(navHostFragment); // } // fragmentTransaction.commitNow(); // } // // private static void detachNavHostFragment(final FragmentManager fragmentManager, final NavHostFragment navHostFragment) { // fragmentManager.beginTransaction() // .detach(navHostFragment) // .commitNow(); // } // // @SuppressLint("RestrictedApi") // private static void setupItemReselected(final BottomNavigationView bottomNavigationView, // final SparseArray graphIdToTagMap, // final FragmentManager fragmentManager) { // bottomNavigationView.setOnItemReselectedListener(item -> { // final String newlySelectedItemTag = graphIdToTagMap.get(item.getItemId()); // final Fragment fragmentByTag = fragmentManager.findFragmentByTag(newlySelectedItemTag); // if (fragmentByTag == null) { // return; // } // final NavHostFragment selectedFragment = (NavHostFragment) fragmentByTag; // final NavController navController = selectedFragment.getNavController(); // final NavGraph navControllerGraph = navController.getGraph(); // final NavDestination currentDestination = navController.getCurrentDestination(); // final int startDestination = navControllerGraph.getStartDestination(); // int backStackSize = navController.getBackStack().size(); // if (currentDestination != null && backStackSize == 2 && currentDestination.getId() == startDestination) { // // scroll to top // final List fragments = selectedFragment.getChildFragmentManager().getFragments(); // if (fragments.isEmpty()) return; // final Fragment fragment = fragments.get(0); // if (fragment instanceof FeedFragment) { // ((FeedFragment) fragment).scrollToTop(); // } // return; // } // final boolean popped = navController.popBackStack(startDestination, false); // backStackSize = navController.getBackStack().size(); // if (!popped || backStackSize > 2) { // try { // // try loop pop // do { // navController.popBackStack(); // backStackSize = navController.getBackStack().size(); // } while (backStackSize > 2); // } catch (Exception e) { // Log.e(TAG, "setupItemReselected: ", e); // } // } // }); // } // // private static void setupDeepLinks(final BottomNavigationView bottomNavigationView, // final List navGraphIds, // final FragmentManager fragmentManager, // final int containerId, // final Intent intent) { // for (int i = 0; i < navGraphIds.size(); i++) { // final int navGraphId = navGraphIds.get(i); // final String fragmentTag = getFragmentTag(navGraphId); // final NavHostFragment navHostFragment = obtainNavHostFragment(fragmentManager, fragmentTag, navGraphId, containerId); // if (navHostFragment.getNavController().handleDeepLink(intent)) { // final int selectedItemId = bottomNavigationView.getSelectedItemId(); // NavController navController = navHostFragment.getNavController(); // NavGraph graph = navController.getGraph(); // if (selectedItemId != graph.getId()) { // navController = navHostFragment.getNavController(); // graph = navController.getGraph(); // bottomNavigationView.setSelectedItemId(graph.getId()); // } // } // } // } // // private static boolean isOnBackStack(final FragmentManager fragmentManager, final String backStackName) { // int backStackCount = fragmentManager.getBackStackEntryCount(); // for (int i = 0; i < backStackCount; i++) { // final FragmentManager.BackStackEntry backStackEntry = fragmentManager.getBackStackEntryAt(i); // final String name = backStackEntry.getName(); // if (name != null && name.equals(backStackName)) { // return true; // } // } // return false; // } // // private static String getFragmentTag(final int index) { // return "bottomNavigation#" + index; // } // } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/NavigationHelper.kt ================================================ package awais.instagrabber.utils import android.content.Context import android.content.res.Resources import androidx.annotation.ArrayRes import awais.instagrabber.R import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.models.Tab var tabOrderString: String? = null private val NON_REMOVABLE_NAV_ROOT_IDS: List = listOf(R.id.profile_nav_graph, R.id.more_nav_graph) fun getLoggedInNavTabs(context: Context): Pair, List> { val navRootIds = getArrayResIds(context.resources, R.array.logged_in_nav_root_ids) return getTabs(context, navRootIds) } fun getAnonNavTabs(context: Context): List { val navRootIds = getArrayResIds(context.resources, R.array.anon_nav_root_ids) val (tabs, _) = getTabs(context, navRootIds, true) return tabs } private fun getTabs( context: Context, navRootIds: IntArray, isAnon: Boolean = false, ): Pair, MutableList> { val navGraphNames = getResIdsForNavRootIds(navRootIds, ::getNavGraphNameForNavRootId) val navGraphResIds = getResIdsForNavRootIds(navRootIds, ::getNavGraphResIdForNavRootId) val titleArray = getResIdsForNavRootIds(navRootIds, ::getTitleResIdForNavRootId) val iconIds = getResIdsForNavRootIds(navRootIds, ::getIconResIdForNavRootId) val startDestFragIds = getResIdsForNavRootIds(navRootIds, ::getStartDestFragIdForNavRootId) val (orderedGraphNames, orderedNavRootIds) = if (isAnon) navGraphNames to navRootIds.toList() else getOrderedNavRootIdsFromPref(navGraphNames) val tabs = mutableListOf() val otherTabs = mutableListOf() // Will contain tabs not in current list for (i in navRootIds.indices) { val navRootId = navRootIds[i] val tab = Tab( iconIds[i], context.getString(titleArray[i]), if (isAnon) false else !NON_REMOVABLE_NAV_ROOT_IDS.contains(navRootId), navGraphResIds[i], navRootId, startDestFragIds[i] ) if (!isAnon && !orderedGraphNames.contains(navGraphNames[i])) { otherTabs.add(tab) continue } tabs.add(tab) } val associateBy = tabs.associateBy { it.navigationRootId } val orderedTabs = orderedNavRootIds.mapNotNull { associateBy[it] } return orderedTabs to otherTabs } private fun getArrayResIds(resources: Resources, @ArrayRes arrayRes: Int): IntArray { val typedArray = resources.obtainTypedArray(arrayRes) val length = typedArray.length() val navRootIds = IntArray(length) for (i in 0 until length) { val resourceId = typedArray.getResourceId(i, 0) if (resourceId == 0) continue navRootIds[i] = resourceId } typedArray.recycle() return navRootIds } private fun getResIdsForNavRootIds(navRootIds: IntArray, resMapper: Function1): List = navRootIds .asSequence() .filterNot { it == 0 } .map(resMapper) .filterNot { it == 0 } .toList() private fun getTitleResIdForNavRootId(id: Int): Int = when (id) { R.id.direct_messages_nav_graph -> R.string.title_dm R.id.feed_nav_graph -> R.string.feed R.id.profile_nav_graph -> R.string.profile R.id.discover_nav_graph -> R.string.title_discover R.id.more_nav_graph -> R.string.more R.id.favorites_nav_graph -> R.string.title_favorites R.id.notification_viewer_nav_graph -> R.string.title_notifications else -> 0 } private fun getIconResIdForNavRootId(id: Int): Int = when (id) { R.id.direct_messages_nav_graph -> R.drawable.ic_message_24 R.id.feed_nav_graph -> R.drawable.ic_home_24 R.id.profile_nav_graph -> R.drawable.ic_person_24 R.id.discover_nav_graph -> R.drawable.ic_explore_24 R.id.more_nav_graph -> R.drawable.ic_more_horiz_24 R.id.favorites_nav_graph -> R.drawable.ic_star_24 R.id.notification_viewer_nav_graph -> R.drawable.ic_not_liked else -> 0 } private fun getStartDestFragIdForNavRootId(id: Int): Int = when (id) { R.id.direct_messages_nav_graph -> R.id.directMessagesInboxFragment R.id.feed_nav_graph -> R.id.feedFragment R.id.profile_nav_graph -> R.id.profileFragment R.id.discover_nav_graph -> R.id.discoverFragment R.id.more_nav_graph -> R.id.morePreferencesFragment R.id.favorites_nav_graph -> R.id.favoritesFragment R.id.notification_viewer_nav_graph -> R.id.notificationsViewer else -> 0 } fun getNavGraphNameForNavRootId(id: Int): String = when (id) { R.id.direct_messages_nav_graph -> "direct_messages_nav_graph" R.id.feed_nav_graph -> "feed_nav_graph" R.id.profile_nav_graph -> "profile_nav_graph" R.id.discover_nav_graph -> "discover_nav_graph" R.id.more_nav_graph -> "more_nav_graph" R.id.favorites_nav_graph -> "favorites_nav_graph" R.id.notification_viewer_nav_graph -> "notification_viewer_nav_graph" else -> "" } fun getNavGraphResIdForNavRootId(id: Int): Int = when (id) { R.id.direct_messages_nav_graph -> R.navigation.direct_messages_nav_graph R.id.feed_nav_graph -> R.navigation.feed_nav_graph R.id.profile_nav_graph -> R.navigation.profile_nav_graph R.id.discover_nav_graph -> R.navigation.discover_nav_graph R.id.more_nav_graph -> R.navigation.more_nav_graph R.id.favorites_nav_graph -> R.navigation.favorites_nav_graph R.id.notification_viewer_nav_graph -> R.navigation.notification_viewer_nav_graph else -> 0 } private fun getNavRootIdForGraphName(navGraphName: String): Int = when (navGraphName) { "direct_messages_nav_graph" -> R.id.direct_messages_nav_graph "feed_nav_graph" -> R.id.feed_nav_graph "profile_nav_graph" -> R.id.profile_nav_graph "discover_nav_graph" -> R.id.discover_nav_graph "more_nav_graph" -> R.id.more_nav_graph "favorites_nav_graph" -> R.id.favorites_nav_graph "notification_viewer_nav_graph" -> R.id.notification_viewer_nav_graph else -> 0 } private fun getOrderedNavRootIdsFromPref(navGraphNames: List): Pair, List> { tabOrderString = Utils.settingsHelper.getString(PreferenceKeys.PREF_TAB_ORDER) if (tabOrderString.isNullOrBlank()) { // Use top 5 entries for default list val top5navGraphNames: List = navGraphNames.subList(0, 5) val newOrderString = top5navGraphNames.joinToString(",") Utils.settingsHelper.putString(PreferenceKeys.PREF_TAB_ORDER, newOrderString) tabOrderString = newOrderString return top5navGraphNames to top5navGraphNames.map(::getNavRootIdForGraphName) } val orderString = tabOrderString ?: return navGraphNames to navGraphNames.subList(0, 5).map(::getNavRootIdForGraphName) // Make sure that the list from preference does not contain any invalid values val orderGraphNames = orderString .split(",") .asSequence() .filter(String::isNotBlank) .filter(navGraphNames::contains) .toList() val graphNames = if (orderGraphNames.isEmpty()) { // Use top 5 entries for default list navGraphNames.subList(0, 5) } else orderGraphNames return graphNames to graphNames.map(::getNavRootIdForGraphName) } fun isNavRootInCurrentTabs(navRootString: String?): Boolean { val navRoot = navRootString ?: return false return tabOrderString?.contains(navRoot) ?: false } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/NetworkUtils.java ================================================ package awais.instagrabber.utils; import androidx.annotation.NonNull; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.util.Map; import java.util.Set; public final class NetworkUtils { @NonNull public static String readFromConnection(@NonNull final HttpURLConnection conn) throws Exception { final InputStream inputStream = conn.getInputStream(); return readFromInputStream(inputStream); } @NonNull public static String readFromInputStream(final InputStream inputStream) throws IOException { final StringBuilder sb = new StringBuilder(); try (final BufferedReader br = new BufferedReader(new InputStreamReader(inputStream))) { String line; while ((line = br.readLine()) != null) sb.append(line).append('\n'); } return sb.toString(); } public static void setConnectionHeaders(final HttpURLConnection connection, final Map headers) { if (connection == null || headers == null || headers.isEmpty()) { return; } for (Map.Entry header : headers.entrySet()) { connection.setRequestProperty(header.getKey(), header.getValue()); } } public static String getQueryString(final Map queryParamsMap) { if (queryParamsMap == null || queryParamsMap.isEmpty()) { return ""; } final Set> params = queryParamsMap.entrySet(); final StringBuilder builder = new StringBuilder(); for (final Map.Entry param : params) { if (TextUtils.isEmpty(param.getKey())) { continue; } if (builder.length() != 0) { builder.append("&"); } builder.append(param.getKey()); builder.append("="); builder.append(param.getValue() != null ? param.getValue() : ""); } return builder.toString(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/NullSafePair.kt ================================================ package awais.instagrabber.utils /* * Copyright (C) 2009 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Container to ease passing around a tuple of two objects. This object provides a sensible * implementation of equals(), returning true if equals() is true on each of the contained * objects. */ /** * Constructor for a Pair. * * @param first the first object in the Pair * @param second the second object in the pair */ data class NullSafePair(@JvmField val first: F, @JvmField val second: S) { companion object { /** * Convenience method for creating an appropriately typed pair. * * @param a the first object in the Pair * @param b the second object in the pair * @return a Pair that is templatized with the types of a and b */ fun create(a: A, b: B): NullSafePair { return NullSafePair(a, b) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/NumberUtils.kt ================================================ @file:JvmName("NumberUtils") package awais.instagrabber.utils import java.util.* import kotlin.math.ln import kotlin.math.pow fun getResultingHeight(requiredWidth: Int, height: Int, width: Int): Int { return requiredWidth * height / width } fun getResultingWidth(requiredHeight: Int, height: Int, width: Int): Int { return requiredHeight * width / height } // TODO Replace all usages with kotlin Random.nextLong() once converted to kotlin fun random(origin: Long, bound: Long): Long { val random = Random() var r = random.nextLong() val n = bound - origin val m = n - 1 when { n and m == 0L -> r = (r and m) + origin // power of two n > 0L -> { // reject over-represented candidates var u = r ushr 1 // ensure non-negative while (u + m - u % n.also { r = it } < 0L) { // rejection check // retry u = random.nextLong() ushr 1 } r += origin } else -> { // range not representable as long while (r < origin || r >= bound) r = random.nextLong() } } return r } fun calculateWidthHeight(height: Int, width: Int, maxHeight: Int, maxWidth: Int): NullSafePair { if (width > maxWidth) { var tempHeight = getResultingHeight(maxWidth, height, width) var tempWidth = maxWidth if (tempHeight > maxHeight) { tempWidth = getResultingWidth(maxHeight, tempHeight, tempWidth) tempHeight = maxHeight } return NullSafePair(tempWidth, tempHeight) } if (height < maxHeight && width < maxWidth || height > maxHeight) { var tempWidth = getResultingWidth(maxHeight, height, width) var tempHeight = maxHeight if (tempWidth > maxWidth) { tempHeight = getResultingHeight(maxWidth, tempHeight, tempWidth) tempWidth = maxWidth } return NullSafePair(tempWidth, tempHeight) } return NullSafePair(width, height) } fun roundFloat2Decimals(value: Float): Float { return ((value + (if (value >= 0) 1 else -1) * 0.005f) * 100).toInt() / 100f } fun abbreviate(number: Long, options: AbbreviateOptions? = null): String { // adapted from https://stackoverflow.com/a/9769590/1436766 var threshold = 1000 var addSpace = false if (options != null) { threshold = options.threshold addSpace = options.addSpaceBeforePrefix } if (number < threshold) return "" + number val exp = (ln(number.toDouble()) / ln(threshold.toDouble())).toInt() return String.format( Locale.US, "%.1f%s%c", number / threshold.toDouble().pow(exp.toDouble()), if (addSpace) " " else "", "kMGTPE"[exp - 1] ) } data class AbbreviateOptions(val threshold: Int = 1000, val addSpaceBeforePrefix: Boolean = false) ================================================ FILE: app/src/main/java/awais/instagrabber/utils/PasswordUtils.kt ================================================ package awais.instagrabber.utils import android.util.Base64 import java.security.GeneralSecurityException import java.security.InvalidAlgorithmParameterException import java.security.InvalidKeyException import java.security.NoSuchAlgorithmException import javax.crypto.BadPaddingException import javax.crypto.Cipher import javax.crypto.IllegalBlockSizeException import javax.crypto.NoSuchPaddingException import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec object PasswordUtils { private const val cipherAlgo = "AES" private const val cipherTran = "AES/CBC/PKCS5Padding" @JvmStatic @Throws(Exception::class) fun dec(encrypted: String?, keyValue: ByteArray?): ByteArray { return try { val cipher = Cipher.getInstance(cipherTran) val secretKey = SecretKeySpec(keyValue, cipherAlgo) cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) cipher.doFinal(Base64.decode(encrypted, Base64.DEFAULT or Base64.NO_PADDING or Base64.NO_WRAP)) } catch (e: NoSuchAlgorithmException) { throw IncorrectPasswordException(e) } catch (e: NoSuchPaddingException) { throw IncorrectPasswordException(e) } catch (e: InvalidAlgorithmParameterException) { throw IncorrectPasswordException(e) } catch (e: InvalidKeyException) { throw IncorrectPasswordException(e) } catch (e: BadPaddingException) { throw IncorrectPasswordException(e) } catch (e: IllegalBlockSizeException) { throw IncorrectPasswordException(e) } } @JvmStatic @Throws(Exception::class) fun enc(str: String, keyValue: ByteArray?): ByteArray { val cipher = Cipher.getInstance(cipherTran) val secretKey = SecretKeySpec(keyValue, cipherAlgo) cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) val bytes = cipher.doFinal(str.toByteArray()) return Base64.encode(bytes, Base64.DEFAULT or Base64.NO_PADDING or Base64.NO_WRAP) } class IncorrectPasswordException(e: GeneralSecurityException?) : Exception(e) } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/PermissionUtils.kt ================================================ package awais.instagrabber.utils import android.Manifest.permission import android.content.Context import androidx.core.content.PermissionChecker import awais.instagrabber.utils.PermissionUtils import androidx.core.content.ContextCompat import android.content.pm.PackageManager import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.fragment.app.Fragment object PermissionUtils { val AUDIO_RECORD_PERMS = arrayOf(permission.RECORD_AUDIO) val ATTACH_MEDIA_PERMS = arrayOf(permission.READ_EXTERNAL_STORAGE) val CAMERA_PERMS = arrayOf(permission.CAMERA) @JvmStatic fun hasAudioRecordPerms(context: Context): Boolean { return PermissionChecker.checkSelfPermission( context, permission.RECORD_AUDIO ) == PermissionChecker.PERMISSION_GRANTED } @JvmStatic fun requestAudioRecordPerms(fragment: Fragment, requestCode: Int) { fragment.requestPermissions(AUDIO_RECORD_PERMS, requestCode) } @JvmStatic fun hasAttachMediaPerms(context: Context): Boolean { return PermissionChecker.checkSelfPermission( context, permission.READ_EXTERNAL_STORAGE ) == PermissionChecker.PERMISSION_GRANTED } @JvmStatic fun requestAttachMediaPerms(fragment: Fragment, requestCode: Int) { fragment.requestPermissions(ATTACH_MEDIA_PERMS, requestCode) } fun hasCameraPerms(context: Context?): Boolean { return ContextCompat.checkSelfPermission( context!!, permission.CAMERA ) == PackageManager.PERMISSION_GRANTED } fun requestCameraPerms(activity: AppCompatActivity?, requestCode: Int) { ActivityCompat.requestPermissions(activity!!, CAMERA_PERMS, requestCode) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/ProcessPhoenix.java ================================================ /* * Copyright (C) 2014 Jake Wharton * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package awais.instagrabber.utils; import android.app.Activity; import android.app.ActivityManager; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.os.Process; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; /** * Process Phoenix facilitates restarting your application process. This should only be used for * things like fundamental state changes in your debug builds (e.g., changing from staging to * production). *

* Trigger process recreation by calling {@link #triggerRebirth} with a {@link Context} instance. */ public final class ProcessPhoenix extends Activity { private static final String KEY_RESTART_INTENTS = "phoenix_restart_intents"; /** * Call to restart the application process using the {@linkplain Intent#CATEGORY_DEFAULT default} * activity as an intent. *

* Behavior of the current process after invoking this method is undefined. */ public static void triggerRebirth(Context context) { triggerRebirth(context, getRestartIntent(context)); } /** * Call to restart the application process using the specified intents. *

* Behavior of the current process after invoking this method is undefined. */ public static void triggerRebirth(Context context, Intent... nextIntents) { Intent intent = new Intent(context, ProcessPhoenix.class); intent.addFlags(FLAG_ACTIVITY_NEW_TASK); // In case we are called with non-Activity context. intent.putParcelableArrayListExtra(KEY_RESTART_INTENTS, new ArrayList<>(Arrays.asList(nextIntents))); context.startActivity(intent); if (context instanceof Activity) { ((Activity) context).finish(); } Runtime.getRuntime().exit(0); // Kill kill kill! } private static Intent getRestartIntent(Context context) { String packageName = context.getPackageName(); Intent defaultIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); if (defaultIntent != null) { defaultIntent.addFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK); return defaultIntent; } throw new IllegalStateException("Unable to determine default activity for " + packageName + ". Does an activity specify the DEFAULT category in its intent filter?"); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ArrayList intents = getIntent().getParcelableArrayListExtra(KEY_RESTART_INTENTS); startActivities(intents.toArray(new Intent[intents.size()])); finish(); Runtime.getRuntime().exit(0); // Kill kill kill! } /** * Checks if the current process is a temporary Phoenix Process. * This can be used to avoid initialisation of unused resources or to prevent running code that * is not multi-process ready. * * @return true if the current process is a temporary Phoenix Process */ public static boolean isPhoenixProcess(Context context) { int currentPid = Process.myPid(); ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); List runningProcesses = manager.getRunningAppProcesses(); if (runningProcesses != null) { for (ActivityManager.RunningAppProcessInfo processInfo : runningProcesses) { if (processInfo.pid == currentPid && processInfo.processName.endsWith(":phoenix")) { return true; } } } return false; } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/RankedRecipientsCache.kt ================================================ package awais.instagrabber.utils import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.repositories.responses.directmessages.RankedRecipientsResponse import java.time.LocalDateTime import java.time.temporal.ChronoUnit object RankedRecipientsCache { private var lastUpdatedOn: LocalDateTime? = null var isUpdateInitiated = false var isFailed = false val rankedRecipients: List get() = response?.rankedRecipients ?: emptyList() var response: RankedRecipientsResponse? = null set(value) { field = value lastUpdatedOn = LocalDateTime.now() } val isExpired: Boolean get() { if (lastUpdatedOn == null || response == null) return true val expiresInSecs = response!!.expires return LocalDateTime.now().isAfter(lastUpdatedOn!!.plus(expiresInSecs, ChronoUnit.SECONDS)) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/ResponseBodyUtils.java ================================================ package awais.instagrabber.utils; import android.net.Uri; import android.util.Log; import androidx.annotation.Nullable; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import awais.instagrabber.models.enums.MediaItemType; import awais.instagrabber.repositories.responses.Caption; import awais.instagrabber.repositories.responses.FriendshipStatus; import awais.instagrabber.repositories.responses.ImageVersions2; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.MediaCandidate; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.stories.StoryMedia; public final class ResponseBodyUtils { private static final String TAG = "ResponseBodyUtils"; // isI: true if the content was requested from i.instagram.com instead of graphql @Nullable public static String getHighQualityPost(final JSONArray resources, final boolean isVideo, final boolean isI, final boolean low) { try { final int resourcesLen = resources.length(); final String[] sources = new String[resourcesLen]; int lastResMain = low ? 1000000 : 0, lastIndexMain = -1; int lastResBase = low ? 1000000 : 0, lastIndexBase = -1; for (int i = 0; i < resourcesLen; ++i) { final JSONObject item = resources.getJSONObject(i); if (item != null && (!isVideo || item.has(Constants.EXTRAS_PROFILE) || isI)) { sources[i] = item.getString(isI ? "url" : "src"); final int currRes = item.getInt(isI ? "width" : "config_width") * item.getInt(isI ? "height" : "config_height"); final String profile = isVideo ? item.optString(Constants.EXTRAS_PROFILE) : null; if (!isVideo || "MAIN".equals(profile)) { if (currRes > lastResMain && !low) { lastResMain = currRes; lastIndexMain = i; } else if (currRes < lastResMain && low) { lastResMain = currRes; lastIndexMain = i; } } else { if (currRes > lastResBase && !low) { lastResBase = currRes; lastIndexBase = i; } else if (currRes < lastResBase && low) { lastResBase = currRes; lastIndexBase = i; } } } } if (lastIndexMain >= 0) return sources[lastIndexMain]; else if (lastIndexBase >= 0) return sources[lastIndexBase]; } catch (final Exception e) { Log.e(TAG, "", e); } return null; } public static String getHighQualityImage(final JSONObject resources) { String src = null; try { if (resources.has("display_resources")) src = getHighQualityPost(resources.getJSONArray("display_resources"), false, false, false); else if (resources.has("image_versions2")) src = getHighQualityPost(resources.getJSONObject("image_versions2").getJSONArray("candidates"), false, true, false); if (src == null) return resources.getString("display_url"); } catch (final Exception e) { Log.e(TAG, "", e); } return src; } // the "user" argument can be null, it's used because instagram redacts user details from responses public static Media parseGraphQLItem(final JSONObject itemJson, final User backup) throws JSONException { if (itemJson == null) { return null; } final JSONObject feedItem = itemJson.has("node") ? itemJson.getJSONObject("node") : itemJson; final String mediaType = feedItem.optString("__typename"); if ("GraphSuggestedUserFeedUnit".equals(mediaType)) return null; final boolean isVideo = feedItem.optBoolean("is_video"); final long videoViews = feedItem.optLong("video_view_count", 0); final String displayUrl = feedItem.optString("display_url"); if (TextUtils.isEmpty(displayUrl)) return null; final String resourceUrl; if (isVideo && feedItem.has("video_url")) { resourceUrl = feedItem.getString("video_url"); } else { resourceUrl = feedItem.has("display_resources") ? ResponseBodyUtils.getHighQualityImage(feedItem) : displayUrl; } JSONObject tempJsonObject = feedItem.optJSONObject("edge_media_preview_comment"); final long commentsCount = tempJsonObject != null ? tempJsonObject.optLong("count") : 0; tempJsonObject = feedItem.optJSONObject("edge_media_preview_like"); final long likesCount = tempJsonObject != null ? tempJsonObject.optLong("count") : 0; tempJsonObject = feedItem.optJSONObject("edge_media_to_caption"); final JSONArray captions = tempJsonObject != null ? tempJsonObject.getJSONArray("edges") : null; String captionText = null; if (captions != null && captions.length() > 0) { if ((tempJsonObject = captions.optJSONObject(0)) != null && (tempJsonObject = tempJsonObject.optJSONObject("node")) != null) { captionText = tempJsonObject.getString("text"); } } final JSONObject locationJson = feedItem.optJSONObject("location"); // Log.d(TAG, "location: " + (location == null ? null : location.toString())); long locationId = 0; String locationName = null; if (locationJson != null) { locationName = locationJson.optString("name"); if (locationJson.has("id")) { locationId = locationJson.optLong("id"); } else if (locationJson.has("pk")) { locationId = locationJson.optLong("pk"); } // Log.d(TAG, "locationId: " + locationId); } int height = 0; int width = 0; final JSONObject dimensions = feedItem.optJSONObject("dimensions"); if (dimensions != null) { height = dimensions.optInt("height"); width = dimensions.optInt("width"); } String thumbnailUrl = null; final List candidates = new ArrayList(); if (feedItem.has("display_resources") || feedItem.has("thumbnail_resources")) { final JSONArray displayResources = feedItem.has("display_resources") ? feedItem.getJSONArray("display_resources") : feedItem.getJSONArray("thumbnail_resources"); for (int i = 0; i < displayResources.length(); i++) { final JSONObject displayResource = displayResources.getJSONObject(i); candidates.add(new MediaCandidate( displayResource.getInt("config_width"), displayResource.getInt("config_height"), displayResource.getString("src") )); } } final ImageVersions2 imageVersions2 = new ImageVersions2(candidates); User user = backup; long userId = -1; if (feedItem.has("owner") && user == null) { final JSONObject owner = feedItem.getJSONObject("owner"); final FriendshipStatus friendshipStatus = new FriendshipStatus( false, false, false, false, false, false, false, false, false, false ); userId = owner.optLong(Constants.EXTRAS_ID, -1); user = new User( userId, owner.optString(Constants.EXTRAS_USERNAME), owner.optString("full_name"), false, owner.optString("profile_pic_url"), owner.optBoolean("is_verified")); } final String id = feedItem.getString(Constants.EXTRAS_ID); MediaCandidate videoVersion = null; if (isVideo) { videoVersion = new MediaCandidate( width, height, resourceUrl ); } final Caption caption = new Caption( userId, captionText != null ? captionText : "" ); final boolean isSlider = "GraphSidecar".equals(mediaType) && feedItem.has("edge_sidecar_to_children"); List childItems = null; if (isSlider) { childItems = new ArrayList<>(); // feedModelBuilder.setItemType(MediaItemType.MEDIA_TYPE_SLIDER); final JSONObject sidecar = feedItem.optJSONObject("edge_sidecar_to_children"); if (sidecar != null) { final JSONArray children = sidecar.optJSONArray("edges"); if (children != null) { // final List sliderItems = getSliderItems(children); // feedModelBuilder.setSliderItems(sliderItems) // .setImageHeight(sliderItems.get(0).getHeight()) // .setImageWidth(sliderItems.get(0).getWidth()); for (int i = 0; i < children.length(); i++) { final JSONObject child = children.optJSONObject(i); if (child == null) continue; final Media media = parseGraphQLItem(child, null); media.setSidecarChild(true); childItems.add(media); } } } } MediaItemType mediaItemType = MediaItemType.MEDIA_TYPE_IMAGE; if (isSlider) { mediaItemType = MediaItemType.MEDIA_TYPE_SLIDER; } else if (isVideo) { mediaItemType = MediaItemType.MEDIA_TYPE_VIDEO; } final Location location = new Location( locationId, locationName, locationName, null, null, -1, -1 ); return new Media( id, id, feedItem.optString(Constants.EXTRAS_SHORTCODE), feedItem.optLong("taken_at_timestamp", -1), user, false, imageVersions2, width, height, mediaItemType.getId(), false, feedItem.optBoolean("comments_disabled"), -1, commentsCount, likesCount, false, false, isVideo ? Collections.singletonList(videoVersion) : null, feedItem.optBoolean("has_audio"), feedItem.optDouble("video_duration"), videoViews, caption, false, null, null, childItems, location, null, false, false, null, null, null ); } public static String getThumbUrl(final Object media) { return getImageCandidate(media, CandidateType.THUMBNAIL); } public static String getImageUrl(final Object media) { return getImageCandidate(media, CandidateType.DOWNLOAD); } private static String getImageCandidate(final Object rawMedia, final CandidateType type) { if (rawMedia == null) return null; final ImageVersions2 imageVersions2; final int originalWidth, originalHeight; if (rawMedia instanceof StoryMedia) { imageVersions2 = ((StoryMedia) rawMedia).getImageVersions2(); originalWidth = ((StoryMedia) rawMedia).getOriginalWidth(); originalHeight = ((StoryMedia) rawMedia).getOriginalHeight(); } else if (rawMedia instanceof Media) { imageVersions2 = ((Media) rawMedia).getImageVersions2(); originalWidth = ((Media) rawMedia).getOriginalWidth(); originalHeight = ((Media) rawMedia).getOriginalHeight(); } else return null; if (imageVersions2 == null) return null; final List candidates = imageVersions2.getCandidates(); if (candidates == null || candidates.isEmpty()) return null; final boolean isSquare = Integer.compare(originalWidth, originalHeight) == 0; final List sortedCandidates = candidates.stream() .sorted((c1, c2) -> Integer.compare(c2.getWidth(), c1.getWidth())) .collect(Collectors.toList()); final List filteredCandidates = sortedCandidates.stream() .filter(c -> c.getWidth() <= originalWidth && c.getWidth() <= type.getValue() && (isSquare || Integer .compare(c.getWidth(), c.getHeight()) != 0) ) .collect(Collectors.toList()); if (filteredCandidates.size() == 0) return sortedCandidates.get(0).getUrl(); final MediaCandidate candidate = filteredCandidates.get(0); if (candidate == null) return null; return candidate.getUrl(); } public static String getThumbVideoUrl(final Media media) { return getVideoCandidate(media, CandidateType.VIDEO_THUMBNAIL); } public static String getVideoUrl(final Object media) { return getVideoCandidate(media, CandidateType.DOWNLOAD); } // TODO: merge with getImageCandidate when Kotlin private static String getVideoCandidate(final Object rawMedia, final CandidateType type) { if (rawMedia == null) return null; final List candidates; final int originalWidth, originalHeight; if (rawMedia instanceof StoryMedia) { candidates = ((StoryMedia) rawMedia).getVideoVersions(); originalWidth = ((StoryMedia) rawMedia).getOriginalWidth(); originalHeight = ((StoryMedia) rawMedia).getOriginalHeight(); } else if (rawMedia instanceof Media) { candidates = ((Media) rawMedia).getVideoVersions(); originalWidth = ((Media) rawMedia).getOriginalWidth(); originalHeight = ((Media) rawMedia).getOriginalHeight(); } else return null; if (candidates == null || candidates.isEmpty()) return null; final boolean isSquare = Integer.compare(originalWidth, originalHeight) == 0; final List sortedCandidates = candidates.stream() .sorted((c1, c2) -> Integer.compare(c2.getWidth(), c1.getWidth())) .collect(Collectors.toList()); final List filteredCandidates = sortedCandidates.stream() .filter(c -> c.getWidth() <= originalWidth && c.getWidth() <= type.getValue() && (isSquare || Integer .compare(c.getWidth(), c.getHeight()) != 0) ) .collect(Collectors.toList()); if (filteredCandidates.size() == 0) return sortedCandidates.get(0).getUrl(); final MediaCandidate candidate = filteredCandidates.get(0); if (candidate == null) return null; return candidate.getUrl(); } private enum CandidateType { VIDEO_THUMBNAIL(700), THUMBNAIL(1000), DOWNLOAD(10000); private final int value; CandidateType(final int value) { this.value = value; } public int getValue() { return value; } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/SerializablePair.kt ================================================ package awais.instagrabber.utils import android.util.Pair import java.io.Serializable /** * Constructor for a Pair. * * @param first the first object in the Pair * @param second the second object in the pair */ data class SerializablePair(@JvmField val first: F, @JvmField val second: S) : Pair(first, second), Serializable ================================================ FILE: app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt ================================================ package awais.instagrabber.utils import android.content.Context import android.content.SharedPreferences import android.os.Build import androidx.annotation.StringDef import androidx.appcompat.app.AppCompatDelegate import awais.instagrabber.fragments.settings.PreferenceKeys import java.util.* class SettingsHelper(context: Context) { private val sharedPreferences: SharedPreferences? = context.getSharedPreferences(Constants.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) fun getString(@StringSettings key: String): String { val stringDefault = getStringDefault(key) return sharedPreferences?.getString( key, stringDefault ) ?: stringDefault } fun getStringSet(@StringSetSettings key: String?): Set { val stringSetDefault: Set = HashSet() return sharedPreferences?.getStringSet( key, stringSetDefault ) ?: stringSetDefault } fun getInteger(@IntegerSettings key: String): Int { val integerDefault = getIntegerDefault(key) return sharedPreferences?.getInt(key, integerDefault) ?: integerDefault } fun getBoolean(@BooleanSettings key: String?): Boolean { return sharedPreferences?.getBoolean(key, false) ?: false } private fun getStringDefault(@StringSettings key: String): String { if (PreferenceKeys.DATE_TIME_FORMAT == key) { return Constants.defaultDateTimeFormat } return if (PreferenceKeys.DATE_TIME_SELECTION == key) "0;3;0" else "" } private fun getIntegerDefault(@IntegerSettings key: String): Int { if (PreferenceKeys.APP_THEME == key) return getThemeCode(true) return if (Constants.PREV_INSTALL_VERSION == key || Constants.APP_UA_CODE == key || Constants.BROWSER_UA_CODE == key) -1 else 0 } fun getThemeCode(fromHelper: Boolean): Int { var themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM if (!fromHelper && sharedPreferences != null) { themeCode = sharedPreferences.getString(PreferenceKeys.APP_THEME, themeCode.toString())?.toInt() ?: 0 when (themeCode) { 1 -> themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY 3 -> themeCode = AppCompatDelegate.MODE_NIGHT_NO 0 -> themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } } if (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && Build.VERSION.SDK_INT < 29) { themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY } return themeCode } fun putString(@StringSettings key: String?, `val`: String?) { sharedPreferences?.edit()?.putString(key, `val`)?.apply() } fun putStringSet(@StringSetSettings key: String?, `val`: Set?) { sharedPreferences?.edit()?.putStringSet(key, `val`)?.apply() } fun putInteger(@IntegerSettings key: String?, `val`: Int) { sharedPreferences?.edit()?.putInt(key, `val`)?.apply() } fun putBoolean(@BooleanSettings key: String?, `val`: Boolean) { sharedPreferences?.edit()?.putBoolean(key, `val`)?.apply() } fun hasPreference(key: String?): Boolean { return sharedPreferences?.contains(key) ?: false } @StringDef( PreferenceKeys.APP_LANGUAGE, PreferenceKeys.APP_THEME, Constants.APP_UA, Constants.BROWSER_UA, Constants.COOKIE, PreferenceKeys.FOLDER_PATH, PreferenceKeys.DATE_TIME_FORMAT, PreferenceKeys.DATE_TIME_SELECTION, PreferenceKeys.CUSTOM_DATE_TIME_FORMAT, Constants.DEVICE_UUID, Constants.SKIPPED_VERSION, Constants.DEFAULT_TAB, Constants.PREF_DARK_THEME, Constants.PREF_LIGHT_THEME, Constants.PREF_POSTS_LAYOUT, Constants.PREF_PROFILE_POSTS_LAYOUT, Constants.PREF_TOPIC_POSTS_LAYOUT, Constants.PREF_HASHTAG_POSTS_LAYOUT, Constants.PREF_LOCATION_POSTS_LAYOUT, Constants.PREF_LIKED_POSTS_LAYOUT, Constants.PREF_TAGGED_POSTS_LAYOUT, Constants.PREF_SAVED_POSTS_LAYOUT, PreferenceKeys.STORY_SORT, Constants.PREF_EMOJI_VARIANTS, Constants.PREF_REACTIONS, PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_UNIT, PreferenceKeys.PREF_TAB_ORDER, PreferenceKeys.PREF_BARINSTA_DIR_URI ) annotation class StringSettings @StringDef( PreferenceKeys.DOWNLOAD_USER_FOLDER, PreferenceKeys.DOWNLOAD_PREPEND_USER_NAME, PreferenceKeys.AUTOPLAY_VIDEOS_STORIES, PreferenceKeys.MUTED_VIDEOS, // PreferenceKeys.SHOW_CAPTIONS, PreferenceKeys.CUSTOM_DATE_TIME_FORMAT_ENABLED, PreferenceKeys.MARK_AS_SEEN, PreferenceKeys.DM_MARK_AS_SEEN, PreferenceKeys.CHECK_ACTIVITY, PreferenceKeys.CHECK_UPDATES, PreferenceKeys.SWAP_DATE_TIME_FORMAT_ENABLED, PreferenceKeys.PREF_ENABLE_DM_NOTIFICATIONS, PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH, PreferenceKeys.FLAG_SECURE, PreferenceKeys.TOGGLE_KEYWORD_FILTER, PreferenceKeys.PREF_ENABLE_SENTRY, PreferenceKeys.HIDE_MUTED_REELS, PreferenceKeys.PLAY_IN_BACKGROUND, PreferenceKeys.PREF_SHOWN_COUNT_TOOLTIP, PreferenceKeys.PREF_SEARCH_FOCUS_KEYBOARD, PreferenceKeys.PREF_STORY_SHOW_LIST, PreferenceKeys.PREF_AUTO_BACKUP_ENABLED ) annotation class BooleanSettings @StringDef( Constants.PREV_INSTALL_VERSION, Constants.BROWSER_UA_CODE, Constants.APP_UA_CODE, PreferenceKeys.PREF_ENABLE_DM_AUTO_REFRESH_FREQ_NUMBER ) annotation class IntegerSettings @StringDef(PreferenceKeys.KEYWORD_FILTERS) annotation class StringSetSettings } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt ================================================ /* * Copyright 2017 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package awais.instagrabber.utils import android.util.Log import androidx.annotation.MainThread import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import awais.instagrabber.utils.extensions.TAG import java.util.concurrent.atomic.AtomicBoolean /** * A lifecycle-aware observable that sends only new updates after subscription, used for events like * navigation and Snackbar messages. * * * This avoids a common problem with events: on configuration change (like rotation) an update * can be emitted if the observer is active. This LiveData only calls the observable if there's an * explicit call to setValue() or call(). * * * Note that only one observer is going to be notified of changes. */ class SingleLiveEvent : MutableLiveData() { private val pending = AtomicBoolean(false) @MainThread override fun observe(owner: LifecycleOwner, observer: Observer) { if (hasActiveObservers()) { Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") } // Observe the internal MutableLiveData super.observe(owner, { t -> if (pending.compareAndSet(true, false)) { observer.onChanged(t) } }) } @MainThread override fun setValue(t: T?) { pending.set(true) super.setValue(t) } /** * Used for cases where T is Void, to make calls cleaner. */ @MainThread fun call() { value = null } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/SingletonHolder.kt ================================================ package awais.instagrabber.utils open class SingletonHolder(creator: (A) -> T) { private var creator: ((A) -> T)? = creator @Volatile private var instance: T? = null fun getInstance(arg: A): T { val i = instance if (i != null) { return i } return synchronized(this) { val i2 = instance if (i2 != null) { i2 } else { val created = creator!!(arg) instance = created creator = null created } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/TextUtils.kt ================================================ package awais.instagrabber.utils import android.util.Patterns import java.time.Duration import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* import kotlin.math.absoluteValue object TextUtils { var dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(Constants.defaultDateTimeFormat) @JvmStatic fun isEmpty(charSequence: CharSequence?): Boolean { if (charSequence.isNullOrBlank()) return true if (charSequence is String) { var str = charSequence if ("" == str || "null" == str || str.isEmpty()) return true str = str.trim { it <= ' ' } return "" == str || "null" == str || str.isEmpty() } return "null".contentEquals(charSequence) || "".contentEquals(charSequence) } @JvmStatic @JvmOverloads fun millisToTimeString(millis: Long, includeHoursAlways: Boolean = false): String { val sec = (millis / 1000).toInt() % 60 var min = (millis / (1000 * 60)).toInt() if (min >= 60) { min = (millis / (1000 * 60) % 60).toInt() val hr = (millis / (1000 * 60 * 60) % 24).toInt() return String.format(Locale.ENGLISH, "%02d:%02d:%02d", hr, min, sec) } return if (includeHoursAlways) { String.format(Locale.ENGLISH, "%02d:%02d:%02d", 0, min, sec) } else String.format(Locale.ENGLISH, "%02d:%02d", min, sec) } private val timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) @JvmStatic fun getRelativeDateTimeString(from: Long): String { val now = LocalDateTime.now() val then = LocalDateTime.ofInstant(Instant.ofEpochMilli(from), ZoneId.systemDefault()) val days = Duration.between(now, then).toDays().absoluteValue return then.format(if (days == 0L) timeFormatter else dateFormatter) } @JvmStatic fun extractUrls(text: String): List { if (isEmpty(text)) return emptyList() val matcher = Patterns.WEB_URL.matcher(text) val urls: MutableList = ArrayList() while (matcher.find()) { urls.add(matcher.group()) } return urls } // https://github.com/notslang/instagram-id-to-url-segment @JvmStatic fun shortcodeToId(shortcode: String): Long { var result = 0L var i = 0 while (i < shortcode.length && i < 11) { val c = shortcode[i] val k = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".indexOf(c) result = result * 64 + k i++ } return result } @JvmStatic fun setFormatter(datetimeParser: DateTimeFormatter) { if (!DateUtils.checkFormatterValid(datetimeParser)) return this.dateTimeFormatter = datetimeParser } @JvmStatic fun epochSecondToString(epochSecond: Long): String { return LocalDateTime.ofInstant( Instant.ofEpochSecond(epochSecond), ZoneId.systemDefault() ).format(dateTimeFormatter) } @JvmStatic fun nowToString(): String { return LocalDateTime.now().format(dateTimeFormatter) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/ThemeUtils.kt ================================================ @file:JvmName("ThemeUtils") package awais.instagrabber.utils import android.content.Context import android.content.res.Configuration import android.os.Build import androidx.appcompat.app.AppCompatDelegate import awais.instagrabber.R object ThemeUtils { fun changeTheme(context: Context) { var themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM // this is fallback / default if (Utils.settingsHelper != null) themeCode = Utils.settingsHelper.getThemeCode(false) if (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && Build.VERSION.SDK_INT < 29) { themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY } val isNight = isNight(context, themeCode) val themeResName = if (isNight) Utils.settingsHelper.getString(Constants.PREF_DARK_THEME) else Utils.settingsHelper.getString( Constants.PREF_LIGHT_THEME ) val themeResId = context.resources.getIdentifier(themeResName, "style", context.packageName) val finalThemeResId: Int finalThemeResId = if (themeResId <= 0) { // Nothing set in settings if (isNight) R.style.AppTheme_Dark_Black else R.style.AppTheme_Light_White } else themeResId // Log.d(TAG, "changeTheme: finalThemeResId: " + finalThemeResId); context.setTheme(finalThemeResId) } fun isNight(context: Context, themeCode: Int): Boolean { // check if setting is set to 'Dark' var isNight = themeCode == AppCompatDelegate.MODE_NIGHT_YES // if not dark check if themeCode is MODE_NIGHT_FOLLOW_SYSTEM or MODE_NIGHT_AUTO_BATTERY if (!isNight && (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM || themeCode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY)) { // check if resulting theme would be NIGHT val uiMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK isNight = uiMode == Configuration.UI_MODE_NIGHT_YES } return isNight } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/UpdateCheckCommon.kt ================================================ @file:JvmName("UpdateCheckCommon") package awais.instagrabber.utils import android.content.Context import android.content.DialogInterface import awais.instagrabber.BuildConfig import awais.instagrabber.R import awais.instagrabber.utils.AppExecutors.mainThread import com.google.android.material.dialog.MaterialAlertDialogBuilder fun shouldShowUpdateDialog( force: Boolean, version: String ): Boolean { val skippedVersion = Utils.settingsHelper.getString(Constants.SKIPPED_VERSION) return force || !BuildConfig.DEBUG && skippedVersion != version } fun showUpdateDialog( context: Context, version: String, onDownloadClickListener: DialogInterface.OnClickListener ) { mainThread.execute { MaterialAlertDialogBuilder(context).apply { setTitle(context.getString(R.string.update_available, version)) setNeutralButton(R.string.skip_update) { dialog: DialogInterface, _: Int -> Utils.settingsHelper.putString(Constants.SKIPPED_VERSION, version) dialog.dismiss() } setPositiveButton(R.string.action_download, onDownloadClickListener) setNegativeButton(R.string.cancel, null) }.show() } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/UserAgentUtils.kt ================================================ @file:JvmName("UserAgentUtils") package awais.instagrabber.utils /* GraphQL user agents (which are just standard browser UA"s). * Go to https://www.whatismybrowser.com/guides/the-latest-user-agent/ to update it * Windows first (Assume win64 not wow64): Chrome, Firefox, Edge * Then macOS: Chrome, Firefox, Safari */ @JvmField val browsers = arrayOf( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Edg/90.0.818.62", "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_3_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 11.3; rv:88.0) Gecko/20100101 Firefox/88.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Safari/605.1.15" ) // use APKpure, assume arm64-v8a private const val igVersion = "195.0.0.31.123" private const val igVersionCode = "302733772" // you can pick *any* device as long as you LEAVE OUT the resolution for maximum download quality // https://github.com/dilame/instagram-private-api/blob/master/src/samples/devices.json @JvmField val devices = arrayOf( "25/7.1.1; 440dpi; 2880x5884; Xiaomi; Mi Note 3; jason; qcom", "23/6.0.1; 480dpi; 2880x5884; Xiaomi; Redmi Note 3; kenzo; qcom", "23/6.0; 480dpi; 2880x5884; Xiaomi; Redmi Note 4; nikel; mt6797", "24/7.0; 480dpi; 2880x5884; Xiaomi/xiaomi; Redmi Note 4; mido; qcom", "23/6.0; 480dpi; 2880x5884; Xiaomi; Redmi Note 4X; nikel; mt6797", "27/8.1.0; 440dpi; 2880x5884; Xiaomi/xiaomi; Redmi Note 5; whyred; qcom", "23/6.0.1; 480dpi; 2880x5884; Xiaomi; Redmi 4; markw; qcom", "27/8.1.0; 440dpi; 2880x5884; Xiaomi/xiaomi; Redmi 5 Plus; vince; qcom", "25/7.1.2; 440dpi; 2880x5884; Xiaomi/xiaomi; Redmi 5 Plus; vince; qcom", "26/8.0.0; 480dpi; 2880x5884; Xiaomi; MI 5; gemini; qcom", "27/8.1.0; 480dpi; 2880x5884; Xiaomi/xiaomi; Mi A1; tissot_sprout; qcom", "26/8.0.0; 480dpi; 2880x5884; Xiaomi; MI 6; sagit; qcom", "25/7.1.1; 440dpi; 2880x5884; Xiaomi; MI MAX 2; oxygen; qcom", "24/7.0; 480dpi; 2880x5884; Xiaomi; MI 5s; capricorn; qcom", "26/8.0.0; 480dpi; 2880x5884; samsung; SM-A520F; a5y17lte; samsungexynos7880", "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G950F; dreamlte; samsungexynos8895", "26/8.0.0; 640dpi; 2880x5884; samsung; SM-G950F; dreamlte; samsungexynos8895", "26/8.0.0; 420dpi; 2880x5884; samsung; SM-G955F; dream2lte; samsungexynos8895", "26/8.0.0; 560dpi; 2880x5884; samsung; SM-G955F; dream2lte; samsungexynos8895", "24/7.0; 480dpi; 2880x5884; samsung; SM-A510F; a5xelte; samsungexynos7580", "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G930F; herolte; samsungexynos8890", "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G935F; hero2lte; samsungexynos8890", "26/8.0.0; 420dpi; 2880x5884; samsung; SM-G965F; star2lte; samsungexynos9810", "26/8.0.0; 480dpi; 2880x5884; samsung; SM-A530F; jackpotlte; samsungexynos7885", "24/7.0; 640dpi; 2880x5884; samsung; SM-G925F; zerolte; samsungexynos7420", "26/8.0.0; 420dpi; 2880x5884; samsung; SM-A720F; a7y17lte; samsungexynos7880", "24/7.0; 640dpi; 2880x5884; samsung; SM-G920F; zeroflte; samsungexynos7420", "24/7.0; 420dpi; 2880x5884; samsung; SM-J730FM; j7y17lte; samsungexynos7870", "26/8.0.0; 480dpi; 2880x5884; samsung; SM-G960F; starlte; samsungexynos9810", "26/8.0.0; 420dpi; 2880x5884; samsung; SM-N950F; greatlte; samsungexynos8895", "26/8.0.0; 420dpi; 2880x5884; samsung; SM-A730F; jackpot2lte; samsungexynos7885", "26/8.0.0; 420dpi; 2880x5884; samsung; SM-A605FN; a6plte; qcom", "26/8.0.0; 480dpi; 2880x5884; HUAWEI/HONOR; STF-L09; HWSTF; hi3660", "27/8.1.0; 480dpi; 2880x5884; HUAWEI/HONOR; COL-L29; HWCOL; kirin970", "26/8.0.0; 480dpi; 2880x5884; HUAWEI/HONOR; LLD-L31; HWLLD-H; hi6250", "26/8.0.0; 480dpi; 2880x5884; HUAWEI; ANE-LX1; HWANE; hi6250", "26/8.0.0; 480dpi; 2880x5884; HUAWEI; FIG-LX1; HWFIG-H; hi6250", "27/8.1.0; 480dpi; 2880x5884; HUAWEI/HONOR; COL-L29; HWCOL; kirin970", "26/8.0.0; 480dpi; 2880x5884; HUAWEI/HONOR; BND-L21; HWBND-H; hi6250", "23/6.0.1; 420dpi; 2880x5884; LeMobile/LeEco; Le X527; le_s2_ww; qcom", // https://github.com/mimmi20/BrowserDetector/tree/master "28/9; 560dpi; 2880x5884; samsung; SM-N960F; crownlte; samsungexynos9810", // mgp25 "23/6.0.1; 640dpi; 2880x5884; LGE/lge; RS988; h1; h1", "24/7.0; 640dpi; 2880x5884; HUAWEI; LON-L29; HWLON; hi3660", "23/6.0.1; 640dpi; 2880x5884; ZTE; ZTE A2017U; ailsa_ii; qcom", "23/6.0.1; 640dpi; 2880x5884; samsung; SM-G935F; hero2lte; samsungexynos8890", "23/6.0.1; 640dpi; 2880x5884; samsung; SM-G930F; herolte; samsungexynos8890" ) fun generateBrowserUA(code: Int): String { return browsers[code] } fun generateAppUA(code: Int, lang: String): String { return "Instagram " + igVersion + " Android (" + devices[code] + "; " + lang + "; " + igVersionCode + ")" } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/Utils.java ================================================ package awais.instagrabber.utils; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.ClipboardManager; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.graphics.Color; import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.os.storage.StorageManager; import android.provider.Browser; import android.provider.DocumentsContract; import android.util.DisplayMetrics; import android.util.Log; import android.util.Pair; import android.util.TypedValue; import android.view.Display; import android.view.Gravity; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.view.inputmethod.InputMethodManager; import android.webkit.MimeTypeMap; import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.documentfile.provider.DocumentFile; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat; import com.google.android.exoplayer2.database.ExoDatabaseProvider; import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import org.json.JSONObject; import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import awais.instagrabber.R; import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.enums.FavoriteType; public final class Utils { private static final String TAG = "Utils"; private static final int VIDEO_CACHE_MAX_BYTES = 10 * 1024 * 1024; // public static LogCollector logCollector; public static SettingsHelper settingsHelper; public static boolean sessionVolumeFull = false; public static final MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); public static final DisplayMetrics displayMetrics = Resources.getSystem().getDisplayMetrics(); public static ClipboardManager clipboardManager; public static SimpleCache simpleCache; private static int statusBarHeight; private static int actionBarHeight; public static String cacheDir; private static int defaultStatusBarColor; private static Object[] volumes; public static int convertDpToPx(final float dp) { return Math.round((dp * displayMetrics.densityDpi) / DisplayMetrics.DENSITY_DEFAULT); } public static void copyText(@NonNull final Context context, final CharSequence string) { if (clipboardManager == null) { clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); } int toastMessage = R.string.clipboard_error; if (clipboardManager != null) { try { clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.app_name), string)); toastMessage = R.string.clipboard_copied; } catch (Exception e) { Log.e(TAG, "copyText: ", e); } } Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show(); } public static Map sign(final Map form) { // final String signed = sign(Constants.SIGNATURE_KEY, new JSONObject(form).toString()); // if (signed == null) { // return null; // } final Map map = new HashMap<>(); // map.put("ig_sig_key_version", Constants.SIGNATURE_VERSION); // map.put("signed_body", signed); map.put("signed_body", "SIGNATURE." + new JSONObject(form).toString()); return map; } // public static String sign(final String key, final String message) { // try { // final Mac hasher = Mac.getInstance("HmacSHA256"); // hasher.init(new SecretKeySpec(key.getBytes(), "HmacSHA256")); // byte[] hash = hasher.doFinal(message.getBytes()); // final StringBuilder hexString = new StringBuilder(); // for (byte b : hash) { // final String hex = Integer.toHexString(0xff & b); // if (hex.length() == 1) hexString.append('0'); // hexString.append(hex); // } // return hexString.toString() + "." + message; // } catch (Exception e) { // Log.e(TAG, "Error signing", e); // return null; // } // } public static String getMimeType(@NonNull final Uri uri, final ContentResolver contentResolver) { String mimeType; final String scheme = uri.getScheme(); final String fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); if (TextUtils.isEmpty(scheme)) { mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension.toLowerCase()); } else { if (ContentResolver.SCHEME_CONTENT.equals(scheme)) { mimeType = contentResolver.getType(uri); } else { mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension.toLowerCase()); } } if (mimeType == null) return null; return mimeType.toLowerCase(); } public static SimpleCache getSimpleCacheInstance(final Context context) { if (context == null) { return null; } final ExoDatabaseProvider exoDatabaseProvider = new ExoDatabaseProvider(context); final File cacheDir = context.getCacheDir(); if (simpleCache == null && cacheDir != null) { simpleCache = new SimpleCache(cacheDir, new LeastRecentlyUsedCacheEvictor(VIDEO_CACHE_MAX_BYTES), exoDatabaseProvider); } return simpleCache; } @Nullable public static Pair migrateOldFavQuery(final String queryText) { if (queryText.startsWith("@")) { return new Pair<>(FavoriteType.USER, queryText.substring(1)); } else if (queryText.contains("/")) { return new Pair<>(FavoriteType.LOCATION, queryText.substring(0, queryText.indexOf("/"))); } else if (queryText.startsWith("#")) { return new Pair<>(FavoriteType.HASHTAG, queryText.substring(1)); } return null; } public static int getStatusBarHeight(final Context context) { if (statusBarHeight > 0) { return statusBarHeight; } int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android"); if (resourceId > 0) { statusBarHeight = context.getResources().getDimensionPixelSize(resourceId); } return statusBarHeight; } public static int getActionBarHeight(@NonNull final Context context) { if (actionBarHeight > 0) { return actionBarHeight; } final TypedValue tv = new TypedValue(); if (context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { actionBarHeight = TypedValue.complexToDimensionPixelSize(tv.data, displayMetrics); } return actionBarHeight; } public static void openURL(final Context context, final String url) { if (context == null || TextUtils.isEmpty(url)) { return; } try { String url1 = url; // add http:// if string doesn't have http:// or https:// if (!url.startsWith("http://") && !url.startsWith("https://")) { url1 = "http://" + url; } final Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(url1)); i.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); i.putExtra(Browser.EXTRA_CREATE_NEW_TAB, true); context.startActivity(i); } catch (ActivityNotFoundException e) { Log.e(TAG, "openURL: No activity found to handle URLs", e); Toast.makeText(context, context.getString(R.string.no_external_app_url), Toast.LENGTH_LONG).show(); } catch (Exception e) { Log.e(TAG, "openURL", e); } } public static void openEmailAddress(final Context context, final String emailAddress) { if (context == null || TextUtils.isEmpty(emailAddress)) { return; } Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:" + emailAddress)); emailIntent.putExtra(Intent.EXTRA_SUBJECT, ""); emailIntent.putExtra(Intent.EXTRA_TEXT, ""); context.startActivity(emailIntent); } public static void displayToastAboveView(@NonNull final Context context, @NonNull final View view, @NonNull final String text) { final Toast toast = Toast.makeText(context, text, Toast.LENGTH_SHORT); toast.setGravity(Gravity.TOP | Gravity.START, view.getLeft(), view.getTop()); toast.show(); } public static PostsLayoutPreferences getPostsLayoutPreferences(final String layoutPreferenceKey) { PostsLayoutPreferences layoutPreferences = PostsLayoutPreferences.fromJson(settingsHelper.getString(layoutPreferenceKey)); if (layoutPreferences == null) { layoutPreferences = PostsLayoutPreferences.builder().build(); settingsHelper.putString(layoutPreferenceKey, layoutPreferences.getJson()); } return layoutPreferences; } private static Field mAttachInfoField; private static Field mStableInsetsField; public static int getViewInset(View view) { if (view == null || view.getHeight() == displayMetrics.heightPixels || view.getHeight() == displayMetrics.widthPixels - getStatusBarHeight(view.getContext())) { return 0; } try { if (mAttachInfoField == null) { //noinspection JavaReflectionMemberAccess mAttachInfoField = View.class.getDeclaredField("mAttachInfo"); mAttachInfoField.setAccessible(true); } Object mAttachInfo = mAttachInfoField.get(view); if (mAttachInfo != null) { if (mStableInsetsField == null) { mStableInsetsField = mAttachInfo.getClass().getDeclaredField("mStableInsets"); mStableInsetsField.setAccessible(true); } Rect insets = (Rect) mStableInsetsField.get(mAttachInfo); if (insets == null) { return 0; } return insets.bottom; } } catch (Exception e) { Log.e(TAG, "getViewInset", e); } return 0; } public static int getThemeAccentColor(Context context) { int colorAttr; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { colorAttr = android.R.attr.colorAccent; } else { //Get colorAccent defined for AppCompat colorAttr = context.getResources().getIdentifier("colorAccent", "attr", context.getPackageName()); } TypedValue outValue = new TypedValue(); context.getTheme().resolveAttribute(colorAttr, outValue, true); return outValue.data; } public static int getAttrValue(@NonNull final Context context, final int attr) { final TypedValue outValue = new TypedValue(); context.getTheme().resolveAttribute(attr, outValue, true); return outValue.data; } public static int getAttrResId(@NonNull final Context context, final int attr) { final TypedValue outValue = new TypedValue(); context.getTheme().resolveAttribute(attr, outValue, true); return outValue.resourceId; } public static void transparentStatusBar(final Activity activity, final boolean enable, final boolean fullscreen) { if (activity == null) return; final ActionBar actionBar = ((AppCompatActivity) activity).getSupportActionBar(); final Window window = activity.getWindow(); final View decorView = window.getDecorView(); if (enable) { decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); if (actionBar != null) { actionBar.hide(); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { defaultStatusBarColor = window.getStatusBarColor(); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); // FOR TRANSPARENT NAVIGATION BAR window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); window.setStatusBarColor(Color.TRANSPARENT); Log.d(TAG, "Setting Color Transparent " + Color.TRANSPARENT + " Default Color " + defaultStatusBarColor); return; } Log.d(TAG, "Setting Color Trans " + Color.TRANSPARENT); window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); return; } if (fullscreen) { int uiOptions = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN; decorView.setSystemUiVisibility(uiOptions); return; } if (actionBar != null) { actionBar.show(); } decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); window.setStatusBarColor(defaultStatusBarColor); return; } window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); } // public static void mediaScanFile(@NonNull final Context context, // @NonNull File file, // @NonNull final OnScanCompletedListener callback) { // //noinspection UnstableApiUsage // final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(Files.getFileExtension(file.getName())); // MediaScannerConnection.scanFile( // context, // new String[]{file.getAbsolutePath()}, // new String[]{mimeType}, // callback // ); // } public static void showKeyboard(@NonNull final View view) { try { final Context context = view.getContext(); if (context == null) return; final InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); if (imm == null) return; view.requestFocus(); final boolean shown = imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); if (!shown) { Log.e(TAG, "showKeyboard: System did not display the keyboard"); } } catch (Exception e) { Log.e(TAG, "showKeyboard: ", e); } } public static void hideKeyboard(final View view) { if (view == null) return; final Context context = view.getContext(); if (context == null) return; try { final InputMethodManager manager = (InputMethodManager) context.getSystemService(Activity.INPUT_METHOD_SERVICE); if (manager == null) return; manager.hideSoftInputFromWindow(view.getWindowToken(), 0); } catch (Exception e) { Log.e(TAG, "hideKeyboard: ", e); } } public static Drawable getAnimatableDrawable(@NonNull final Context context, @DrawableRes final int drawableResId) { final Drawable drawable; if (Build.VERSION.SDK_INT >= 24) { drawable = ContextCompat.getDrawable(context, drawableResId); } else { drawable = AnimatedVectorDrawableCompat.create(context, drawableResId); } return drawable; } public static void enabledKeepScreenOn(@NonNull final Activity activity) { final Window window = activity.getWindow(); if (window == null) return; window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } public static void disableKeepScreenOn(@NonNull final Activity activity) { final Window window = activity.getWindow(); if (window == null) return; window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } public static void moveItem(int sourceIndex, int targetIndex, List list) { if (sourceIndex <= targetIndex) { Collections.rotate(list.subList(sourceIndex, targetIndex + 1), -1); } else { Collections.rotate(list.subList(targetIndex, sourceIndex + 1), 1); } } // public static void scanDocumentFile(@NonNull final Context context, // @NonNull final DocumentFile documentFile, // @NonNull final OnScanCompletedListener callback) { // if (!documentFile.isFile() || !documentFile.exists()) { // Log.d(TAG, "scanDocumentFile: " + documentFile); // callback.onScanCompleted(null, null); // return; // } // File file = null; // try { // file = getDocumentFileRealPath(context, documentFile); // } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { // Log.e(TAG, "scanDocumentFile: ", e); // } // if (file == null) return; // MediaScannerConnection.scanFile(context, // new String[]{file.getAbsolutePath()}, // new String[]{documentFile.getType()}, // callback); // } public static File getDocumentFileRealPath(@NonNull final Context context, @NonNull final DocumentFile documentFile) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { final String docId = DocumentsContract.getDocumentId(documentFile.getUri()); final String[] split = docId.split(":"); final String type = split[0]; if (type.equalsIgnoreCase("primary")) { return new File(Environment.getExternalStorageDirectory(), split[1]); } else if (type.equalsIgnoreCase("raw")) { return new File(split[1]); } else { if (volumes == null) { final StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); if (sm == null) return null; final Method getVolumeListMethod = sm.getClass().getMethod("getVolumeList"); volumes = (Object[]) getVolumeListMethod.invoke(sm); } if (volumes == null) return null; for (Object volume : volumes) { final Method getUuidMethod = volume.getClass().getMethod("getUuid"); final String uuid = (String) getUuidMethod.invoke(volume); if (uuid != null && uuid.equalsIgnoreCase(type)) { final Method getPathMethod = volume.getClass().getMethod("getPath"); final String path = (String) getPathMethod.invoke(volume); return new File(path, split[1]); } } } return null; } public static void setupSelectedDir(@NonNull final Context context, @NonNull final Intent intent) throws DownloadUtils.ReselectDocumentTreeException { final Uri dirUri = intent.getData(); Log.d(TAG, "onActivityResult: " + dirUri); if (dirUri == null) return; final int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); context.getContentResolver().takePersistableUriPermission(dirUri, takeFlags); // re-init DownloadUtils DownloadUtils.init(context, dirUri.toString()); } @NonNull public static Point getNavigationBarSize(@NonNull Context context) { Point appUsableSize = getAppUsableScreenSize(context); Point realScreenSize = getRealScreenSize(context); // navigation bar on the right if (appUsableSize.x < realScreenSize.x) { return new Point(realScreenSize.x - appUsableSize.x, appUsableSize.y); } // navigation bar at the bottom if (appUsableSize.y < realScreenSize.y) { return new Point(appUsableSize.x, realScreenSize.y - appUsableSize.y); } // navigation bar is not present return new Point(); } @NonNull public static Point getAppUsableScreenSize(@NonNull Context context) { WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); Display display = windowManager.getDefaultDisplay(); Point size = new Point(); display.getSize(size); return size; } @NonNull public static Point getRealScreenSize(@NonNull Context context) { WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); Display display = windowManager.getDefaultDisplay(); Point size = new Point(); display.getRealSize(size); return size; } public static LiveData> zipLiveData(@NonNull final LiveData firstLiveData, @NonNull final LiveData secondLiveData) { final ZippedLiveData zippedLiveData = new ZippedLiveData<>(); zippedLiveData.addFirstSource(firstLiveData); zippedLiveData.addSecondSource(secondLiveData); return zippedLiveData; } public static class ZippedLiveData extends MediatorLiveData> { private F lastF; private S lastS; private void update() { F localLastF = lastF; S localLastS = lastS; if (localLastF != null && localLastS != null) { setValue(new Pair<>(localLastF, localLastS)); } } public void addFirstSource(@NonNull final LiveData firstLiveData) { addSource(firstLiveData, f -> { lastF = f; update(); }); } public void addSecondSource(@NonNull final LiveData secondLiveData) { addSource(secondLiveData, s -> { lastS = s; update(); }); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/ViewUtils.kt ================================================ @file:JvmName("ViewUtils") package awais.instagrabber.utils import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RoundRectShape import android.os.Build import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView import androidx.annotation.ColorInt import androidx.core.content.res.ResourcesCompat import androidx.core.util.Pair import androidx.dynamicanimation.animation.FloatPropertyCompat import androidx.dynamicanimation.animation.SpringAnimation import kotlin.jvm.internal.Intrinsics fun createRoundRectDrawableWithIcon(context: Context, rad: Int, iconRes: Int): Drawable? { val defaultDrawable = ShapeDrawable(RoundRectShape(FloatArray(8) { rad.toFloat() }, null, null)) defaultDrawable.paint.color = -0x1 val d = ResourcesCompat.getDrawable(context.resources, iconRes, null) ?: return null val drawable = d.mutate() return CombinedDrawable(defaultDrawable, drawable) } fun createRoundRectDrawable(rad: Int, defaultColor: Int): Drawable { val defaultDrawable = ShapeDrawable(RoundRectShape(FloatArray(8) { rad.toFloat() }, null, null)) defaultDrawable.paint.color = defaultColor return defaultDrawable } fun createFrame( width: Int, height: Float, gravity: Int, leftMargin: Float, topMargin: Float, rightMargin: Float, bottomMargin: Float ): FrameLayout.LayoutParams { val layoutParams = FrameLayout.LayoutParams(getSize(width.toFloat()), getSize(height), gravity) layoutParams.setMargins( Utils.convertDpToPx(leftMargin), Utils.convertDpToPx(topMargin), Utils.convertDpToPx(rightMargin), Utils.convertDpToPx(bottomMargin) ) return layoutParams } fun createGradientDrawable( orientation: GradientDrawable.Orientation?, @ColorInt colors: IntArray? ): GradientDrawable { val drawable = GradientDrawable(orientation, colors) drawable.shape = GradientDrawable.RECTANGLE return drawable } private fun getSize(size: Float): Int { return if (size < 0) size.toInt() else Utils.convertDpToPx(size) } fun measure(view: View, parent: View): Pair { view.measure( View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) ) return Pair(view.measuredHeight, view.measuredWidth) } fun getTextViewValueWidth(textView: TextView, text: String?): Float { return textView.paint.measureText(text) } /** * Creates [SpringAnimation] for object. * If finalPosition is not [Float.NaN] then create [SpringAnimation] with * [SpringForce.mFinalPosition]. * * @param object Object * @param property object's property to be animated. * @param finalPosition [SpringForce.mFinalPosition] Final position of spring. * @return [SpringAnimation] */ fun springAnimationOf( `object`: Any?, property: FloatPropertyCompat?, finalPosition: Float? ): SpringAnimation { return finalPosition?.let { SpringAnimation(`object`, property, it) } ?: SpringAnimation(`object`, property) } fun suppressLayoutCompat(`$this$suppressLayoutCompat`: ViewGroup, suppress: Boolean) { Intrinsics.checkNotNullParameter(`$this$suppressLayoutCompat`, "\$this\$suppressLayoutCompat") if (Build.VERSION.SDK_INT >= 29) { `$this$suppressLayoutCompat`.suppressLayout(suppress) } else { hiddenSuppressLayout(`$this$suppressLayoutCompat`, suppress) } } private var tryHiddenSuppressLayout = true @SuppressLint("NewApi") private fun hiddenSuppressLayout(group: ViewGroup, suppress: Boolean) { if (tryHiddenSuppressLayout) { try { group.suppressLayout(suppress) } catch (var3: NoSuchMethodError) { tryHiddenSuppressLayout = false } } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/VoiceRecorder.java ================================================ package awais.instagrabber.utils; import android.app.Application; import android.content.ContentResolver; import android.media.MediaRecorder; import android.os.Handler; import android.os.Message; import android.os.ParcelFileDescriptor; import android.util.Log; import android.webkit.MimeTypeMap; import androidx.annotation.NonNull; import androidx.documentfile.provider.DocumentFile; import java.io.IOException; import java.io.File; import java.time.format.DateTimeFormatter; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Locale; public class VoiceRecorder { private static final String TAG = VoiceRecorder.class.getSimpleName(); private static final String FILE_PREFIX = "recording"; private static final String EXTENSION = "mp4"; private static final String MIME_TYPE = MimeTypeMap.getSingleton().getMimeTypeFromExtension(EXTENSION); private static final int AUDIO_SAMPLE_RATE = 44100; private static final int AUDIO_BIT_DEPTH = 16; private static final int AUDIO_BIT_RATE = AUDIO_SAMPLE_RATE * AUDIO_BIT_DEPTH; private static final String FILE_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"; private static final DateTimeFormatter SIMPLE_DATE_FORMAT = DateTimeFormatter.ofPattern(FILE_FORMAT, Locale.US); private final List waveform = new ArrayList<>(); private final DocumentFile recordingsDir; private final VoiceRecorderCallback callback; private MediaRecorder recorder; private DocumentFile audioTempFile; private MaxAmpHandler maxAmpHandler; private boolean stopped; public VoiceRecorder(@NonNull final DocumentFile recordingsDir, final VoiceRecorderCallback callback) { this.recordingsDir = recordingsDir; this.callback = callback; } public void startRecording(final ContentResolver contentResolver) { stopped = false; ParcelFileDescriptor parcelFileDescriptor = null; try { recorder = new MediaRecorder(); recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); deleteTempAudioFile(); audioTempFile = getAudioRecordFile(); parcelFileDescriptor = contentResolver.openFileDescriptor(audioTempFile.getUri(), "rwt"); recorder.setOutputFile(parcelFileDescriptor.getFileDescriptor()); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC); recorder.setAudioEncodingBitRate(AUDIO_BIT_RATE); recorder.setAudioSamplingRate(AUDIO_SAMPLE_RATE); recorder.prepare(); waveform.clear(); maxAmpHandler = new MaxAmpHandler(waveform); recorder.start(); if (callback != null) { callback.onStart(); } getMaxAmp(); } catch (Exception e) { Log.e(TAG, "Audio recording failed", e); deleteTempAudioFile(); } finally { if (parcelFileDescriptor != null) { try { parcelFileDescriptor.close(); } catch (IOException ignored) {} } } } public void stopRecording(final boolean cancelled) { stopped = true; if (maxAmpHandler != null) { maxAmpHandler.removeCallbacks(getMaxAmpRunnable); } if (recorder == null) { if (callback != null) { callback.onCancel(); } return; } try { recorder.stop(); recorder.release(); recorder = null; // processWaveForm(); } catch (Exception e) { Log.e(TAG, "stopRecording: error", e); deleteTempAudioFile(); } if (cancelled) { deleteTempAudioFile(); if (callback != null) { callback.onCancel(); } return; } if (callback != null) { callback.onComplete(new VoiceRecordingResult(MIME_TYPE, audioTempFile, waveform)); } } private static class MaxAmpHandler extends Handler { private final List waveform; public MaxAmpHandler(final List waveform) { this.waveform = waveform; } @Override public void handleMessage(@NonNull final Message msg) { if (waveform == null) return; waveform.add(msg.obj instanceof Float ? (Float) msg.obj : 0f); } } private final Runnable getMaxAmpRunnable = this::getMaxAmp; private void getMaxAmp() { if (stopped || recorder == null || maxAmpHandler == null) return; final float value = (float) Math.pow(2.0d, (Math.log10((double) recorder.getMaxAmplitude() / 2700.0d) * 20.0d) / 6.0d); maxAmpHandler.postDelayed(getMaxAmpRunnable, 100); Message msg = Message.obtain(); msg.obj = value; maxAmpHandler.sendMessage(msg); } // private void processWaveForm() { // // if (waveform == null || waveform.isEmpty()) return; // final Optional maxAmplitudeOptional = waveform.stream().max(Float::compareTo); // if (!maxAmplitudeOptional.isPresent()) return; // final float maxAmp = maxAmplitudeOptional.get(); // final List normalised = waveform.stream() // .map(amp -> amp / maxAmp) // .map(amp -> amp < 0.01f ? 0f : amp) // .collect(Collectors.toList()); // // final List normalised = waveform.stream() // // .map(amp -> amp * 1.0f / 32768) // // .collect(Collectors.toList()); // // Log.d(TAG, "processWaveForm: " + waveform); // Log.d(TAG, "processWaveForm: " + normalised); // } @NonNull private DocumentFile getAudioRecordFile() { final String name = String.format("%s-%s.%s", FILE_PREFIX, LocalDateTime.now().format(SIMPLE_DATE_FORMAT), EXTENSION); DocumentFile file = recordingsDir.findFile(name); if (file == null || !file.exists()) { file = recordingsDir.createFile(MIME_TYPE, name); } return file; } private void deleteTempAudioFile() { if (audioTempFile == null) { //noinspection ResultOfMethodCallIgnored getAudioRecordFile().delete(); return; } final boolean deleted = audioTempFile.delete(); if (!deleted) { Log.w(TAG, "stopRecording: file not deleted"); } audioTempFile = null; } public static class VoiceRecordingResult { private final String mimeType; private final DocumentFile file; private final List waveform; private final int samplingFreq = 10; public VoiceRecordingResult(final String mimeType, final DocumentFile file, final List waveform) { this.mimeType = mimeType; this.file = file; this.waveform = waveform; } public String getMimeType() { return mimeType; } public DocumentFile getFile() { return file; } public List getWaveform() { return waveform; } public int getSamplingFreq() { return samplingFreq; } } public interface VoiceRecorderCallback { void onStart(); void onComplete(final VoiceRecordingResult voiceRecordingResult); void onCancel(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/emoji/EmojiCategoryDeserializer.kt ================================================ package awais.instagrabber.utils.emoji import android.util.Log import awais.instagrabber.customviews.emoji.Emoji import awais.instagrabber.customviews.emoji.EmojiCategory import awais.instagrabber.customviews.emoji.EmojiCategoryType import awais.instagrabber.utils.extensions.TAG import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException import java.lang.reflect.Type class EmojiCategoryDeserializer : JsonDeserializer { @Throws(JsonParseException::class) override fun deserialize( json: JsonElement, typeOfT: Type, context: JsonDeserializationContext ): EmojiCategory { val jsonObject = json.asJsonObject val typeElement = jsonObject["type"] val emojisObject = jsonObject.getAsJsonObject("emojis") if (typeElement == null || emojisObject == null) { throw JsonParseException("Invalid json for EmojiCategory") } val typeString = typeElement.asString val type: EmojiCategoryType = try { EmojiCategoryType.valueOf(typeString) } catch (e: IllegalArgumentException) { Log.e(TAG, "deserialize: ", e) EmojiCategoryType.OTHERS } val emojis: MutableMap = linkedMapOf() for ((unicode, value) in emojisObject.entrySet()) { if (unicode == null || value == null) { throw JsonParseException("Invalid json for EmojiCategory") } emojis[unicode] = context.deserialize(value, Emoji::class.java) } return EmojiCategory(type, emojis) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/emoji/EmojiDeserializer.kt ================================================ package awais.instagrabber.utils.emoji import awais.instagrabber.customviews.emoji.Emoji import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException import java.lang.reflect.Type class EmojiDeserializer : JsonDeserializer { @Throws(JsonParseException::class) override fun deserialize( json: JsonElement, typeOfT: Type, context: JsonDeserializationContext ): Emoji { val jsonObject = json.asJsonObject val unicodeElement = jsonObject["unicode"] val nameElement = jsonObject["name"] if (unicodeElement == null || nameElement == null) { throw JsonParseException("Invalid json for Emoji class") } val variantsElement = jsonObject["variants"] val variants: MutableList = mutableListOf() if (variantsElement != null) { val variantsArray = variantsElement.asJsonArray for (variantElement in variantsArray) { val variant = context.deserialize(variantElement, Emoji::class.java) if (variant != null) { variants.add(variant) } } } return Emoji( unicodeElement.asString, nameElement.asString, variants ) } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/emoji/EmojiParser.kt ================================================ package awais.instagrabber.utils.emoji import android.content.Context import android.util.Log import awais.instagrabber.R import awais.instagrabber.customviews.emoji.Emoji import awais.instagrabber.customviews.emoji.EmojiCategory import awais.instagrabber.customviews.emoji.EmojiCategoryType import awais.instagrabber.utils.NetworkUtils import awais.instagrabber.utils.SingletonHolder import awais.instagrabber.utils.extensions.TAG import com.google.gson.FieldNamingPolicy import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken class EmojiParser private constructor(context: Context) { var allEmojis: Map = emptyMap() var categoryMap: Map = emptyMap() val emojiCategories: List by lazy { categoryMap.values.toList() } fun getEmoji(emoji: String): Emoji? { return allEmojis[emoji] } init { try { context.applicationContext.resources.openRawResource(R.raw.emojis).use { `in` -> val json = NetworkUtils.readFromInputStream(`in`) val gson = GsonBuilder().apply { setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) registerTypeAdapter(EmojiCategory::class.java, EmojiCategoryDeserializer()) registerTypeAdapter(Emoji::class.java, EmojiDeserializer()) setLenient() }.create() val type = object : TypeToken>() {}.type categoryMap = gson.fromJson(json, type) // Log.d(TAG, "EmojiParser: " + categoryMap); allEmojis = categoryMap .flatMap { (_, emojiCategory) -> emojiCategory.emojis.values } .flatMap { listOf(it) + it.variants } .filterNotNull() .map { it.unicode to it } .toMap() } } catch (e: Exception) { Log.e(TAG, "EmojiParser: ", e) } } companion object : SingletonHolder(::EmojiParser) } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/extensions/AnyExtensions.kt ================================================ package awais.instagrabber.utils.extensions val Any.TAG: String get() { return if (!javaClass.isAnonymousClass) { val name = javaClass.simpleName if (name.length <= 23) name else name.substring(0, 23) // first 23 chars } else { val name = javaClass.name if (name.length <= 23) name else name.substring(name.length - 23, name.length) // last 23 chars } } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt ================================================ package awais.instagrabber.utils.extensions fun String.trimAll() = this.trim { it <= ' ' } ================================================ FILE: app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt ================================================ package awais.instagrabber.utils.extensions import awais.instagrabber.repositories.responses.User fun User.isReallyPrivate(currentUser: User? = null): Boolean { if (currentUser == null) return this.isPrivate if (this.pk == currentUser.pk) return false return this.friendshipStatus?.following == false && this.isPrivate } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java ================================================ package awais.instagrabber.viewmodels; import android.app.Application; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import awais.instagrabber.db.repositories.AccountRepository; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.webservices.UserRepository; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class AppStateViewModel extends AndroidViewModel { private static final String TAG = AppStateViewModel.class.getSimpleName(); private final String cookie; private final MutableLiveData> currentUser = new MutableLiveData<>(Resource.loading(null)); private AccountRepository accountRepository; private UserRepository userRepository; public AppStateViewModel(@NonNull final Application application) { super(application); // Log.d(TAG, "AppStateViewModel: constructor"); cookie = settingsHelper.getString(Constants.COOKIE); final boolean isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; if (!isLoggedIn) { currentUser.postValue(Resource.success(null)); return; } userRepository = UserRepository.Companion.getInstance(); accountRepository = AccountRepository.Companion.getInstance(application); fetchProfileDetails(); } @Nullable public Resource getCurrentUser() { return currentUser.getValue(); } public LiveData> getCurrentUserLiveData() { return currentUser; } public void fetchProfileDetails() { currentUser.postValue(Resource.loading(null)); final long uid = CookieUtils.getUserIdFromCookie(cookie); if (uid == 0L) { currentUser.postValue(Resource.success(null)); return; } userRepository.getUserInfo(uid, CoroutineUtilsKt.getContinuation((user, throwable) -> { if (throwable != null) { Log.e(TAG, "onFailure: ", throwable); final Resource userResource = currentUser.getValue(); final User backup = userResource != null && userResource.data != null ? userResource.data : new User(uid); currentUser.postValue(Resource.error(throwable.getMessage(), backup)); return; } currentUser.postValue(Resource.success(user)); if (accountRepository != null && user != null) { accountRepository.insertOrUpdateAccount( user.getPk(), user.getUsername(), cookie, user.getFullName() != null ? user.getFullName() : "", user.getProfilePicUrl(), CoroutineUtilsKt.getContinuation((account, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { if (throwable1 != null) { Log.e(TAG, "updateAccountInfo: ", throwable1); } }), Dispatchers.getIO()) ); } }, Dispatchers.getIO())); } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/ArchivesViewModel.java ================================================ package awais.instagrabber.viewmodels; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import java.util.List; import awais.instagrabber.repositories.responses.stories.Story; public class ArchivesViewModel extends ViewModel { private MutableLiveData> list; public MutableLiveData> getList() { if (list == null) { list = new MutableLiveData<>(); } return list; } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/CommentsViewerViewModel.java ================================================ package awais.instagrabber.viewmodels; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import com.google.common.collect.ImmutableList; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.OptionalInt; import java.util.stream.Collectors; import java.util.stream.IntStream; import awais.instagrabber.R; import awais.instagrabber.models.Comment; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.ChildCommentsFetchResponse; import awais.instagrabber.repositories.responses.CommentsFetchResponse; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.webservices.CommentService; import awais.instagrabber.webservices.GraphQLRepository; import awais.instagrabber.webservices.ServiceCallback; import kotlin.coroutines.Continuation; import kotlinx.coroutines.Dispatchers; import static awais.instagrabber.utils.Utils.settingsHelper; public class CommentsViewerViewModel extends ViewModel { private static final String TAG = CommentsViewerViewModel.class.getSimpleName(); private final MutableLiveData currentUserId = new MutableLiveData<>(0L); private final MutableLiveData>> rootList = new MutableLiveData<>(); private final MutableLiveData rootCount = new MutableLiveData<>(0); private final MutableLiveData>> replyList = new MutableLiveData<>(); private final GraphQLRepository graphQLRepository; private String shortCode; private String postId; private String rootCursor; private boolean rootHasNext = true; private Comment repliesParent, replyTo; private String repliesCursor; private boolean repliesHasNext = true; private final CommentService commentService; private List prevReplies; private String prevRepliesCursor; private boolean prevRepliesHasNext = true; private final ServiceCallback ccb = new ServiceCallback() { @Override public void onSuccess(final CommentsFetchResponse result) { // Log.d(TAG, "onSuccess: " + result); if (result == null) { rootList.postValue(Resource.error(R.string.generic_null_response, getPrevList(rootList))); return; } List comments = result.getComments(); if (rootCursor == null) { rootCount.postValue(result.getCommentCount()); } if (rootCursor != null) { comments = mergeList(rootList, comments); } rootCursor = result.getNextMinId(); rootHasNext = !TextUtils.isEmpty(rootCursor); rootList.postValue(Resource.success(comments)); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "onFailure: ", t); rootList.postValue(Resource.error(t.getMessage(), getPrevList(rootList))); } }; private final ServiceCallback rcb = new ServiceCallback() { @Override public void onSuccess(final ChildCommentsFetchResponse result) { // Log.d(TAG, "onSuccess: " + result); if (result == null) { rootList.postValue(Resource.error(R.string.generic_null_response, getPrevList(replyList))); return; } List comments = result.getChildComments(); // Replies if (repliesCursor == null) { // add parent to top of replies comments = ImmutableList.builder() .add(repliesParent) .addAll(comments) .build(); } if (repliesCursor != null) { comments = mergeList(replyList, comments); } repliesCursor = result.getNextMaxChildCursor(); repliesHasNext = result.getHasMoreTailChildComments(); replyList.postValue(Resource.success(comments)); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "onFailure: ", t); replyList.postValue(Resource.error(t.getMessage(), getPrevList(replyList))); } }; public CommentsViewerViewModel() { graphQLRepository = GraphQLRepository.Companion.getInstance(); final String cookie = settingsHelper.getString(Constants.COOKIE); final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); final long userIdFromCookie = CookieUtils.getUserIdFromCookie(cookie); commentService = CommentService.getInstance(deviceUuid, csrfToken, userIdFromCookie); } public void setCurrentUser(final User currentUser) { currentUserId.postValue(currentUser == null ? 0 : currentUser.getPk()); } public void setPostDetails(final String shortCode, final String postId, final long postUserId) { this.shortCode = shortCode; this.postId = postId; } public LiveData getCurrentUserId() { return currentUserId; } @Nullable public Comment getRepliesParent() { return repliesParent; } @Nullable public void setReplyTo(final Comment replyTo) { this.replyTo = replyTo; } public LiveData>> getRootList() { return rootList; } public LiveData>> getReplyList() { return replyList; } public LiveData getRootCommentsCount() { return rootCount; } public void fetchComments() { if (shortCode == null || postId == null) return; if (!rootHasNext) return; rootList.postValue(Resource.loading(getPrevList(rootList))); if (currentUserId.getValue() != 0L) { commentService.fetchComments(postId, rootCursor, ccb); return; } graphQLRepository.fetchComments( shortCode, true, rootCursor, enqueueRequest(true, shortCode, ccb) ); } public void fetchReplies() { if (repliesParent == null) return; fetchReplies(repliesParent.getPk()); } public void fetchReplies(@NonNull final String commentId) { if (!repliesHasNext) return; final List list; if (repliesParent != null && !Objects.equals(repliesParent.getPk(), commentId)) { repliesCursor = null; repliesHasNext = false; list = Collections.emptyList(); } else { list = getPrevList(replyList); } replyList.postValue(Resource.loading(list)); if (currentUserId.getValue() != 0L) { commentService.fetchChildComments(postId, commentId, repliesCursor, rcb); return; } graphQLRepository.fetchComments(commentId, false, repliesCursor, enqueueRequest(false, commentId, rcb)); } private Continuation enqueueRequest(final boolean root, final String shortCodeOrCommentId, @SuppressWarnings("rawtypes") final ServiceCallback callback) { return CoroutineUtilsKt.getContinuation((response, throwable) -> { if (throwable != null) { callback.onFailure(throwable); return; } if (response == null) { Log.e(TAG, "Error occurred while fetching gql comments of " + shortCodeOrCommentId); //noinspection unchecked callback.onSuccess(null); return; } try { final JSONObject body = root ? new JSONObject(response).getJSONObject("data") .getJSONObject("shortcode_media") .getJSONObject("edge_media_to_parent_comment") : new JSONObject(response).getJSONObject("data") .getJSONObject("comment") .getJSONObject("edge_threaded_comments"); final int count = body.optInt("count"); final JSONObject pageInfo = body.getJSONObject("page_info"); final boolean hasNextPage = pageInfo.getBoolean("has_next_page"); final String endCursor = pageInfo.isNull("end_cursor") || !hasNextPage ? null : pageInfo.optString("end_cursor"); final JSONArray commentsJsonArray = body.getJSONArray("edges"); final ImmutableList.Builder builder = ImmutableList.builder(); for (int i = 0; i < commentsJsonArray.length(); i++) { final Comment commentModel = getComment(commentsJsonArray.getJSONObject(i).getJSONObject("node"), root); builder.add(commentModel); } final Object result = root ? new CommentsFetchResponse(count, endCursor, builder.build()) : new ChildCommentsFetchResponse(count, endCursor, builder.build(), hasNextPage); //noinspection unchecked callback.onSuccess(result); } catch (Exception e) { Log.e(TAG, "onResponse", e); callback.onFailure(e); } }, Dispatchers.getIO()); } @NonNull private Comment getComment(@NonNull final JSONObject commentJsonObject, final boolean root) throws JSONException { final JSONObject owner = commentJsonObject.getJSONObject("owner"); final User user = new User( owner.optLong(Constants.EXTRAS_ID, 0), owner.getString(Constants.EXTRAS_USERNAME), null, false, owner.getString("profile_pic_url"), owner.optBoolean("is_verified")); final JSONObject likedBy = commentJsonObject.optJSONObject("edge_liked_by"); final String commentId = commentJsonObject.getString("id"); final JSONObject childCommentsJsonObject = commentJsonObject.optJSONObject("edge_threaded_comments"); int replyCount = 0; if (childCommentsJsonObject != null) { replyCount = childCommentsJsonObject.optInt("count"); } return new Comment(commentId, commentJsonObject.getString("text"), commentJsonObject.getLong("created_at"), likedBy != null ? likedBy.optLong("count", 0) : 0, commentJsonObject.getBoolean("viewer_has_liked"), user, replyCount); } @NonNull private List getPrevList(@NonNull final LiveData>> list) { if (list.getValue() == null) return Collections.emptyList(); final Resource> listResource = list.getValue(); if (listResource.data == null) return Collections.emptyList(); return listResource.data; } private List mergeList(@NonNull final LiveData>> list, final List comments) { final List prevList = getPrevList(list); if (comments == null) { return prevList; } return ImmutableList.builder() .addAll(prevList) .addAll(comments) .build(); } public void showReplies(final Comment comment) { if (comment == null) return; if (repliesParent == null || !Objects.equals(repliesParent.getPk(), comment.getPk())) { repliesParent = comment; replyTo = comment; prevReplies = null; prevRepliesCursor = null; prevRepliesHasNext = true; fetchReplies(comment.getPk()); return; } if (prevReplies != null && !prevReplies.isEmpty()) { // user clicked same comment, show prev loaded replies repliesCursor = prevRepliesCursor; repliesHasNext = prevRepliesHasNext; replyList.postValue(Resource.success(prevReplies)); return; } // prev list was null or empty, fetch prevRepliesCursor = null; prevRepliesHasNext = true; fetchReplies(comment.getPk()); } public LiveData> likeComment(@NonNull final Comment comment, final boolean liked, final boolean isReply) { final MutableLiveData> data = new MutableLiveData<>(Resource.loading(null)); final ServiceCallback callback = new ServiceCallback() { @Override public void onSuccess(final Boolean result) { if (result == null || !result) { data.postValue(Resource.error(R.string.downloader_unknown_error, null)); return; } data.postValue(Resource.success(new Object())); setLiked(isReply, comment, liked); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error liking comment", t); data.postValue(Resource.error(t.getMessage(), null)); } }; if (liked) { commentService.commentLike(comment.getPk(), callback); } else { commentService.commentUnlike(comment.getPk(), callback); } return data; } private void setLiked(final boolean isReply, @NonNull final Comment comment, final boolean liked) { final List list = getPrevList(isReply ? replyList : rootList); if (list == null) return; final List copy = new ArrayList<>(list); OptionalInt indexOpt = IntStream.range(0, copy.size()) .filter(i -> copy.get(i) != null && Objects.equals(copy.get(i).getPk(), comment.getPk())) .findFirst(); if (!indexOpt.isPresent()) return; try { final Comment clone = (Comment) comment.clone(); clone.setLiked(liked); copy.set(indexOpt.getAsInt(), clone); final MutableLiveData>> liveData = isReply ? replyList : rootList; liveData.postValue(Resource.success(copy)); } catch (Exception e) { Log.e(TAG, "setLiked: ", e); } } public LiveData> comment(@NonNull final String text, final boolean isReply) { final MutableLiveData> data = new MutableLiveData<>(Resource.loading(null)); String replyToId = null; if (isReply && replyTo != null) { replyToId = replyTo.getPk(); } if (isReply && replyToId == null) { data.postValue(Resource.error(null, null)); return data; } commentService.comment(postId, text, replyToId, new ServiceCallback() { @Override public void onSuccess(final Comment result) { if (result == null) { data.postValue(Resource.error(R.string.downloader_unknown_error, null)); return; } addComment(result, isReply); data.postValue(Resource.success(new Object())); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error during comment", t); data.postValue(Resource.error(t.getMessage(), null)); } }); return data; } private void addComment(@NonNull final Comment comment, final boolean isReply) { final List list = getPrevList(isReply ? replyList : rootList); final ImmutableList.Builder builder = ImmutableList.builder(); if (isReply) { // replies are added to the bottom of the list to preserve chronological order builder.addAll(list) .add(comment); } else { builder.add(comment) .addAll(list); } final MutableLiveData>> liveData = isReply ? replyList : rootList; liveData.postValue(Resource.success(builder.build())); } public void translate(@NonNull final Comment comment, @NonNull final ServiceCallback callback) { commentService.translate(comment.getPk(), callback); } public LiveData> deleteComment(@NonNull final Comment comment, final boolean isReply) { final MutableLiveData> data = new MutableLiveData<>(Resource.loading(null)); commentService.deleteComment(postId, comment.getPk(), new ServiceCallback() { @Override public void onSuccess(final Boolean result) { if (result == null || !result) { data.postValue(Resource.error(R.string.downloader_unknown_error, null)); return; } removeComment(comment, isReply); data.postValue(Resource.success(new Object())); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "Error deleting comment", t); data.postValue(Resource.error(t.getMessage(), null)); } }); return data; } private void removeComment(@NonNull final Comment comment, final boolean isReply) { final List list = getPrevList(isReply ? replyList : rootList); final List updated = list.stream() .filter(Objects::nonNull) .filter(c -> !Objects.equals(c.getPk(), comment.getPk())) .collect(Collectors.toList()); final MutableLiveData>> liveData = isReply ? replyList : rootList; liveData.postValue(Resource.success(updated)); } public void clearReplies() { prevRepliesCursor = repliesCursor; prevRepliesHasNext = repliesHasNext; repliesCursor = null; repliesHasNext = true; // cache prev reply list to save time and data if user clicks same comment again prevReplies = getPrevList(replyList); replyList.postValue(Resource.success(Collections.emptyList())); } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/DirectInboxViewModel.kt ================================================ package awais.instagrabber.viewmodels import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import awais.instagrabber.managers.DirectMessagesManager import awais.instagrabber.managers.InboxManager import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.DirectInbox import awais.instagrabber.repositories.responses.directmessages.DirectThread class DirectInboxViewModel : ViewModel() { private val inboxManager: InboxManager = DirectMessagesManager.inboxManager val inbox: LiveData> = inboxManager.getInbox() val threads: LiveData> = inboxManager.threads val unseenCount: LiveData> = inboxManager.getUnseenCount() val pendingRequestsTotal: LiveData = inboxManager.getPendingRequestsTotal() val viewer: User? = inboxManager.viewer fun fetchInbox() { inboxManager.fetchInbox(viewModelScope) } fun refresh() { inboxManager.refresh(viewModelScope) } fun onDestroy() { inboxManager.onDestroy() } init { inboxManager.fetchInbox(viewModelScope) inboxManager.fetchUnseenCount(viewModelScope) } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/DirectPendingInboxViewModel.kt ================================================ package awais.instagrabber.viewmodels import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import awais.instagrabber.managers.DirectMessagesManager.pendingInboxManager import awais.instagrabber.managers.InboxManager import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.DirectInbox import awais.instagrabber.repositories.responses.directmessages.DirectThread class DirectPendingInboxViewModel : ViewModel() { private val inboxManager: InboxManager = pendingInboxManager val threads: LiveData> = inboxManager.threads val inbox: LiveData> = inboxManager.getInbox() val viewer: User? = inboxManager.viewer fun fetchInbox() { inboxManager.fetchInbox(viewModelScope) } fun refresh() { inboxManager.refresh(viewModelScope) } fun onDestroy() { inboxManager.onDestroy() } init { inboxManager.fetchInbox(viewModelScope) } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/DirectSettingsViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.app.Application import androidx.annotation.StringRes import androidx.core.util.Pair import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import awais.instagrabber.R import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option import awais.instagrabber.managers.DirectMessagesManager import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.DirectThread import awais.instagrabber.repositories.responses.directmessages.DirectThreadParticipantRequestsResponse import awais.instagrabber.utils.Constants import awais.instagrabber.utils.Utils import awais.instagrabber.utils.getCsrfTokenFromCookie import awais.instagrabber.utils.getUserIdFromCookie class DirectSettingsViewModel( application: Application, threadId: String, pending: Boolean, currentUser: User, ) : AndroidViewModel(application) { private val viewerId: Long private val resources = application.resources private val threadManager = DirectMessagesManager.getThreadManager(threadId, pending, currentUser, application.contentResolver) val thread: LiveData = threadManager.thread // public void setThread(@NonNull final DirectThread thread) { // this.thread = thread; // inputMode.postValue(thread.getInputMode()); // List users = thread.getUsers(); // final ImmutableList.Builder builder = ImmutableList.builder().add(currentUser); // if (users != null) { // builder.addAll(users); // } // users = builder.build(); // this.users.postValue(new Pair<>(users, thread.getLeftUsers())); // // setTitle(thread.getThreadTitle()); // final List adminUserIds = thread.getAdminUserIds(); // this.adminUserIds.postValue(adminUserIds); // viewerIsAdmin = adminUserIds.contains(viewerId); // muted.postValue(thread.getMuted()); // mentionsMuted.postValue(thread.isMentionsMuted()); // approvalRequiredToJoin.postValue(thread.isApprovalRequiredForNewMembers()); // isPending.postValue(thread.isPending()); // if (thread.getInputMode() != 1 && thread.isGroup() && viewerIsAdmin) { // fetchPendingRequests(); // } // } val inputMode: LiveData = threadManager.inputMode fun isGroup(): LiveData = threadManager.isGroup fun getUsers(): LiveData> = threadManager.usersWithCurrent fun getLeftUsers(): LiveData> = threadManager.leftUsers fun getUsersAndLeftUsers(): LiveData, List>> = threadManager.usersAndLeftUsers fun getTitle(): LiveData = threadManager.threadTitle // public void setTitle(final String title) { // if (title == null) { // this.title.postValue(""); // return; // } // this.title.postValue(title.trim()); // } fun getAdminUserIds(): LiveData> = threadManager.adminUserIds fun isMuted(): LiveData = threadManager.isMuted fun getApprovalRequiredToJoin(): LiveData = threadManager.isApprovalRequiredToJoin fun getPendingRequests(): LiveData = threadManager.pendingRequests fun isPending(): LiveData = threadManager.isPending fun isViewerAdmin(): LiveData = threadManager.isViewerAdmin fun updateTitle(newTitle: String): LiveData> = threadManager.updateTitle(newTitle, viewModelScope) fun addMembers(users: Set): LiveData> = threadManager.addMembers(users, viewModelScope) fun removeMember(user: User): LiveData> = threadManager.removeMember(user, viewModelScope) private fun makeAdmin(user: User): LiveData> = threadManager.makeAdmin(user, viewModelScope) private fun removeAdmin(user: User): LiveData> = threadManager.removeAdmin(user, viewModelScope) fun mute(): LiveData> = threadManager.mute(viewModelScope) fun unmute(): LiveData> = threadManager.unmute(viewModelScope) fun muteMentions(): LiveData> = threadManager.muteMentions(viewModelScope) fun unmuteMentions(): LiveData> = threadManager.unmuteMentions(viewModelScope) private fun blockUser(user: User): LiveData> = threadManager.blockUser(user, viewModelScope) private fun unblockUser(user: User): LiveData> = threadManager.unblockUser(user, viewModelScope) private fun restrictUser(user: User): LiveData> = threadManager.restrictUser(user, viewModelScope) private fun unRestrictUser(user: User): LiveData> = threadManager.unRestrictUser(user, viewModelScope) fun approveUsers(users: List): LiveData> = threadManager.approveUsers(users, viewModelScope) fun denyUsers(users: List): LiveData> = threadManager.denyUsers(users, viewModelScope) fun approvalRequired(): LiveData> = threadManager.approvalRequired(viewModelScope) fun approvalNotRequired(): LiveData> = threadManager.approvalNotRequired(viewModelScope) fun leave(): LiveData> = threadManager.leave(viewModelScope) fun end(): LiveData> = threadManager.end(viewModelScope) fun createUserOptions(user: User?): ArrayList> { val options: ArrayList> = ArrayList() if (user == null || isSelf(user) || hasLeft(user)) { return options } val viewerIsAdmin: Boolean? = threadManager.isViewerAdmin.value if (viewerIsAdmin != null && viewerIsAdmin) { options.add(Option(getString(R.string.dms_action_kick), ACTION_KICK)) val isAdmin: Boolean = threadManager.isAdmin(user) options.add(Option( if (isAdmin) getString(R.string.dms_action_remove_admin) else getString(R.string.dms_action_make_admin), if (isAdmin) ACTION_REMOVE_ADMIN else ACTION_MAKE_ADMIN )) } val blocking: Boolean = user.friendshipStatus?.blocking ?: false options.add(Option( if (blocking) getString(R.string.unblock) else getString(R.string.block), if (blocking) ACTION_UNBLOCK else ACTION_BLOCK )) // options.add(new Option<>(getString(R.string.report), ACTION_REPORT)); val isGroup: Boolean? = threadManager.isGroup.value if (isGroup != null && isGroup) { val restricted: Boolean = user.friendshipStatus?.isRestricted ?: false options.add(Option( if (restricted) getString(R.string.unrestrict) else getString(R.string.restrict), if (restricted) ACTION_UNRESTRICT else ACTION_RESTRICT )) } return options } private fun hasLeft(user: User): Boolean { val leftUsers: List = getLeftUsers().value ?: return false return leftUsers.contains(user) } private fun isSelf(user: User): Boolean = user.pk == viewerId private fun getString(@StringRes resId: Int): String { return resources.getString(resId) } fun doAction(user: User?, action: String?): LiveData>? { return if (user == null || action == null) null else when (action) { ACTION_KICK -> removeMember(user) ACTION_MAKE_ADMIN -> makeAdmin(user) ACTION_REMOVE_ADMIN -> removeAdmin(user) ACTION_BLOCK -> blockUser(user) ACTION_UNBLOCK -> unblockUser(user) ACTION_RESTRICT -> restrictUser(user) ACTION_UNRESTRICT -> unRestrictUser(user) else -> null } } fun getInviter(): LiveData = threadManager.inviter companion object { private const val ACTION_KICK = "kick" private const val ACTION_MAKE_ADMIN = "make_admin" private const val ACTION_REMOVE_ADMIN = "remove_admin" private const val ACTION_BLOCK = "block" private const val ACTION_UNBLOCK = "unblock" // private static final String ACTION_REPORT = "report"; private const val ACTION_RESTRICT = "restrict" private const val ACTION_UNRESTRICT = "unrestrict" } init { val cookie = Utils.settingsHelper.getString(Constants.COOKIE) viewerId = getUserIdFromCookie(cookie) val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) val csrfToken = getCsrfTokenFromCookie(cookie) require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/DirectThreadViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.app.Application import android.content.ContentResolver import android.net.Uri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.* import awais.instagrabber.customviews.emoji.Emoji import awais.instagrabber.managers.DirectMessagesManager import awais.instagrabber.managers.DirectMessagesManager.inboxManager import awais.instagrabber.managers.ThreadManager import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.success import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.DirectItem import awais.instagrabber.repositories.responses.directmessages.DirectThread import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.repositories.responses.giphy.GiphyGif import awais.instagrabber.utils.* import awais.instagrabber.utils.MediaUtils.OnInfoLoadListener import awais.instagrabber.utils.MediaUtils.VideoInfo import awais.instagrabber.utils.VoiceRecorder.VoiceRecorderCallback import awais.instagrabber.utils.VoiceRecorder.VoiceRecordingResult class DirectThreadViewModel( application: Application, val threadId: String, pending: Boolean, val currentUser: User, ) : AndroidViewModel(application) { // private val TAG = DirectThreadViewModel::class.java.simpleName // private static final String ERROR_INVALID_THREAD = "Invalid thread"; private val contentResolver: ContentResolver = application.contentResolver private val recordingsDir: DocumentFile? = DownloadUtils.recordingsDir private var voiceRecorder: VoiceRecorder? = null private lateinit var threadManager: ThreadManager val viewerId: Long val threadTitle: LiveData by lazy { threadManager.threadTitle } val thread: LiveData by lazy { threadManager.thread } val items: LiveData> by lazy { Transformations.map(threadManager.items) { it.filter { thread -> thread.hideInThread == 0 } } } val isFetching: LiveData> by lazy { threadManager.fetching } val users: LiveData> by lazy { threadManager.users } val leftUsers: LiveData> by lazy { threadManager.leftUsers } val pendingRequestsCount: LiveData by lazy { threadManager.pendingRequestsCount } val inputMode: LiveData by lazy { threadManager.inputMode } val isPending: LiveData by lazy { threadManager.isPending } val replyToItem: LiveData by lazy { threadManager.replyToItem } fun moveFromPending() { val messagesManager = DirectMessagesManager messagesManager.moveThreadFromPending(threadId) threadManager = messagesManager.getThreadManager(threadId, false, currentUser, contentResolver) } fun removeThread() { threadManager.removeThread() } fun fetchChats() { threadManager.fetchChats(viewModelScope) } fun refreshChats() { threadManager.refreshChats(viewModelScope) } fun sendText(text: String): LiveData> { return threadManager.sendText(text, viewModelScope) } fun sendUri(uri: Uri): LiveData> { return threadManager.sendUri(uri, viewModelScope) } fun startRecording(): LiveData> { val data = MutableLiveData>() voiceRecorder = VoiceRecorder(recordingsDir!!, object : VoiceRecorderCallback { override fun onStart() {} override fun onComplete(result: VoiceRecordingResult) { // Log.d(TAG, "onComplete: recording complete. Scanning file..."); MediaUtils.getVoiceInfo( contentResolver, result.file.uri, object : OnInfoLoadListener { override fun onLoad(videoInfo: VideoInfo?) { if (videoInfo == null) return threadManager.sendVoice( data, result.file.uri, result.waveform, result.samplingFreq, videoInfo.duration, result.file.length(), viewModelScope ) } override fun onFailure(t: Throwable) { data.postValue(error(t.message, null)) } }) } override fun onCancel() {} }) voiceRecorder?.startRecording(contentResolver) return data } fun stopRecording(delete: Boolean) { voiceRecorder?.stopRecording(delete) voiceRecorder = null } fun sendReaction(item: DirectItem, emoji: Emoji): LiveData> { return threadManager.sendReaction(item, emoji, viewModelScope) } fun sendDeleteReaction(itemId: String): LiveData> { return threadManager.sendDeleteReaction(itemId, viewModelScope) } fun unsend(item: DirectItem): LiveData> { return threadManager.unsend(item, viewModelScope) } fun sendAnimatedMedia(giphyGif: GiphyGif): LiveData> { return threadManager.sendAnimatedMedia(giphyGif, viewModelScope) } fun getUser(userId: Long): User? { var match: User? = null users.value?.let { match = it.firstOrNull { user -> user.pk == userId } } if (match == null) { leftUsers.value?.let { match = it.firstOrNull { user -> user.pk == userId } } } return match } fun forward(recipients: Set, itemToForward: DirectItem) { threadManager.forward(recipients, itemToForward, viewModelScope) } fun forward(recipient: RankedRecipient, itemToForward: DirectItem) { threadManager.forward(recipient, itemToForward, viewModelScope) } fun setReplyToItem(item: DirectItem?) { // Log.d(TAG, "setReplyToItem: " + item); threadManager.setReplyToItem(item) } fun acceptRequest(): LiveData> { return threadManager.acceptRequest(viewModelScope) } fun declineRequest(): LiveData> { return threadManager.declineRequest(viewModelScope) } fun markAsSeen(): LiveData> { val thread = thread.value ?: return successEventResObjectLiveData val items = thread.items if (items.isNullOrEmpty()) return successEventResObjectLiveData val directItem = items.firstOrNull { (_, userId) -> userId != currentUser.pk } ?: return successEventResObjectLiveData val lastSeenAt = thread.lastSeenAt ?: return threadManager.markAsSeen(directItem, viewModelScope) val seenAt = lastSeenAt[currentUser.pk] ?: return threadManager.markAsSeen(directItem, viewModelScope) try { val timestamp = seenAt.timestamp ?: return threadManager.markAsSeen(directItem, viewModelScope) val itemIdMatches = seenAt.itemId == directItem.itemId val timestampMatches = timestamp.toLong() >= directItem.getTimestamp() if (itemIdMatches || timestampMatches) { return successEventResObjectLiveData } return threadManager.markAsSeen(directItem, viewModelScope) } catch (ignored: Exception) { return successEventResObjectLiveData } } private val successEventResObjectLiveData: MutableLiveData> get() { val data = MutableLiveData>() data.postValue(success(Any())) return data } fun deleteThreadIfRequired() { val thread = thread.value ?: return if (thread.isTemp && thread.items.isNullOrEmpty()) { val inboxManager = inboxManager inboxManager.removeThread(threadId) } } init { val cookie = Utils.settingsHelper.getString(Constants.COOKIE) viewerId = getUserIdFromCookie(cookie) val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) val csrfToken = getCsrfTokenFromCookie(cookie) require(!csrfToken.isNullOrBlank() && viewerId != 0L && deviceUuid.isNotBlank()) { "User is not logged in!" } threadManager = DirectMessagesManager.getThreadManager(threadId, pending, currentUser, contentResolver) threadManager.fetchPendingRequests(viewModelScope) } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/DirectorySelectActivityViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.app.Application import android.content.Intent import android.content.UriPermission import android.net.Uri import android.os.Parcelable import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import awais.instagrabber.R import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.utils.Constants import awais.instagrabber.utils.DownloadUtils.ReselectDocumentTreeException import awais.instagrabber.utils.TextUtils.isEmpty import awais.instagrabber.utils.Utils import java.io.UnsupportedEncodingException import java.net.URLDecoder import java.nio.charset.StandardCharsets class DirectorySelectActivityViewModel(application: Application) : AndroidViewModel(application) { private val _message = MutableLiveData() private val _prevUri = MutableLiveData() private val _loading = MutableLiveData(false) private val _dirSuccess = MutableLiveData(false) val message: LiveData = _message val prevUri: LiveData = _prevUri val loading: LiveData = _loading val dirSuccess: LiveData = _dirSuccess fun setInitialUri(intent: Intent?) { if (intent == null) { setMessage(null) return } val initialUriParcelable = intent.getParcelableExtra(Constants.EXTRA_INITIAL_URI) if (initialUriParcelable !is Uri) { setMessage(null) return } setMessage(initialUriParcelable as Uri?) } private fun setMessage(initialUri: Uri?) { if (initialUri == null) { val prevVersionFolderPath = Utils.settingsHelper.getString(PreferenceKeys.FOLDER_PATH) if (isEmpty(prevVersionFolderPath)) { // default message _message.postValue(getApplication().getString(R.string.dir_select_default_message)) _prevUri.postValue(null) return } _message.postValue(getApplication().getString(R.string.dir_select_reselect_message)) _prevUri.postValue(prevVersionFolderPath) return } val existingPermissions = getApplication().contentResolver.persistedUriPermissions val anyMatch = existingPermissions.stream().anyMatch { uriPermission: UriPermission -> uriPermission.uri == initialUri } val documentFile = DocumentFile.fromSingleUri(getApplication(), initialUri) val path: String = try { URLDecoder.decode(initialUri.toString(), StandardCharsets.UTF_8.toString()) } catch (e: UnsupportedEncodingException) { initialUri.toString() } if (!anyMatch) { _message.postValue(getApplication().getString(R.string.dir_select_permission_revoked_message)) _prevUri.postValue(path) return } if (documentFile == null || !documentFile.exists() || documentFile.lastModified() == 0L) { _message.postValue(getApplication().getString(R.string.dir_select_folder_not_exist)) _prevUri.postValue(path) } } @Throws(ReselectDocumentTreeException::class) fun setupSelectedDir(data: Intent) { _loading.postValue(true) try { Utils.setupSelectedDir(getApplication(), data) _message.postValue(getApplication().getString(R.string.dir_select_success_message)) _dirSuccess.postValue(true) } finally { _loading.postValue(false) } } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/FavoritesViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import awais.instagrabber.db.entities.Favorite import awais.instagrabber.db.repositories.FavoriteRepository import awais.instagrabber.utils.extensions.TAG import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class FavoritesViewModel(application: Application) : AndroidViewModel(application) { private val _list = MutableLiveData>() val list: LiveData> = _list private val favoriteRepository: FavoriteRepository = FavoriteRepository.getInstance(application) init { fetch() } fun fetch() { viewModelScope.launch(Dispatchers.IO) { try { _list.postValue(favoriteRepository.getAllFavorites()) } catch (e: Exception) { Log.e(TAG, "fetch: ", e) } } } fun delete(favorite: Favorite, onSuccess: () -> Unit) { viewModelScope.launch(Dispatchers.IO) { try { favoriteRepository.deleteFavorite(favorite.query, favorite.type) withContext(Dispatchers.Main) { onSuccess() } _list.postValue(favoriteRepository.getAllFavorites()) } catch (e: Exception) { Log.e(TAG, "delete: ", e) } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/FeedStoriesViewModel.java ================================================ package awais.instagrabber.viewmodels; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import java.util.List; import awais.instagrabber.repositories.responses.stories.Story; public class FeedStoriesViewModel extends ViewModel { private MutableLiveData> list; public MutableLiveData> getList() { if (list == null) { list = new MutableLiveData<>(); } return list; } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/FileListViewModel.java ================================================ package awais.instagrabber.viewmodels; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import java.io.File; import java.util.List; public class FileListViewModel extends ViewModel { private MutableLiveData> list; public MutableLiveData> getList() { if (list == null) { list = new MutableLiveData<>(); } return list; } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/FiltersFragmentViewModel.java ================================================ package awais.instagrabber.viewmodels; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; public class FiltersFragmentViewModel extends ViewModel { private final MutableLiveData loading = new MutableLiveData<>(false); private final MutableLiveData currentTab = new MutableLiveData<>(); public FiltersFragmentViewModel() { } public LiveData isLoading() { return loading; } public LiveData getCurrentTab() { return currentTab; } public void setCurrentTab(final ImageEditViewModel.Tab tab) { if (tab == null) return; currentTab.postValue(tab); } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/FollowViewModel.kt ================================================ package awais.instagrabber.viewmodels import androidx.lifecycle.* import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.webservices.FriendshipRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class FollowViewModel : ViewModel() { // data val userId = MutableLiveData() private val followers = MutableLiveData>() private val followings = MutableLiveData>() private val searchResults = MutableLiveData>() // cursors private val followersMaxId = MutableLiveData("") private val followingMaxId = MutableLiveData("") private val searchingMaxId = MutableLiveData("") private val searchQuery = MutableLiveData() // comparison val status: LiveData> = object : MediatorLiveData>() { init { postValue(Pair(false, false)) addSource(followersMaxId) { if (it == null) { postValue(Pair(true, value!!.second)) } else fetch(true, it) } addSource(followingMaxId) { if (it == null) { postValue(Pair(value!!.first, true)) } else fetch(false, it) } } } val comparison: LiveData, List, List>> = object : MediatorLiveData, List, List>>() { init { addSource(status) { if (it.first && it.second) { val followersList = followers.value!! val followingList = followings.value!! val allUsers: MutableList = mutableListOf() allUsers.addAll(followersList) allUsers.addAll(followingList) val followersMap = followersList.groupBy { it.pk } val followingMap = followingList.groupBy { it.pk } val mutual: MutableList = mutableListOf() val onlyFollowing: MutableList = mutableListOf() val onlyFollowers: MutableList = mutableListOf() allUsers.forEach { val isFollowing = followingMap.get(it.pk) != null val isFollower = followersMap.get(it.pk) != null if (isFollowing && isFollower) mutual.add(it) else if (isFollowing) onlyFollowing.add(it) else if (isFollower) onlyFollowers.add(it) } postValue(Triple(mutual, onlyFollowing, onlyFollowers)) } } } } private val friendshipRepository: FriendshipRepository by lazy { FriendshipRepository.getInstance() } // fetch: supply max ID for continuous fetch fun fetch(follower: Boolean, nextMaxId: String?): LiveData> { val data = MutableLiveData>() data.postValue(Resource.loading(null)) val maxId = if (follower) followersMaxId else followingMaxId if (maxId.value == null && nextMaxId == null) data.postValue(Resource.success(null)) else if (userId.value == null) data.postValue(Resource.error("No user ID supplied!", null)) else viewModelScope.launch(Dispatchers.IO) { try { val tempList = friendshipRepository.getList( follower, userId.value!!, nextMaxId ?: maxId.value, null ) if (!tempList.status.equals("ok")) { data.postValue(Resource.error("Status not ok!", null)) } else { if (tempList.users != null) { val liveData = if (follower) followers else followings val currentList = if (liveData.value != null) liveData.value!!.toMutableList() else mutableListOf() currentList.addAll(tempList.users!!) liveData.postValue(currentList.toList()) } maxId.postValue(tempList.nextMaxId) data.postValue(Resource.success(null)) } } catch (e: Exception) { data.postValue(Resource.error(e.message, null)) } } return data } fun getList(follower: Boolean): LiveData> { return if (follower) followers else followings } fun search(follower: Boolean): LiveData> { val data = MutableLiveData>() data.postValue(Resource.loading(null)) val query = searchQuery.value if (searchingMaxId.value == null) data.postValue(Resource.success(null)) else if (userId.value == null) data.postValue(Resource.error("No user ID supplied!", null)) else if (query.isNullOrEmpty()) data.postValue(Resource.error("No query supplied!", null)) else viewModelScope.launch(Dispatchers.IO) { try { val tempList = friendshipRepository.getList( follower, userId.value!!, searchingMaxId.value, query ) if (!tempList.status.equals("ok")) { data.postValue(Resource.error("Status not ok!", null)) } else { if (tempList.users != null) { val currentList = if (searchResults.value != null) searchResults.value!!.toMutableList() else mutableListOf() currentList.addAll(tempList.users!!) searchResults.postValue(currentList.toList()) } searchingMaxId.postValue(tempList.nextMaxId) data.postValue(Resource.success(null)) } } catch (e: Exception) { data.postValue(Resource.error(e.message, null)) } } return data } fun getSearch(): LiveData> { return searchResults } fun setQuery(query: String?, follower: Boolean) { searchQuery.value = query if (!query.isNullOrEmpty()) search(follower) } fun clearProgress() { followersMaxId.value = "" followingMaxId.value = "" searchingMaxId.value = "" followings.value = listOf() followers.value = listOf() searchResults.value = listOf() } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/GifPickerViewModel.java ================================================ package awais.instagrabber.viewmodels; import android.util.Log; import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.AnimatedMediaFixedHeight; import awais.instagrabber.repositories.responses.giphy.GiphyGif; import awais.instagrabber.repositories.responses.giphy.GiphyGifImages; import awais.instagrabber.repositories.responses.giphy.GiphyGifResponse; import awais.instagrabber.repositories.responses.giphy.GiphyGifResults; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.webservices.GifService; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class GifPickerViewModel extends ViewModel { private static final String TAG = GifPickerViewModel.class.getSimpleName(); private final MutableLiveData>> images = new MutableLiveData<>(Resource.success(Collections.emptyList())); private final GifService gifService; private Call searchRequest; public GifPickerViewModel() { gifService = GifService.getInstance(); search(null); } public LiveData>> getImages() { return images; } public void search(final String query) { final Resource> currentValue = images.getValue(); if (currentValue != null && currentValue.status == Resource.Status.LOADING) { cancelSearchRequest(); } images.postValue(Resource.loading(getCurrentImages())); searchRequest = gifService.searchGiphyGifs(query, query != null); searchRequest.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (response.isSuccessful()) { parseResponse(response); return; } if (response.errorBody() != null) { try { final String string = response.errorBody().string(); final String msg = String.format(Locale.US, "onResponse: url: %s, responseCode: %d, errorBody: %s", call.request().url().toString(), response.code(), string); images.postValue(Resource.error(msg, getCurrentImages())); Log.e(TAG, msg); } catch (IOException e) { images.postValue(Resource.error(e.getMessage(), getCurrentImages())); Log.e(TAG, "onResponse: ", e); } } images.postValue(Resource.error(R.string.generic_failed_request, getCurrentImages())); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { images.postValue(Resource.error(t.getMessage(), getCurrentImages())); Log.e(TAG, "enqueueRequest: onFailure: ", t); } }); } private void parseResponse(final Response response) { final GiphyGifResponse giphyGifResponse = response.body(); if (giphyGifResponse == null) { images.postValue(Resource.error(R.string.generic_null_response, getCurrentImages())); return; } final GiphyGifResults results = giphyGifResponse.getResults(); images.postValue(Resource.success( ImmutableList.builder() .addAll(results.getGiphy() == null ? Collections.emptyList() : filterInvalid(results.getGiphy())) .addAll(results.getGiphyGifs() == null ? Collections.emptyList() : filterInvalid(results.getGiphyGifs())) .build() )); } private List filterInvalid(@NonNull final List giphyGifs) { return giphyGifs.stream() .filter(Objects::nonNull) .filter(giphyGif -> { final GiphyGifImages images = giphyGif.getImages(); if (images == null) return false; final AnimatedMediaFixedHeight fixedHeight = images.getFixedHeight(); if (fixedHeight == null) return false; return !TextUtils.isEmpty(fixedHeight.getWebp()); }) .collect(Collectors.toList()); } // @NonNull // private List getGiphyGifImages(@NonNull final List giphy) { // return giphy.stream() // .map(giphyGif -> { // final GiphyGifImages images = giphyGif.getImages(); // if (images == null) return null; // return images.getOriginal(); // }) // .filter(Objects::nonNull) // .collect(Collectors.toList()); // } private List getCurrentImages() { final Resource> value = images.getValue(); return value == null ? Collections.emptyList() : value.data; } public void cancelSearchRequest() { if (searchRequest == null) return; searchRequest.cancel(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/ImageEditViewModel.java ================================================ package awais.instagrabber.viewmodels; import android.app.Application; import android.graphics.RectF; import android.net.Uri; import androidx.annotation.NonNull; import androidx.documentfile.provider.DocumentFile; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import java.io.File; import java.time.format.DateTimeFormatter; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import awais.instagrabber.fragments.imageedit.filters.FiltersHelper.FilterType; import awais.instagrabber.fragments.imageedit.filters.filters.Filter; import awais.instagrabber.fragments.imageedit.filters.properties.Property; import awais.instagrabber.models.SavedImageEditState; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.SerializablePair; import awais.instagrabber.utils.Utils; import jp.co.cyberagent.android.gpuimage.GPUImage; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter; import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilterGroup; public class ImageEditViewModel extends AndroidViewModel { private static final String CROP = "crop"; private static final String RESULT = "result"; private static final String FILE_FORMAT = "yyyyMMddHHmmssSSS"; private static final String MIME_TYPE = Utils.mimeTypeMap.getMimeTypeFromExtension("jpg"); private static final DateTimeFormatter SIMPLE_DATE_FORMAT = DateTimeFormatter.ofPattern(FILE_FORMAT, Locale.US); private Uri originalUri; private SavedImageEditState savedImageEditState; private final String sessionId; private final Uri destinationUri; private final Uri cropDestinationUri; private final MutableLiveData loading = new MutableLiveData<>(false); private final MutableLiveData resultUri = new MutableLiveData<>(null); private final MutableLiveData currentTab = new MutableLiveData<>(Tab.RESULT); private final MutableLiveData isCropped = new MutableLiveData<>(false); private final MutableLiveData isTuned = new MutableLiveData<>(false); private final MutableLiveData isFiltered = new MutableLiveData<>(false); private final DocumentFile outputDir; private List> tuningFilters; private Filter appliedFilter; private final DocumentFile destinationFile; public ImageEditViewModel(final Application application) { super(application); sessionId = LocalDateTime.now().format(SIMPLE_DATE_FORMAT); outputDir = DownloadUtils.getImageEditDir(sessionId, application); destinationFile = outputDir.createFile(MIME_TYPE, RESULT + ".jpg"); destinationUri = destinationFile.getUri(); cropDestinationUri = outputDir.createFile(MIME_TYPE, CROP + ".jpg").getUri(); } public String getSessionId() { return sessionId; } public Uri getOriginalUri() { return originalUri; } public void setOriginalUri(final Uri originalUri) { if (originalUri == null) return; this.originalUri = originalUri; savedImageEditState = new SavedImageEditState(sessionId, originalUri.toString()); if (resultUri.getValue() == null) { resultUri.postValue(originalUri); } } public Uri getDestinationUri() { return destinationUri; } public Uri getCropDestinationUri() { return cropDestinationUri; } public LiveData isLoading() { return loading; } public LiveData getResultUri() { return resultUri; } public LiveData isCropped() { return isCropped; } public LiveData isTuned() { return isTuned; } public LiveData isFiltered() { return isFiltered; } public void setResultUri(final Uri uri) { if (uri == null) return; resultUri.postValue(uri); } public LiveData getCurrentTab() { return currentTab; } public void setCurrentTab(final Tab tab) { if (tab == null) return; this.currentTab.postValue(tab); } public SavedImageEditState getSavedImageEditState() { return savedImageEditState; } public void setCropResult(final float[] imageMatrixValues, final RectF cropRect) { savedImageEditState.setCropImageMatrixValues(imageMatrixValues); savedImageEditState.setCropRect(cropRect); isCropped.postValue(true); applyFilters(); } private void applyFilters() { final GPUImage gpuImage = new GPUImage(getApplication()); if ((tuningFilters != null && !tuningFilters.isEmpty()) || appliedFilter != null) { AppExecutors.INSTANCE.getTasksThread().submit(() -> { final List list = new ArrayList<>(); if (tuningFilters != null) { for (Filter tuningFilter : tuningFilters) { list.add(tuningFilter.getInstance()); } } if (appliedFilter != null) { list.add(appliedFilter.getInstance()); } gpuImage.setFilter(new GPUImageFilterGroup(list)); final Uri uri = cropDestinationUri != null ? cropDestinationUri : originalUri; gpuImage.setImage(uri); gpuImage.saveToPictures(new File(destinationUri.toString()), false, uri1 -> setResultUri(destinationUri)); }); return; } setResultUri(cropDestinationUri); } public void cancel() { delete(outputDir); } private void delete(@NonNull final DocumentFile file) { if (file.isDirectory()) { final DocumentFile[] files = file.listFiles(); if (files != null) { for (DocumentFile f : files) { delete(f); } } } file.delete(); } public void setAppliedFilters(final List> tuningFilters, final Filter filter) { this.tuningFilters = tuningFilters; this.appliedFilter = filter; if (savedImageEditState != null) { final HashMap> tuningFiltersMap = new HashMap<>(); for (final Filter tuningFilter : tuningFilters) { final SerializablePair> filterValuesMap = getFilterValuesMap(tuningFilter); tuningFiltersMap.put(filterValuesMap.first, filterValuesMap.second); } savedImageEditState.setAppliedTuningFilters(tuningFiltersMap); savedImageEditState.setAppliedFilter(getFilterValuesMap(filter)); } isTuned.postValue(!tuningFilters.isEmpty()); isFiltered.postValue(filter != null); setResultUri(destinationUri); } private SerializablePair> getFilterValuesMap(final Filter filter) { if (filter == null) return null; final FilterType type = filter.getType(); final Map> properties = filter.getProperties(); final Map propertyValueMap = new HashMap<>(); if (properties != null) { final Set>> entries = properties.entrySet(); for (final Map.Entry> entry : entries) { final Integer propId = entry.getKey(); final Property property = entry.getValue(); final Object value = property.getValue(); propertyValueMap.put(propId, value); } } return new SerializablePair<>(type, propertyValueMap); } // public File getDestinationFile() { // return destinationFile; // } public enum Tab { RESULT, CROP, TUNE, FILTERS } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/MediaViewModel.java ================================================ package awais.instagrabber.viewmodels; import android.util.Log; import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import java.util.ArrayList; import java.util.List; import awais.instagrabber.customviews.helpers.PostFetcher; import awais.instagrabber.fragments.settings.PreferenceKeys; import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.KeywordsFilterUtilsKt; import static awais.instagrabber.utils.Utils.settingsHelper; public class MediaViewModel extends ViewModel { private static final String TAG = MediaViewModel.class.getSimpleName(); private boolean refresh = true; private final PostFetcher postFetcher; private final MutableLiveData> list = new MutableLiveData<>(); public MediaViewModel(@NonNull final PostFetcher.PostFetchService postFetchService) { final FetchListener> fetchListener = new FetchListener>() { @Override public void onResult(final List result) { if (refresh) { list.postValue(filterResult(result, true)); refresh = false; return; } list.postValue(filterResult(result, false)); } @Override public void onFailure(final Throwable t) { Log.e(TAG, "onFailure: ", t); } }; postFetcher = new PostFetcher(postFetchService, fetchListener); } @NonNull private List filterResult(final List result, final boolean isRefresh) { final List models = list.getValue(); final List modelsCopy = models == null || isRefresh ? new ArrayList<>() : new ArrayList<>(models); if (settingsHelper.getBoolean(PreferenceKeys.TOGGLE_KEYWORD_FILTER)) { final List keywords = new ArrayList<>(settingsHelper.getStringSet(PreferenceKeys.KEYWORD_FILTERS)); final List filter = KeywordsFilterUtilsKt.filter(keywords, result); if (filter != null) { modelsCopy.addAll(filter); } return modelsCopy; } modelsCopy.addAll(result); return modelsCopy; } public LiveData> getList() { return list; } public boolean hasMore() { return postFetcher.hasMore(); } public void fetch() { postFetcher.fetch(); } public void reset() { postFetcher.reset(); } public boolean isFetching() { return postFetcher.isFetching(); } public void refresh() { refresh = true; reset(); fetch(); } public static class ViewModelFactory implements ViewModelProvider.Factory { @NonNull private final PostFetcher.PostFetchService postFetchService; public ViewModelFactory(@NonNull final PostFetcher.PostFetchService postFetchService) { this.postFetchService = postFetchService; } @NonNull @Override public T create(@NonNull final Class modelClass) { //noinspection unchecked return (T) new MediaViewModel(postFetchService); } } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/NotificationViewModel.java ================================================ package awais.instagrabber.viewmodels; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import java.util.List; import awais.instagrabber.repositories.responses.notification.Notification; public class NotificationViewModel extends ViewModel { private MutableLiveData> list; public MutableLiveData> getList() { if (list == null) { list = new MutableLiveData<>(); } return list; } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import awais.instagrabber.R import awais.instagrabber.managers.DirectMessagesManager import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.repositories.responses.Caption import awais.instagrabber.repositories.responses.Location import awais.instagrabber.repositories.responses.Media import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.utils.Constants import awais.instagrabber.utils.Utils import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.utils.getCsrfTokenFromCookie import awais.instagrabber.utils.getUserIdFromCookie import awais.instagrabber.webservices.MediaRepository import com.google.common.collect.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.* class PostViewV2ViewModel : ViewModel() { private val user = MutableLiveData() private val caption = MutableLiveData() private val location = MutableLiveData() private val date = MutableLiveData() private val likeCount = MutableLiveData(0L) private val commentCount = MutableLiveData(0L) private val viewCount = MutableLiveData(0L) private val type = MutableLiveData() private val liked = MutableLiveData(false) private val saved = MutableLiveData(false) private val options = MutableLiveData>(ArrayList()) private var messageManager: DirectMessagesManager? = null private val cookie = Utils.settingsHelper.getString(Constants.COOKIE) private val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) private val csrfToken = getCsrfTokenFromCookie(cookie) private val viewerId = getUserIdFromCookie(cookie) private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } lateinit var media: Media private set val isLoggedIn = cookie.isNotBlank() && !csrfToken.isNullOrBlank() && viewerId != 0L fun setMedia(media: Media) { this.media = media user.postValue(media.user) caption.postValue(media.caption) location.postValue(media.location) date.postValue(media.date) likeCount.postValue(media.likeCount) commentCount.postValue(media.commentCount) viewCount.postValue(if (media.type == MediaItemType.MEDIA_TYPE_VIDEO) media.viewCount else null) type.postValue(media.type) liked.postValue(media.hasLiked) saved.postValue(media.hasViewerSaved) initOptions() } private fun initOptions() { val builder = ImmutableList.builder() val user1 = media.user if (isLoggedIn && user1 != null && user1.pk == viewerId) { builder.add(R.id.edit_caption) builder.add(R.id.delete) } options.postValue(builder.build()) } fun getUser(): LiveData { return user } fun getCaption(): LiveData { return caption } fun getLocation(): LiveData { return location } fun getDate(): LiveData { return date } fun getLikeCount(): LiveData { return likeCount } fun getCommentCount(): LiveData { return commentCount } fun getViewCount(): LiveData { return viewCount } fun getType(): LiveData { return type } fun getLiked(): LiveData { return liked } fun getSaved(): LiveData { return saved } fun getOptions(): LiveData> { return options } fun toggleLike(): LiveData> { return if (media.hasLiked) { unlike() } else like() } fun like(): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) if (!isLoggedIn) { data.postValue(error("Not logged in!", null)) return data } viewModelScope.launch(Dispatchers.IO) { try { val mediaId = media.pk ?: return@launch val liked = mediaRepository.like(csrfToken!!, viewerId, deviceUuid, mediaId) updateMediaLikeUnlike(data, liked) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun unlike(): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) if (!isLoggedIn) { data.postValue(error("Not logged in!", null)) return data } viewModelScope.launch(Dispatchers.IO) { try { val mediaId = media.pk ?: return@launch val unliked = mediaRepository.unlike(csrfToken!!, viewerId, deviceUuid, mediaId) updateMediaLikeUnlike(data, unliked) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } private fun updateMediaLikeUnlike(data: MutableLiveData>, result: Boolean) { if (!result) { data.postValue(error("", null)) return } data.postValue(success(true)) val currentLikesCount = media.likeCount val updatedCount: Long if (!media.hasLiked) { updatedCount = currentLikesCount + 1 media.hasLiked = true } else { updatedCount = currentLikesCount - 1 media.hasLiked = false } media.likeCount = updatedCount likeCount.postValue(updatedCount) liked.postValue(media.hasLiked) } fun toggleSave(): LiveData> { return if (!media.hasViewerSaved) { save(null, false) } else unsave() } fun toggleSave(collection: String?, ignoreSaveState: Boolean): LiveData> { return save(collection, ignoreSaveState) } fun save(collection: String?, ignoreSaveState: Boolean): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) if (!isLoggedIn) { data.postValue(error("Not logged in!", null)) return data } viewModelScope.launch(Dispatchers.IO) { try { val mediaId = media.pk ?: return@launch val saved = mediaRepository.save(csrfToken!!, viewerId, deviceUuid, mediaId, collection) getSaveUnsaveCallback(data, saved, ignoreSaveState) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun unsave(): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) if (!isLoggedIn) { data.postValue(error("Not logged in!", null)) return data } viewModelScope.launch(Dispatchers.IO) { val mediaId = media.pk ?: return@launch val unsaved = mediaRepository.unsave(csrfToken!!, viewerId, deviceUuid, mediaId) getSaveUnsaveCallback(data, unsaved, false) } return data } private fun getSaveUnsaveCallback( data: MutableLiveData>, result: Boolean, ignoreSaveState: Boolean, ) { if (!result) { data.postValue(error("", null)) return } data.postValue(success(true)) if (!ignoreSaveState) media.hasViewerSaved = !media.hasViewerSaved saved.postValue(media.hasViewerSaved) } fun updateCaption(caption: String): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) if (!isLoggedIn) { data.postValue(error("Not logged in!", null)) return data } viewModelScope.launch(Dispatchers.IO) { try { val postId = media.pk ?: return@launch val result = mediaRepository.editCaption(csrfToken!!, viewerId, deviceUuid, postId, caption) if (result) { data.postValue(success("")) media.setPostCaption(caption) this@PostViewV2ViewModel.caption.postValue(media.caption) return@launch } data.postValue(error("", null)) } catch (e: Exception) { Log.e(TAG, "Error editing caption", e) data.postValue(error(e.message, null)) } } return data } fun translateCaption(): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val value = caption.value val pk = value?.pk if (pk == null) { data.postValue(error("caption is null", null)) return data } viewModelScope.launch(Dispatchers.IO) { try { val result = mediaRepository.translate(pk, "1") ?: return@launch if (result.isBlank()) { // data.postValue(error("", null)) return@launch } data.postValue(success(result)) } catch (e: Exception) { Log.e(TAG, "Error translating comment", e) data.postValue(error(e.message, null)) } } return data } fun hasPk(): Boolean { return media.pk != null } fun setViewCount(viewCount: Long?) { this.viewCount.postValue(viewCount) } fun delete(): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) if (!isLoggedIn) { data.postValue(error("Not logged in!", null)) return data } val mediaId = media.id val mediaType = media.type if (mediaId == null || mediaType == null) { data.postValue(error("media id or type is null", null)) return data } viewModelScope.launch(Dispatchers.IO) { try { val response = mediaRepository.delete(csrfToken!!, viewerId, deviceUuid, mediaId, mediaType) if (response == null) { data.postValue(success(Any())) return@launch } data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "delete: ", e) data.postValue(error(e.message, null)) } } return data } fun shareDm(result: RankedRecipient, child: Int) { if (messageManager == null) { messageManager = DirectMessagesManager } val mediaId = media.id ?: return val childId = if (child == -1) null else media.carouselMedia?.get(child)?.id messageManager?.sendMedia(result, mediaId, childId, BroadcastItemType.MEDIA_SHARE, viewModelScope) } fun shareDm(recipients: Set, child: Int) { if (messageManager == null) { messageManager = DirectMessagesManager } val mediaId = media.id ?: return val childId = if (child == -1) null else media.carouselMedia?.get(child)?.id messageManager?.sendMedia(recipients, mediaId, childId, BroadcastItemType.MEDIA_SHARE, viewModelScope) } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.os.Bundle import android.util.Log import androidx.lifecycle.* import androidx.savedstate.SavedStateRegistryOwner import awais.instagrabber.db.entities.Favorite import awais.instagrabber.db.repositories.FavoriteRepository import awais.instagrabber.managers.DirectMessagesManager import awais.instagrabber.models.Resource import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.repositories.requests.StoryViewerOptions import awais.instagrabber.repositories.responses.FriendshipStatus import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.UserProfileContextLink import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.repositories.responses.stories.Story import awais.instagrabber.utils.ControlledRunner import awais.instagrabber.utils.Event import awais.instagrabber.utils.SingleRunner import awais.instagrabber.utils.extensions.TAG import awais.instagrabber.utils.extensions.isReallyPrivate import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileAction.* import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileEvent.* import awais.instagrabber.webservices.* import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.LocalDateTime class ProfileFragmentViewModel( private val state: SavedStateHandle, private val csrfToken: String?, private val deviceUuid: String?, private val userRepository: UserRepository, private val friendshipRepository: FriendshipRepository, private val storiesRepository: StoriesRepository, private val mediaRepository: MediaRepository, private val graphQLRepository: GraphQLRepository, private val favoriteRepository: FavoriteRepository, private val directMessagesRepository: DirectMessagesRepository, private val messageManager: DirectMessagesManager?, ioDispatcher: CoroutineDispatcher, ) : ViewModel() { private val _currentUser = MutableLiveData>(Resource.loading(null)) private val _isFavorite = MutableLiveData(false) private val profileAction = MutableLiveData(INIT) private val _eventLiveData = MutableLiveData?>() private var previousUsername: String? = null enum class ProfileAction { INIT, REFRESH, REFRESH_FRIENDSHIP, } sealed class ProfileEvent { object ShowConfirmUnfollowDialog : ProfileEvent() class DMButtonState(val disabled: Boolean) : ProfileEvent() class NavigateToThread(val threadId: String, val username: String) : ProfileEvent() class ShowTranslation(val result: String) : ProfileEvent() } val currentUser: LiveData> = _currentUser val isLoggedIn: LiveData = currentUser.map { it.data != null } val isFavorite: LiveData = _isFavorite val eventLiveData: LiveData?> = _eventLiveData private val currentUserStateUsernameActionLiveData: LiveData, Resource, ProfileAction>> = object : MediatorLiveData, Resource, ProfileAction>>() { var user: Resource = Resource.loading(null) var stateUsername: Resource = Resource.loading(null) var action: ProfileAction = INIT init { addSource(currentUser) { currentUser -> this.user = currentUser value = Triple(currentUser, stateUsername, action) } addSource(state.getLiveData("username")) { username -> this.stateUsername = Resource.success(username.substringAfter('@')) value = Triple(user, this.stateUsername, action) } addSource(profileAction) { action -> this.action = action value = Triple(user, stateUsername, action) } // trigger currentUserStateUsernameActionLiveData switch map with a state username success resource if (!state.contains("username")) { this.stateUsername = Resource.success(null) value = Triple(user, this.stateUsername, action) } } } private val profileFetchControlledRunner = ControlledRunner() val profile: LiveData> = currentUserStateUsernameActionLiveData.switchMap { val (currentUserResource, stateUsernameResource, action) = it liveData>(context = viewModelScope.coroutineContext + ioDispatcher) { if (action == INIT && previousUsername != null && stateUsernameResource.data == previousUsername) return@liveData if (currentUserResource.status == Resource.Status.LOADING || stateUsernameResource.status == Resource.Status.LOADING) { emit(Resource.loading(profileCopy.value?.data)) return@liveData } val currentUser = currentUserResource.data val stateUsername = stateUsernameResource.data if (stateUsername.isNullOrBlank()) { emit(Resource.success(currentUser)) return@liveData } try { when (action) { INIT, REFRESH -> { previousUsername = stateUsername val fetchedUser = profileFetchControlledRunner.cancelPreviousThenRun { fetchUser(currentUser, stateUsername) } emit(Resource.success(fetchedUser)) if (fetchedUser != null) { checkAndUpdateFavorite(fetchedUser) } } REFRESH_FRIENDSHIP -> { var profile = profileCopy.value?.data ?: return@liveData profile = profile.copy(friendshipStatus = userRepository.getUserFriendship(profile.pk)) emit(Resource.success(profile)) } } } catch (e: Exception) { emit(Resource.error(e.message, profileCopy.value?.data)) Log.e(TAG, "fetching user: ", e) } } } val profileCopy = profile val currentUserProfileActionLiveData: LiveData, Resource, ProfileAction>> = object : MediatorLiveData, Resource, ProfileAction>>() { var currentUser: Resource = Resource.loading(null) var profile: Resource = Resource.loading(null) var action: ProfileAction = INIT init { addSource(this@ProfileFragmentViewModel.currentUser) { currentUser -> this.currentUser = currentUser value = Triple(currentUser, profile, action) } addSource(this@ProfileFragmentViewModel.profile) { profile -> this.profile = profile value = Triple(currentUser, this.profile, action) } addSource(profileAction) { action -> this.action = action value = Triple(currentUser, this.profile, action) } } } private val storyFetchControlledRunner = ControlledRunner() val userStories: LiveData> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair -> liveData>(context = viewModelScope.coroutineContext + ioDispatcher) { val (currentUserResource, profileResource, action) = currentUserAndProfilePair if (action != INIT && action != REFRESH) { return@liveData } // don't fetch if not logged in if (currentUserResource.data == null) { emit(Resource.success(null)) return@liveData } if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { emit(Resource.loading(null)) return@liveData } val user = profileResource.data if (user == null) { emit(Resource.success(null)) return@liveData } try { val fetchedStories = storyFetchControlledRunner.cancelPreviousThenRun { fetchUserStory(user) } emit(Resource.success(fetchedStories)) } catch (e: Exception) { emit(Resource.error(e.message, null)) Log.e(TAG, "fetching story: ", e) } } } private val highlightsFetchControlledRunner = ControlledRunner?>() val userHighlights: LiveData?>> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair -> liveData?>>(context = viewModelScope.coroutineContext + ioDispatcher) { val (currentUserResource, profileResource, action) = currentUserAndProfilePair if (action != INIT && action != REFRESH) { return@liveData } // don't fetch if not logged in if (currentUserResource.data == null) { emit(Resource.success(null)) return@liveData } if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { emit(Resource.loading(null)) return@liveData } val user = profileResource.data if (user == null) { emit(Resource.success(null)) return@liveData } try { val fetchedHighlights = highlightsFetchControlledRunner.cancelPreviousThenRun { fetchUserHighlights(user) } emit(Resource.success(fetchedHighlights)) } catch (e: Exception) { emit(Resource.error(e.message, null)) Log.e(TAG, "fetching highlights: ", e) } } } private suspend fun fetchUser( currentUser: User?, stateUsername: String, ): User? { if (currentUser != null) { // logged in val tempUser = userRepository.getUsernameInfo(stateUsername) if (!tempUser.isReallyPrivate(currentUser)) { tempUser.friendshipStatus = userRepository.getUserFriendship(tempUser.pk) } return tempUser } // anonymous return graphQLRepository.fetchUser(stateUsername) } private suspend fun fetchUserStory(fetchedUser: User): Story? = storiesRepository.getStories( StoryViewerOptions.forUser(fetchedUser.pk, fetchedUser.fullName) ) private suspend fun fetchUserHighlights(fetchedUser: User): List = storiesRepository.fetchHighlights(fetchedUser.pk) private suspend fun checkAndUpdateFavorite(fetchedUser: User) { try { val favorite = favoriteRepository.getFavorite(fetchedUser.username, FavoriteType.USER) if (favorite == null) { _isFavorite.postValue(false) return } _isFavorite.postValue(true) favoriteRepository.insertOrUpdateFavorite( Favorite( favorite.id, fetchedUser.username, FavoriteType.USER, fetchedUser.fullName, fetchedUser.profilePicUrl, favorite.dateAdded ) ) } catch (e: Exception) { _isFavorite.postValue(false) Log.e(TAG, "checkAndUpdateFavorite: ", e) } } fun setCurrentUser(currentUser: Resource) { _currentUser.postValue(currentUser) } fun shareDm(result: RankedRecipient) { val mediaId = profile.value?.data?.pk ?: return messageManager?.sendMedia(result, mediaId.toString(10), null, BroadcastItemType.PROFILE, viewModelScope) } fun shareDm(recipients: Set) { val mediaId = profile.value?.data?.pk ?: return messageManager?.sendMedia(recipients, mediaId.toString(10), null, BroadcastItemType.PROFILE, viewModelScope) } fun refresh() { profileAction.postValue(REFRESH) } private val toggleFavoriteControlledRunner = SingleRunner() fun toggleFavorite() { val username = profile.value?.data?.username ?: return val fullName = profile.value?.data?.fullName ?: return val profilePicUrl = profile.value?.data?.profilePicUrl ?: return viewModelScope.launch(Dispatchers.IO) { toggleFavoriteControlledRunner.afterPrevious { try { val favorite = favoriteRepository.getFavorite(username, FavoriteType.USER) if (favorite == null) { // insert favoriteRepository.insertOrUpdateFavorite( Favorite( 0, username, FavoriteType.USER, fullName, profilePicUrl, LocalDateTime.now() ) ) _isFavorite.postValue(true) return@afterPrevious } // delete favoriteRepository.deleteFavorite(username, FavoriteType.USER) _isFavorite.postValue(false) } catch (e: Exception) { Log.e(TAG, "checkAndUpdateFavorite: ", e) } } } } private val toggleFollowSingleRunner = SingleRunner() fun toggleFollow(confirmed: Boolean) { viewModelScope.launch(Dispatchers.IO) { toggleFollowSingleRunner.afterPrevious { try { val following = profile.value?.data?.friendshipStatus?.following ?: false val currentUserId = currentUser.value?.data?.pk ?: return@afterPrevious val targetUserId = profile.value?.data?.pk ?: return@afterPrevious val csrfToken = csrfToken ?: return@afterPrevious val deviceUuid = deviceUuid ?: return@afterPrevious if (following) { if (!confirmed) { _eventLiveData.postValue(Event(ShowConfirmUnfollowDialog)) return@afterPrevious } // unfollow friendshipRepository.unfollow( csrfToken, currentUserId, deviceUuid, targetUserId ) profileAction.postValue(REFRESH_FRIENDSHIP) return@afterPrevious } friendshipRepository.follow( csrfToken, currentUserId, deviceUuid, targetUserId ) profileAction.postValue(REFRESH_FRIENDSHIP) } catch (e: Exception) { Log.e(TAG, "toggleFollow: ", e) } } } } private val sendDmSingleRunner = SingleRunner() fun sendDm() { viewModelScope.launch(Dispatchers.IO) { sendDmSingleRunner.afterPrevious { _eventLiveData.postValue(Event(DMButtonState(true))) try { val currentUserId = currentUser.value?.data?.pk ?: return@afterPrevious val targetUserId = profile.value?.data?.pk ?: return@afterPrevious val csrfToken = csrfToken ?: return@afterPrevious val deviceUuid = deviceUuid ?: return@afterPrevious val username = profile.value?.data?.username ?: return@afterPrevious val thread = directMessagesRepository.createThread( csrfToken, currentUserId, deviceUuid, listOf(targetUserId), null, ) val inboxManager = DirectMessagesManager.inboxManager if (!inboxManager.containsThread(thread.threadId)) { thread.isTemp = true inboxManager.addThread(thread, 0) } val threadId = thread.threadId ?: return@afterPrevious _eventLiveData.postValue(Event(NavigateToThread(threadId, username))) delay(200) // Add delay so that the postValue in finally does not overwrite the NavigateToThread event } catch (e: Exception) { Log.e(TAG, "sendDm: ", e) } finally { _eventLiveData.postValue(Event(DMButtonState(false))) } } } } private val restrictUserSingleRunner = SingleRunner() fun restrictUser() { if (isLoggedIn.value == false) return viewModelScope.launch(Dispatchers.IO) { restrictUserSingleRunner.afterPrevious { try { val profile = profile.value?.data ?: return@afterPrevious friendshipRepository.toggleRestrict( csrfToken ?: return@afterPrevious, deviceUuid ?: return@afterPrevious, profile.pk, !(profile.friendshipStatus?.isRestricted ?: false), ) profileAction.postValue(REFRESH_FRIENDSHIP) } catch (e: Exception) { Log.e(TAG, "restrictUser: ", e) } } } } private val blockUserSingleRunner = SingleRunner() fun blockUser() { if (isLoggedIn.value == false) return viewModelScope.launch(Dispatchers.IO) { blockUserSingleRunner.afterPrevious { try { val profile = profile.value?.data ?: return@afterPrevious friendshipRepository.changeBlock( csrfToken ?: return@afterPrevious, currentUser.value?.data?.pk ?: return@afterPrevious, deviceUuid ?: return@afterPrevious, profile.friendshipStatus?.blocking ?: return@afterPrevious, profile.pk ) profileAction.postValue(REFRESH_FRIENDSHIP) } catch (e: Exception) { Log.e(TAG, "blockUser: ", e) } } } } private val muteStoriesSingleRunner = SingleRunner() fun muteStories() { if (isLoggedIn.value == false) return viewModelScope.launch(Dispatchers.IO) { muteStoriesSingleRunner.afterPrevious { try { val profile = profile.value?.data ?: return@afterPrevious friendshipRepository.changeMute( csrfToken ?: return@afterPrevious, currentUser.value?.data?.pk ?: return@afterPrevious, deviceUuid ?: return@afterPrevious, profile.friendshipStatus?.isMutingReel ?: return@afterPrevious, profile.pk, true ) profileAction.postValue(REFRESH_FRIENDSHIP) } catch (e: Exception) { Log.e(TAG, "muteStories: ", e) } } } } private val mutePostsSingleRunner = SingleRunner() fun mutePosts() { if (isLoggedIn.value == false) return viewModelScope.launch(Dispatchers.IO) { mutePostsSingleRunner.afterPrevious { try { val profile = profile.value?.data ?: return@afterPrevious friendshipRepository.changeMute( csrfToken ?: return@afterPrevious, currentUser.value?.data?.pk ?: return@afterPrevious, deviceUuid ?: return@afterPrevious, profile.friendshipStatus?.muting ?: return@afterPrevious, profile.pk, false ) profileAction.postValue(REFRESH_FRIENDSHIP) } catch (e: Exception) { Log.e(TAG, "mutePosts: ", e) } } } } private val removeFollowerSingleRunner = SingleRunner() fun removeFollower() { if (isLoggedIn.value == false) return viewModelScope.launch(Dispatchers.IO) { removeFollowerSingleRunner.afterPrevious { try { friendshipRepository.removeFollower( csrfToken ?: return@afterPrevious, currentUser.value?.data?.pk ?: return@afterPrevious, deviceUuid ?: return@afterPrevious, profile.value?.data?.pk ?: return@afterPrevious ) profileAction.postValue(REFRESH_FRIENDSHIP) } catch (e: Exception) { Log.e(TAG, "removeFollower: ", e) } } } } private val translateBioSingleRunner = SingleRunner() fun translateBio() { if (isLoggedIn.value == false) return viewModelScope.launch(Dispatchers.IO) { translateBioSingleRunner.afterPrevious { try { val result = mediaRepository.translate( profile.value?.data?.pk?.toString() ?: return@afterPrevious, "3" ) if (result.isNullOrBlank()) return@afterPrevious _eventLiveData.postValue(Event(ShowTranslation(result))) } catch (e: Exception) { Log.e(TAG, "translateBio: ", e) } } } } /** * Username of profile without '`@`' */ val username: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> "" Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.username ?: "" } } val profilePicUrl: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.profilePicUrl } } val fullName: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> "" Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.fullName } } val biography: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> "" Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.biography } } val url: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> "" Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.externalUrl } } val followersCount: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.followerCount } } val followingCount: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.followingCount } } val postCount: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.mediaCount } } val isPrivate: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.isPrivate } } val isVerified: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.isVerified } } val friendshipStatus: LiveData = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.friendshipStatus } } val profileContext: LiveData?>> = Transformations.map(profile) { return@map when (it.status) { Resource.Status.ERROR -> null to null Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.profileContext to it.data?.profileContextLinksWithUserIds } } } @Suppress("UNCHECKED_CAST") class ProfileFragmentViewModelFactory( private val csrfToken: String?, private val deviceUuid: String?, private val userRepository: UserRepository, private val friendshipRepository: FriendshipRepository, private val storiesRepository: StoriesRepository, private val mediaRepository: MediaRepository, private val graphQLRepository: GraphQLRepository, private val favoriteRepository: FavoriteRepository, private val directMessagesRepository: DirectMessagesRepository, private val messageManager: DirectMessagesManager?, owner: SavedStateRegistryOwner, defaultArgs: Bundle? = null, ) : AbstractSavedStateViewModelFactory(owner, defaultArgs) { override fun create( key: String, modelClass: Class, handle: SavedStateHandle, ): T { return ProfileFragmentViewModel( handle, csrfToken, deviceUuid, userRepository, friendshipRepository, storiesRepository, mediaRepository, graphQLRepository, favoriteRepository, directMessagesRepository, messageManager, Dispatchers.IO, ) as T } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/SavedCollectionsViewModel.java ================================================ package awais.instagrabber.viewmodels; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import java.util.List; import awais.instagrabber.repositories.responses.saved.SavedCollection; public class SavedCollectionsViewModel extends ViewModel { private MutableLiveData> list; public MutableLiveData> getList() { if (list == null) { list = new MutableLiveData<>(); } return list; } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import androidx.lifecycle.viewModelScope import awais.instagrabber.db.datasources.RecentSearchDataSource import awais.instagrabber.db.entities.Favorite import awais.instagrabber.db.entities.RecentSearch import awais.instagrabber.db.entities.RecentSearch.Companion.fromSearchItem import awais.instagrabber.db.repositories.FavoriteRepository import awais.instagrabber.db.repositories.RecentSearchRepository import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.repositories.responses.search.SearchItem import awais.instagrabber.repositories.responses.search.SearchResponse import awais.instagrabber.utils.* import awais.instagrabber.utils.AppExecutors.mainThread import awais.instagrabber.utils.TextUtils.isEmpty import awais.instagrabber.webservices.SearchRepository import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.SettableFuture import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import retrofit2.Call import retrofit2.Callback import retrofit2.Response import java.util.* import java.util.function.BiConsumer import java.util.stream.Collectors class SearchFragmentViewModel(application: Application) : AppStateViewModel(application) { private val query = MutableLiveData() private val topResults = MutableLiveData?>>() private val userResults = MutableLiveData?>>() private val hashtagResults = MutableLiveData?>>() private val locationResults = MutableLiveData?>>() private val searchRepository: SearchRepository by lazy { SearchRepository.getInstance() } private val searchCallback: Debouncer.Callback = object : Debouncer.Callback { override fun call(key: String) { if (tempQuery == null) return query.postValue(tempQuery!!) } override fun onError(t: Throwable) { Log.e(TAG, "onError: ", t) } } private val searchDebouncer = Debouncer(searchCallback, 500) private val cookie = Utils.settingsHelper.getString(Constants.COOKIE) private val isLoggedIn = !isEmpty(cookie) && getUserIdFromCookie(cookie) != 0L private val distinctQuery = Transformations.distinctUntilChanged(query) private val recentSearchRepository: RecentSearchRepository by lazy { RecentSearchRepository.getInstance(RecentSearchDataSource.getInstance(application)) } private val favoriteRepository: FavoriteRepository by lazy { FavoriteRepository.getInstance(application) } private var tempQuery: String? = null fun getQuery(): LiveData { return distinctQuery } fun getTopResults(): LiveData?>> { return topResults } fun getUserResults(): LiveData?>> { return userResults } fun getHashtagResults(): LiveData?>> { return hashtagResults } fun getLocationResults(): LiveData?>> { return locationResults } fun submitQuery(query: String?) { var localQuery = query if (query == null) { localQuery = "" } if (tempQuery != null && localQuery!!.lowercase(Locale.getDefault()) == tempQuery!!.lowercase(Locale.getDefault())) return tempQuery = query if (isEmpty(query)) { // If empty immediately post it searchDebouncer.cancel(QUERY) this.query.postValue("") return } searchDebouncer.call(QUERY) } fun search( query: String, type: FavoriteType ) { val liveData = getLiveDataByType(type) ?: return if (isEmpty(query)) { showRecentSearchesAndFavorites(type, liveData) return } if (query == "@" || query == "#") return val c: String c = when (type) { FavoriteType.TOP -> "blended" FavoriteType.USER -> "user" FavoriteType.HASHTAG -> "hashtag" FavoriteType.LOCATION -> "place" else -> return } liveData.postValue(loading?>(null)) viewModelScope.launch(Dispatchers.IO) { try { val response = searchRepository.search(isLoggedIn, query, c) parseResponse(response, type) } catch (e: Exception) { sendErrorResponse(type) } } } private fun showRecentSearchesAndFavorites( type: FavoriteType, liveData: MutableLiveData?>> ) { val recentResultsFuture = SettableFuture.create>() val favoritesFuture = SettableFuture.create>() viewModelScope.launch(Dispatchers.IO) { try { val recentSearches = recentSearchRepository.getAllRecentSearches() recentResultsFuture.set( if (type == FavoriteType.TOP) recentSearches else recentSearches.stream() .filter { (_, _, _, _, _, type1) -> type1 === type } .collect(Collectors.toList()) ) } catch (e: Exception) { recentResultsFuture.set(emptyList()) } try { val favorites = favoriteRepository.getAllFavorites() favoritesFuture.set( if (type == FavoriteType.TOP) favorites else favorites .stream() .filter { (_, _, type1) -> type1 === type } .collect(Collectors.toList()) ) } catch (e: Exception) { favoritesFuture.set(emptyList()) } } val listenableFuture = Futures.allAsList>(recentResultsFuture, favoritesFuture) Futures.addCallback(listenableFuture, object : FutureCallback?>?> { override fun onSuccess(result: List?>?) { if (!isEmpty(tempQuery)) return // Make sure user has not entered anything before updating results if (result == null) { liveData.postValue(success(emptyList())) return } try { liveData.postValue( success( ImmutableList.builder() .addAll(SearchItem.fromRecentSearch(result[0] as List?)) .addAll(SearchItem.fromFavorite(result[1] as List?)) .build() ) ) } catch (e: Exception) { Log.e(TAG, "onSuccess: ", e) liveData.postValue(success(emptyList())) } } override fun onFailure(t: Throwable) { if (!isEmpty(tempQuery)) return liveData.postValue(success(emptyList())) Log.e(TAG, "onFailure: ", t) } }, mainThread) } private fun sendErrorResponse(type: FavoriteType) { val liveData = getLiveDataByType(type) ?: return liveData.postValue(error(null, emptyList())) } private fun getLiveDataByType(type: FavoriteType): MutableLiveData?>>? { val liveData: MutableLiveData?>> liveData = when (type) { FavoriteType.TOP -> topResults FavoriteType.USER -> userResults FavoriteType.HASHTAG -> hashtagResults FavoriteType.LOCATION -> locationResults else -> return null } return liveData } private fun parseResponse( body: SearchResponse, type: FavoriteType ) { val liveData = getLiveDataByType(type) ?: return if (isLoggedIn) { if (body.list == null) { liveData.postValue(success(emptyList())) return } if (type === FavoriteType.HASHTAG || type === FavoriteType.LOCATION) { liveData.postValue(success(body.list .stream() .filter { i: SearchItem -> i.user == null } .collect(Collectors.toList()))) return } liveData.postValue(success(body.list)) return } // anonymous val list: List? list = when (type) { FavoriteType.TOP -> ImmutableList .builder() .addAll(body.users ?: emptyList()) .addAll(body.hashtags ?: emptyList()) .addAll(body.places ?: emptyList()) .build() FavoriteType.USER -> body.users FavoriteType.HASHTAG -> body.hashtags FavoriteType.LOCATION -> body.places else -> return } liveData.postValue(success(list)) } fun saveToRecentSearches(searchItem: SearchItem?) { if (searchItem == null) return viewModelScope.launch(Dispatchers.IO) { try { val recentSearch = fromSearchItem(searchItem) recentSearchRepository.insertOrUpdateRecentSearch(recentSearch!!) } catch (e: Exception) { Log.e(TAG, "saveToRecentSearches: ", e) } } } fun deleteRecentSearch(searchItem: SearchItem?): LiveData>? { if (searchItem == null || !searchItem.isRecent) return null val (_, igId, _, _, _, type) = fromSearchItem(searchItem) ?: return null val data = MutableLiveData>() data.postValue(loading(null)) viewModelScope.launch(Dispatchers.IO) { try { recentSearchRepository.deleteRecentSearchByIgIdAndType(igId, type) data.postValue(success(Any())) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } companion object { private val TAG = SearchFragmentViewModel::class.java.simpleName private const val QUERY = "query" } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/StoryFragmentViewModel.kt ================================================ package awais.instagrabber.viewmodels import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import awais.instagrabber.R import awais.instagrabber.managers.DirectMessagesManager import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.models.enums.StoryPaginationType import awais.instagrabber.repositories.requests.StoryViewerOptions import awais.instagrabber.repositories.responses.Media import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.repositories.responses.stories.* import awais.instagrabber.utils.Constants import awais.instagrabber.utils.Utils import awais.instagrabber.utils.getCsrfTokenFromCookie import awais.instagrabber.utils.getUserIdFromCookie import awais.instagrabber.webservices.MediaRepository import awais.instagrabber.webservices.StoriesRepository import com.google.common.collect.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class StoryFragmentViewModel : ViewModel() { // large data private val currentStory = MutableLiveData() private val currentMedia = MutableLiveData() // small data private val storyTitle = MutableLiveData() private val date = MutableLiveData() private val type = MutableLiveData() private val poll = MutableLiveData() private val quiz = MutableLiveData() private val question = MutableLiveData() private val slider = MutableLiveData() private val swipeUp = MutableLiveData() private val linkedPost = MutableLiveData() private val appAttribution = MutableLiveData() private val reelMentions = MutableLiveData>>() // process private val currentIndex = MutableLiveData() private val pagination = MutableLiveData(StoryPaginationType.DO_NOTHING) private val options = MutableLiveData>, String?, String?>>() private val seen = MutableLiveData>() // utils private var messageManager: DirectMessagesManager? = null private val cookie = Utils.settingsHelper.getString(Constants.COOKIE) private val deviceId = Utils.settingsHelper.getString(Constants.DEVICE_UUID) private val csrfToken = getCsrfTokenFromCookie(cookie) private val userId = getUserIdFromCookie(cookie) private val storiesRepository: StoriesRepository by lazy { StoriesRepository.getInstance() } private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } // for highlights ONLY val highlights = MutableLiveData?>() /* set functions */ fun setStory(story: Story) { currentStory.postValue(story) storyTitle.postValue(story.title ?: story.user?.username) if (story.broadcast != null) { date.postValue(story.dateTime) type.postValue(MediaItemType.MEDIA_TYPE_LIVE) pagination.postValue(StoryPaginationType.DO_NOTHING) return } if (story.items == null || story.items.size == 0) { pagination.postValue(StoryPaginationType.ERROR) return } } fun setMedia(index: Int) { if (currentStory.value?.items == null) return if (index < 0 || index >= currentStory.value!!.items!!.size) { pagination.postValue(if (index < 0) StoryPaginationType.BACKWARD else StoryPaginationType.FORWARD) return } currentIndex.postValue(index) val story: Story? = currentStory.value val media = story!!.items!!.get(index) currentMedia.postValue(media) date.postValue(media.date) type.postValue(media.type) initStickers(media) } fun setSingleMedia(media: StoryMedia) { currentStory.postValue(null) currentIndex.postValue(0) currentMedia.postValue(media) date.postValue(media.date) type.postValue(media.type) } private fun initStickers(media: StoryMedia) { val builder = ImmutableList.builder>() var linkedText: String? = null var appText: String? = null if (setMentions(media)) builder.add(Pair(R.id.mentions, R.string.story_mentions)) if (setQuiz(media)) builder.add(Pair(R.id.quiz, R.string.story_quiz)) if (setQuestion(media)) builder.add(Pair(R.id.question, R.string.story_question)) if (setPoll(media)) builder.add(Pair(R.id.poll, R.string.story_poll)) if (setSlider(media)) builder.add(Pair(R.id.slider, R.string.story_slider)) if (setLinkedPost(media)) builder.add(Pair(R.id.viewStoryPost, R.string.view_post)) if (setStoryCta(media)) { linkedText = media.linkText builder.add(Pair(R.id.swipeUp, 0)) } if (setStoryAppAttribution(media)) { appText = media.storyAppAttribution!!.appActionText builder.add(Pair(R.id.spotify, 0)) } options.postValue(Triple(builder.build(), linkedText, appText)) } private fun setMentions(media: StoryMedia): Boolean { val mentions: MutableList> = mutableListOf() if (media.reelMentions != null) mentions.addAll(media.reelMentions.map{ Triple("@" + it.user?.username, it.user?.username, FavoriteType.USER) }) if (media.storyHashtags != null) mentions.addAll(media.storyHashtags.map{ Triple("#" + it.hashtag?.name, it.hashtag?.name, FavoriteType.HASHTAG) }) if (media.storyLocations != null) mentions.addAll(media.storyLocations.map{ Triple(it.location?.name ?: "", it.location?.pk?.toString(10), FavoriteType.LOCATION) }) reelMentions.postValue(mentions.filterNot { it.second.isNullOrEmpty() } .distinct()) return !mentions.isEmpty() } private fun setPoll(media: StoryMedia): Boolean { poll.postValue(media.storyPolls?.get(0)?.pollSticker ?: return false) return true } private fun setQuiz(media: StoryMedia): Boolean { quiz.postValue(media.storyQuizs?.get(0)?.quizSticker ?: return false) return true } private fun setQuestion(media: StoryMedia): Boolean { val questionSticker = media.storyQuestions?.get(0)?.questionSticker ?: return false if (questionSticker.questionType.equals("music")) return false question.postValue(questionSticker) return true } private fun setSlider(media: StoryMedia): Boolean { slider.postValue(media.storySliders?.get(0)?.sliderSticker ?: return false) return true } private fun setLinkedPost(media: StoryMedia): Boolean { linkedPost.postValue(media.storyFeedMedia?.get(0)?.mediaId ?: return false) return true } private fun setStoryCta(media: StoryMedia): Boolean { val webUri = media.storyCta?.get(0)?.links?.get(0)?.webUri ?: return false val parsedUri = Uri.parse(webUri) val cleanUri = if (parsedUri.host.equals("l.instagram.com")) parsedUri.getQueryParameter("u") else null swipeUp.postValue(if (cleanUri != null && Uri.parse(cleanUri).scheme?.startsWith("http") == true) cleanUri else webUri) return true } private fun setStoryAppAttribution(media: StoryMedia): Boolean { appAttribution.postValue(media.storyAppAttribution ?: return false) return true } /* get functions */ fun getCurrentStory(): LiveData { return currentStory } fun getCurrentIndex(): LiveData { return currentIndex } fun getCurrentMedia(): LiveData { return currentMedia } fun getPagination(): LiveData { return pagination } fun getDate(): LiveData { return date } fun getTitle(): LiveData { return storyTitle } fun getType(): LiveData { return type } fun getMedia(): LiveData { return currentMedia } fun getMention(index: Int): Triple? { return reelMentions.value?.get(index) } fun getMentionTexts(): Array { return reelMentions.value!!.map { it.first } .toTypedArray() } fun getPoll(): LiveData { return poll } fun getQuestion(): LiveData { return question } fun getQuiz(): LiveData { return quiz } fun getSlider(): LiveData { return slider } fun getLinkedPost(): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val postId = linkedPost.value if (postId == null) data.postValue(error("No post ID supplied", null)) else viewModelScope.launch(Dispatchers.IO) { try { val media = mediaRepository.fetch(postId.toLong()) data.postValue(success(media)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun getSwipeUp(): String? { return swipeUp.value } fun getAppAttribution(): String? { return appAttribution.value?.url } fun getOptions(): LiveData>, String?, String?>> { return options } /* action functions */ fun answerPoll(w: Int): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) viewModelScope.launch(Dispatchers.IO) { try { val oldPoll: PollSticker = poll.value!! val response = storiesRepository.respondToPoll( csrfToken!!, userId, deviceId, currentMedia.value!!.pk, oldPoll.pollId, w ) if (!"ok".equals(response.status)) throw Exception("Instagram returned status \"" + response.status + "\"") val tally = oldPoll.tallies.get(w) val newTally = tally.copy(count = tally.count + 1) val newTallies = oldPoll.tallies.toMutableList() newTallies.set(w, newTally) poll.postValue(oldPoll.copy(viewerVote = w, tallies = newTallies.toList())) data.postValue(success(null)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun answerQuiz(w: Int): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) viewModelScope.launch(Dispatchers.IO) { try { val oldQuiz = quiz.value!! val response = storiesRepository.respondToQuiz( csrfToken!!, userId, deviceId, currentMedia.value!!.pk, oldQuiz.quizId, w ) if (!"ok".equals(response.status)) throw Exception("Instagram returned status \"" + response.status + "\"") val tally = oldQuiz.tallies.get(w) val newTally = tally.copy(count = tally.count + 1) val newTallies = oldQuiz.tallies.toMutableList() newTallies.set(w, newTally) quiz.postValue(oldQuiz.copy(viewerAnswer = w, tallies = newTallies.toList())) data.postValue(success(null)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun answerQuestion(a: String): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) viewModelScope.launch(Dispatchers.IO) { try { val response = storiesRepository.respondToQuestion( csrfToken!!, userId, deviceId, currentMedia.value!!.pk, question.value!!.questionId, a ) if (!"ok".equals(response.status)) throw Exception("Instagram returned status \"" + response.status + "\"") data.postValue(success(null)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun answerSlider(a: Double): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) viewModelScope.launch(Dispatchers.IO) { try { val oldSlider = slider.value!! val response = storiesRepository.respondToSlider( csrfToken!!, userId, deviceId, currentMedia.value!!.pk, oldSlider.sliderId, a ) if (!"ok".equals(response.status)) throw Exception("Instagram returned status \"" + response.status + "\"") val newVoteCount = (oldSlider.sliderVoteCount ?: 0) + 1 val newAverage = if (oldSlider.sliderVoteAverage == null) a else (oldSlider.sliderVoteAverage * oldSlider.sliderVoteCount!! + a) / newVoteCount slider.postValue(oldSlider.copy(viewerCanVote = false, sliderVoteCount = newVoteCount, viewerVote = a, sliderVoteAverage = newAverage)) data.postValue(success(null)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun reply(a: String): LiveData>? { if (messageManager == null) { messageManager = DirectMessagesManager } return messageManager?.replyToStory( currentStory.value?.user?.pk, currentStory.value?.id, currentMedia.value?.id, a, viewModelScope ) } fun shareDm(result: RankedRecipient) { if (messageManager == null) { messageManager = DirectMessagesManager } val mediaId = currentMedia.value?.id ?: return val reelId = currentStory.value?.id ?: return messageManager?.sendMedia(result, mediaId, reelId, BroadcastItemType.STORY, viewModelScope) } fun shareDm(recipients: Set) { if (messageManager == null) { messageManager = DirectMessagesManager } val mediaId = currentMedia.value?.id ?: return val reelId = currentStory.value?.id ?: return messageManager?.sendMedia(recipients, mediaId, reelId, BroadcastItemType.STORY, viewModelScope) } fun paginate(backward: Boolean) { var index = currentIndex.value!! index = if (backward) index - 1 else index + 1 if (index < 0 || index >= currentStory.value!!.items!!.size) skip(backward) setMedia(index) } fun skip(backward: Boolean) { pagination.postValue(if (backward) StoryPaginationType.BACKWARD else StoryPaginationType.FORWARD) } fun fetchStory(fetchOptions: StoryViewerOptions?): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) viewModelScope.launch(Dispatchers.IO) { try { val story = storiesRepository.getStories(fetchOptions!!) setStory(story!!) data.postValue(success(null)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun fetchHighlights(id: Long) { viewModelScope.launch(Dispatchers.IO) { try { val result = storiesRepository.fetchHighlights(id) highlights.postValue(result) } catch (e: Exception) { } } } fun fetchSingleMedia(mediaId: Long): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) viewModelScope.launch(Dispatchers.IO) { try { val storyMedia = storiesRepository.fetch(mediaId) setSingleMedia(storyMedia!!) data.postValue(success(null)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } fun markAsSeen(storyMedia: StoryMedia): LiveData> { val data = MutableLiveData>() data.postValue(loading(null)) val oldStory = currentStory.value!! if (oldStory.seen != null && oldStory.seen >= storyMedia.takenAt) data.postValue(success(null)) else viewModelScope.launch(Dispatchers.IO) { try { storiesRepository.seen( csrfToken!!, userId, deviceId, storyMedia.id, storyMedia.takenAt, System.currentTimeMillis() / 1000 ) val newStory = oldStory.copy(seen = storyMedia.takenAt) data.postValue(success(newStory)) } catch (e: Exception) { data.postValue(error(e.message, null)) } } return data } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/TopicClusterViewModel.java ================================================ package awais.instagrabber.viewmodels; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import java.util.List; import awais.instagrabber.repositories.responses.discover.TopicCluster; public class TopicClusterViewModel extends ViewModel { private MutableLiveData> list; public MutableLiveData> getList() { if (list == null) { list = new MutableLiveData<>(); } return list; } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java ================================================ package awais.instagrabber.viewmodels; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import awais.instagrabber.R; import awais.instagrabber.fragments.UserSearchMode; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.Debouncer; import awais.instagrabber.utils.RankedRecipientsCache; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.webservices.DirectMessagesRepository; import awais.instagrabber.webservices.UserRepository; import kotlinx.coroutines.Dispatchers; import okhttp3.ResponseBody; import retrofit2.Call; import retrofit2.Response; import static awais.instagrabber.utils.Utils.settingsHelper; public class UserSearchViewModel extends ViewModel { private static final String TAG = UserSearchViewModel.class.getSimpleName(); public static final String DEBOUNCE_KEY = "search"; private String prevQuery; private String currentQuery; private Call searchRequest; private long[] hideUserIds; private String[] hideThreadIds; private UserSearchMode searchMode; private boolean showGroups; private boolean waitingForCache; private boolean showCachedResults; private final MutableLiveData>> recipients = new MutableLiveData<>(); private final MutableLiveData showAction = new MutableLiveData<>(false); private final Debouncer searchDebouncer; private final Set selectedRecipients = new HashSet<>(); private final UserRepository userRepository; private final DirectMessagesRepository directMessagesRepository; private final RankedRecipientsCache rankedRecipientsCache; public UserSearchViewModel() { final String cookie = settingsHelper.getString(Constants.COOKIE); final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); final long viewerId = CookieUtils.getUserIdFromCookie(cookie); final String deviceUuid = settingsHelper.getString(Constants.DEVICE_UUID); if (TextUtils.isEmpty(csrfToken) || viewerId <= 0 || TextUtils.isEmpty(deviceUuid)) { throw new IllegalArgumentException("User is not logged in!"); } userRepository = UserRepository.Companion.getInstance(); directMessagesRepository = DirectMessagesRepository.Companion.getInstance(); rankedRecipientsCache = RankedRecipientsCache.INSTANCE; if ((rankedRecipientsCache.isFailed() || rankedRecipientsCache.isExpired()) && !rankedRecipientsCache.isUpdateInitiated()) { updateRankedRecipientCache(); } final Debouncer.Callback searchCallback = new Debouncer.Callback() { @Override public void call(final String key) { if (currentQuery != null && currentQuery.equalsIgnoreCase(prevQuery)) return; sendSearchRequest(); prevQuery = currentQuery; } @Override public void onError(final Throwable t) { Log.e(TAG, "onError: ", t); } }; searchDebouncer = new Debouncer<>(searchCallback, 1000); } private void updateRankedRecipientCache() { rankedRecipientsCache.setUpdateInitiated(true); directMessagesRepository.rankedRecipients( null, null, null, CoroutineUtilsKt.getContinuation((response, throwable) -> { if (throwable != null) { Log.e(TAG, "updateRankedRecipientCache: ", throwable); rankedRecipientsCache.setUpdateInitiated(false); rankedRecipientsCache.setFailed(true); continueSearchIfRequired(); return; } rankedRecipientsCache.setResponse(response); rankedRecipientsCache.setUpdateInitiated(false); continueSearchIfRequired(); }, Dispatchers.getIO()) ); } private void continueSearchIfRequired() { if (!waitingForCache) { if (showCachedResults) { recipients.postValue(Resource.success(getCachedRecipients())); } return; } waitingForCache = false; sendSearchRequest(); } public LiveData>> getRecipients() { return recipients; } public void search(@Nullable final String query) { currentQuery = query; if (TextUtils.isEmpty(query)) { cancelSearch(); if (showCachedResults) { recipients.postValue(Resource.success(getCachedRecipients())); } return; } recipients.postValue(Resource.loading(getCachedRecipients())); searchDebouncer.call(DEBOUNCE_KEY); } private void sendSearchRequest() { if (!rankedRecipientsCache.isFailed()) { // to avoid infinite loop in case of any network issues if (rankedRecipientsCache.isUpdateInitiated()) { // wait for cache first waitingForCache = true; return; } if (rankedRecipientsCache.isExpired()) { // update cache first updateRankedRecipientCache(); waitingForCache = true; return; } } switch (searchMode) { case RAVEN: case RESHARE: rankedRecipientSearch(); break; case USER_SEARCH: default: defaultUserSearch(); break; } } private void defaultUserSearch() { userRepository.search(currentQuery, CoroutineUtilsKt.getContinuation((userSearchResponse, throwable) -> { if (throwable != null) { Log.e(TAG, "onFailure: ", throwable); recipients.postValue(Resource.error(throwable.getMessage(), getCachedRecipients())); searchRequest = null; return; } if (userSearchResponse == null) { recipients.postValue(Resource.error(R.string.generic_null_response, getCachedRecipients())); searchRequest = null; return; } final List list = userSearchResponse .getUsers() .stream() .map(RankedRecipient::of) .collect(Collectors.toList()); recipients.postValue(Resource.success(mergeResponseWithCache(list))); searchRequest = null; })); } private void rankedRecipientSearch() { directMessagesRepository.rankedRecipients( searchMode.getMode(), showGroups, currentQuery, CoroutineUtilsKt.getContinuation((response, throwable) -> { if (throwable != null) { Log.e(TAG, "rankedRecipientSearch: ", throwable); recipients.postValue(Resource.error(throwable.getMessage(), getCachedRecipients())); return; } final List list = response.getRankedRecipients(); if (list != null) { recipients.postValue(Resource.success(mergeResponseWithCache(list))); } }, Dispatchers.getIO()) ); } private List mergeResponseWithCache(@NonNull final List list) { final Iterator iterator = list.stream() .filter(Objects::nonNull) .filter(this::filterValidRecipients) .filter(this::filterOutGroups) .filter(this::filterIdsToHide) .iterator(); return ImmutableList.builder() .addAll(getCachedRecipients()) // add cached results first .addAll(iterator) .build(); } @NonNull private List getCachedRecipients() { final List rankedRecipients = rankedRecipientsCache.getRankedRecipients(); final List list = rankedRecipients != null ? rankedRecipients : Collections.emptyList(); return list.stream() .filter(Objects::nonNull) .filter(this::filterValidRecipients) .filter(this::filterOutGroups) .filter(this::filterQuery) .filter(this::filterIdsToHide) .collect(Collectors.toList()); } private void handleErrorResponse(final Response response, boolean updateResource) { final ResponseBody errorBody = response.errorBody(); if (errorBody == null) { if (updateResource) { recipients.postValue(Resource.error(R.string.generic_failed_request, getCachedRecipients())); } return; } String errorString; try { errorString = errorBody.string(); Log.e(TAG, "handleErrorResponse: " + errorString); } catch (IOException e) { Log.e(TAG, "handleErrorResponse: ", e); errorString = e.getMessage(); } if (updateResource) { recipients.postValue(Resource.error(errorString, getCachedRecipients())); } } public void cleanup() { searchDebouncer.terminate(); } public void setSelectedRecipient(final RankedRecipient recipient, final boolean selected) { if (selected) { selectedRecipients.add(recipient); } else { selectedRecipients.remove(recipient); } showAction.postValue(!selectedRecipients.isEmpty()); } public Set getSelectedRecipients() { return selectedRecipients; } public void clearResults() { recipients.postValue(Resource.success(Collections.emptyList())); prevQuery = ""; } public void cancelSearch() { searchDebouncer.cancel(DEBOUNCE_KEY); if (searchRequest != null) { searchRequest.cancel(); searchRequest = null; } } public LiveData showAction() { return showAction; } public void setSearchMode(final UserSearchMode searchMode) { this.searchMode = searchMode; } public void setShowGroups(final boolean showGroups) { this.showGroups = showGroups; } public void setHideUserIds(final long[] hideUserIds) { if (hideUserIds != null) { final long[] copy = Arrays.copyOf(hideUserIds, hideUserIds.length); Arrays.sort(copy); this.hideUserIds = copy; return; } this.hideUserIds = null; } public void setHideThreadIds(final String[] hideThreadIds) { if (hideThreadIds != null) { final String[] copy = Arrays.copyOf(hideThreadIds, hideThreadIds.length); Arrays.sort(copy); this.hideThreadIds = copy; return; } this.hideThreadIds = null; } private boolean filterOutGroups(@NonNull RankedRecipient recipient) { // if showGroups is false, remove groups from the list if (showGroups || recipient.getThread() == null) { return true; } return !recipient.getThread().isGroup(); } private boolean filterValidRecipients(@NonNull RankedRecipient recipient) { // check if both user and thread are null return recipient.getUser() != null || recipient.getThread() != null; } private boolean filterIdsToHide(@NonNull RankedRecipient recipient) { if (hideThreadIds != null && recipient.getThread() != null) { return Arrays.binarySearch(hideThreadIds, recipient.getThread().getThreadId()) < 0; } if (hideUserIds != null) { long pk = -1; if (recipient.getUser() != null) { pk = recipient.getUser().getPk(); } else if (recipient.getThread() != null && !recipient.getThread().isGroup()) { final User user = recipient.getThread().getUsers().get(0); pk = user.getPk(); } return Arrays.binarySearch(hideUserIds, pk) < 0; } return true; } private boolean filterQuery(@NonNull RankedRecipient recipient) { if (TextUtils.isEmpty(currentQuery)) { return true; } if (recipient.getThread() != null) { return recipient.getThread().getThreadTitle().toLowerCase().contains(currentQuery.toLowerCase()); } return recipient.getUser().getUsername().toLowerCase().contains(currentQuery.toLowerCase()) || recipient.getUser().getFullName().toLowerCase().contains(currentQuery.toLowerCase()); } public void showCachedResults() { this.showCachedResults = true; if (rankedRecipientsCache.isUpdateInitiated()) return; recipients.postValue(Resource.success(getCachedRecipients())); } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/factories/DirectSettingsViewModelFactory.java ================================================ package awais.instagrabber.viewmodels.factories; import android.app.Application; import androidx.annotation.NonNull; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.viewmodels.DirectSettingsViewModel; public class DirectSettingsViewModelFactory implements ViewModelProvider.Factory { private final Application application; private final String threadId; private final boolean pending; private final User currentUser; public DirectSettingsViewModelFactory(@NonNull final Application application, @NonNull final String threadId, final boolean pending, @NonNull final User currentUser) { this.application = application; this.threadId = threadId; this.pending = pending; this.currentUser = currentUser; } @NonNull @Override public T create(@NonNull final Class modelClass) { //noinspection unchecked return (T) new DirectSettingsViewModel(application, threadId, pending, currentUser); } } ================================================ FILE: app/src/main/java/awais/instagrabber/viewmodels/factories/DirectThreadViewModelFactory.java ================================================ package awais.instagrabber.viewmodels.factories; import android.app.Application; import androidx.annotation.NonNull; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.viewmodels.DirectThreadViewModel; public class DirectThreadViewModelFactory implements ViewModelProvider.Factory { private final Application application; private final String threadId; private final boolean pending; private final User currentUser; public DirectThreadViewModelFactory(@NonNull final Application application, @NonNull final String threadId, final boolean pending, @NonNull final User currentUser) { this.application = application; this.threadId = threadId; this.pending = pending; this.currentUser = currentUser; } @NonNull @Override public T create(@NonNull final Class modelClass) { //noinspection unchecked return (T) new DirectThreadViewModel(application, threadId, pending, currentUser); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/CollectionService.java ================================================ package awais.instagrabber.webservices; import android.text.TextUtils; import androidx.annotation.NonNull; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; import awais.instagrabber.repositories.CollectionRepository; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.utils.Utils; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class CollectionService { private static final String TAG = "CollectionService"; private final CollectionRepository repository; private final String deviceUuid, csrfToken; private final long userId; private static CollectionService instance; private CollectionService(final String deviceUuid, final String csrfToken, final long userId) { this.deviceUuid = deviceUuid; this.csrfToken = csrfToken; this.userId = userId; repository = RetrofitFactory.INSTANCE .getRetrofit() .create(CollectionRepository.class); } public String getCsrfToken() { return csrfToken; } public String getDeviceUuid() { return deviceUuid; } public long getUserId() { return userId; } public static CollectionService getInstance(final String deviceUuid, final String csrfToken, final long userId) { if (instance == null || !Objects.equals(instance.getCsrfToken(), csrfToken) || !Objects.equals(instance.getDeviceUuid(), deviceUuid) || !Objects.equals(instance.getUserId(), userId)) { instance = new CollectionService(deviceUuid, csrfToken, userId); } return instance; } public void addPostsToCollection(final String collectionId, final List posts, final ServiceCallback callback) { final Map form = new HashMap<>(2); form.put("module_name", "feed_saved_add_to_collection"); final List ids; ids = posts.stream() .map(Media::getPk) .filter(Objects::nonNull) .collect(Collectors.toList()); form.put("added_media_ids", "[" + TextUtils.join(",", ids) + "]"); changeCollection(collectionId, "edit", form, callback); } public void editCollectionName(final String collectionId, final String name, final ServiceCallback callback) { final Map form = new HashMap<>(1); form.put("name", name); changeCollection(collectionId, "edit", form, callback); } public void deleteCollection(final String collectionId, final ServiceCallback callback) { changeCollection(collectionId, "delete", null, callback); } public void changeCollection(final String collectionId, final String action, final Map options, final ServiceCallback callback) { final Map form = new HashMap<>(); form.put("_csrftoken", csrfToken); form.put("_uuid", deviceUuid); form.put("_uid", userId); if (options != null) form.putAll(options); final Map signedForm = Utils.sign(form); final Call request = repository.changeCollection(collectionId, action, signedForm); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (callback == null) return; final String collectionsListResponse = response.body(); if (collectionsListResponse == null) { callback.onSuccess(null); return; } callback.onSuccess(collectionsListResponse); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { if (callback != null) { callback.onFailure(t); } } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/CommentService.java ================================================ package awais.instagrabber.webservices; import android.util.Log; import androidx.annotation.NonNull; import com.google.gson.Gson; import org.json.JSONException; import org.json.JSONObject; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; import awais.instagrabber.models.Comment; import awais.instagrabber.repositories.CommentRepository; import awais.instagrabber.repositories.responses.ChildCommentsFetchResponse; import awais.instagrabber.repositories.responses.CommentsFetchResponse; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class CommentService { private static final String TAG = "CommentService"; private final CommentRepository repository; private final String deviceUuid, csrfToken; private final long userId; private static CommentService instance; private CommentService(final String deviceUuid, final String csrfToken, final long userId) { this.deviceUuid = deviceUuid; this.csrfToken = csrfToken; this.userId = userId; repository = RetrofitFactory.INSTANCE .getRetrofit() .create(CommentRepository.class); } public String getCsrfToken() { return csrfToken; } public String getDeviceUuid() { return deviceUuid; } public long getUserId() { return userId; } public static CommentService getInstance(final String deviceUuid, final String csrfToken, final long userId) { if (instance == null || !Objects.equals(instance.getCsrfToken(), csrfToken) || !Objects.equals(instance.getDeviceUuid(), deviceUuid) || !Objects.equals(instance.getUserId(), userId)) { instance = new CommentService(deviceUuid, csrfToken, userId); } return instance; } public void fetchComments(@NonNull final String mediaId, final String minId, @NonNull final ServiceCallback callback) { final Map form = new HashMap<>(); form.put("can_support_threading", "true"); if (minId != null) form.put("min_id", minId); final Call request = repository.fetchComments(mediaId, form); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { callback.onSuccess(response.body()); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); } }); } public void fetchChildComments(@NonNull final String mediaId, @NonNull final String commentId, final String maxId, @NonNull final ServiceCallback callback) { final Map form = new HashMap<>(); if (maxId != null) form.put("max_id", maxId); final Call request = repository.fetchChildComments(mediaId, commentId, form); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final ChildCommentsFetchResponse cfr = response.body(); if (cfr == null) callback.onFailure(new Exception("response is empty")); callback.onSuccess(cfr); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); } }); } public void comment(@NonNull final String mediaId, @NonNull final String comment, final String replyToCommentId, @NonNull final ServiceCallback callback) { final String module = "self_comments_v2"; final Map form = new HashMap<>(); // form.put("user_breadcrumb", userBreadcrumb(comment.length())); form.put("idempotence_token", UUID.randomUUID().toString()); form.put("_csrftoken", csrfToken); form.put("_uid", userId); form.put("_uuid", deviceUuid); form.put("comment_text", comment); form.put("containermodule", module); if (!TextUtils.isEmpty(replyToCommentId)) { form.put("replied_to_comment_id", replyToCommentId); } final Map signedForm = Utils.sign(form); final Call commentRequest = repository.comment(mediaId, signedForm); commentRequest.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final String body = response.body(); if (body == null) { Log.e(TAG, "Error occurred while creating comment"); callback.onSuccess(null); return; } try { final JSONObject jsonObject = new JSONObject(body); // final String status = jsonObject.optString("status"); final JSONObject commentJsonObject = jsonObject.optJSONObject("comment"); Comment comment = null; if (commentJsonObject != null) { final JSONObject userJsonObject = commentJsonObject.optJSONObject("user"); if (userJsonObject != null) { final Gson gson = new Gson(); final User user = gson.fromJson(userJsonObject.toString(), User.class); comment = new Comment( commentJsonObject.optString("pk"), commentJsonObject.optString("text"), commentJsonObject.optLong("created_at"), 0L, false, user, 0 ); } } callback.onSuccess(comment); } catch (Exception e) { callback.onFailure(e); } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); } }); } public void deleteComment(final String mediaId, final String commentId, @NonNull final ServiceCallback callback) { deleteComments(mediaId, Collections.singletonList(commentId), callback); } public void deleteComments(final String mediaId, final List commentIds, @NonNull final ServiceCallback callback) { final Map form = new HashMap<>(); form.put("comment_ids_to_delete", android.text.TextUtils.join(",", commentIds)); form.put("_csrftoken", csrfToken); form.put("_uid", userId); form.put("_uuid", deviceUuid); final Map signedForm = Utils.sign(form); final Call bulkDeleteRequest = repository.commentsBulkDelete(mediaId, signedForm); bulkDeleteRequest.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final String body = response.body(); if (body == null) { Log.e(TAG, "Error occurred while deleting comments"); callback.onSuccess(false); return; } try { final JSONObject jsonObject = new JSONObject(body); final String status = jsonObject.optString("status"); callback.onSuccess(status.equals("ok")); } catch (JSONException e) { // Log.e(TAG, "Error parsing body", e); callback.onFailure(e); } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { // Log.e(TAG, "Error deleting comments", t); callback.onFailure(t); } }); } public void commentLike(@NonNull final String commentId, @NonNull final ServiceCallback callback) { final Map form = new HashMap<>(); form.put("_csrftoken", csrfToken); // form.put("_uid", userId); // form.put("_uuid", deviceUuid); final Map signedForm = Utils.sign(form); final Call commentLikeRequest = repository.commentLike(commentId, signedForm); commentLikeRequest.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final String body = response.body(); if (body == null) { Log.e(TAG, "Error occurred while liking comment"); callback.onSuccess(false); return; } try { final JSONObject jsonObject = new JSONObject(body); final String status = jsonObject.optString("status"); callback.onSuccess(status.equals("ok")); } catch (JSONException e) { // Log.e(TAG, "Error parsing body", e); callback.onFailure(e); } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { Log.e(TAG, "Error liking comment", t); callback.onFailure(t); } }); } public void commentUnlike(final String commentId, @NonNull final ServiceCallback callback) { final Map form = new HashMap<>(); form.put("_csrftoken", csrfToken); // form.put("_uid", userId); // form.put("_uuid", deviceUuid); final Map signedForm = Utils.sign(form); final Call commentUnlikeRequest = repository.commentUnlike(commentId, signedForm); commentUnlikeRequest.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final String body = response.body(); if (body == null) { Log.e(TAG, "Error occurred while unliking comment"); callback.onSuccess(false); return; } try { final JSONObject jsonObject = new JSONObject(body); final String status = jsonObject.optString("status"); callback.onSuccess(status.equals("ok")); } catch (JSONException e) { // Log.e(TAG, "Error parsing body", e); callback.onFailure(e); } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { Log.e(TAG, "Error unliking comment", t); callback.onFailure(t); } }); } public void translate(final String id, @NonNull final ServiceCallback callback) { final Map form = new HashMap<>(); form.put("id", String.valueOf(id)); form.put("type", "2"); final Call request = repository.translate(form); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final String body = response.body(); if (body == null) { Log.e(TAG, "Error occurred while translating"); callback.onSuccess(null); return; } try { final JSONObject jsonObject = new JSONObject(body); final String translation = jsonObject.optString("translation"); callback.onSuccess(translation); } catch (JSONException e) { // Log.e(TAG, "Error parsing body", e); callback.onFailure(e); } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { Log.e(TAG, "Error translating", t); callback.onFailure(t); } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/DirectMessagesRepository.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.repositories.DirectMessagesService import awais.instagrabber.repositories.requests.directmessages.* import awais.instagrabber.repositories.responses.directmessages.* import awais.instagrabber.repositories.responses.giphy.GiphyGif import awais.instagrabber.utils.TextUtils.extractUrls import awais.instagrabber.utils.Utils import org.json.JSONArray import java.util.* open class DirectMessagesRepository(private val service: DirectMessagesService) { suspend fun fetchInbox( cursor: String?, seqId: Long, ): DirectInboxResponse { val queryMap = mutableMapOf( "visual_message_return_type" to "unseen", "thread_message_limit" to 10.toString(), "persistentBadging" to true.toString(), "limit" to 10.toString(), ) if (!cursor.isNullOrBlank()) { queryMap["cursor"] = cursor queryMap["direction"] = "older" } if (seqId != 0L) { queryMap["seq_id"] = seqId.toString() } return service.fetchInbox(queryMap) } suspend fun fetchThread( threadId: String, cursor: String?, ): DirectThreadFeedResponse { val queryMap = mutableMapOf( "visual_message_return_type" to "unseen", "limit" to 20.toString(), "direction" to "older", ) if (!cursor.isNullOrBlank()) { queryMap["cursor"] = cursor } return service.fetchThread(threadId, queryMap) } suspend fun fetchUnseenCount(): DirectBadgeCount = service.fetchUnseenCount() suspend fun broadcastText( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, text: String, repliedToItemId: String?, repliedToClientContext: String?, ): DirectThreadBroadcastResponse { val urls = extractUrls(text) if (urls.isNotEmpty()) { return broadcastLink( csrfToken, userId, deviceUuid, clientContext, threadIdsOrUserIds, text, urls, repliedToItemId, repliedToClientContext ) } val broadcastOptions = TextBroadcastOptions(clientContext, threadIdsOrUserIds, text) if (!repliedToItemId.isNullOrBlank() && !repliedToClientContext.isNullOrBlank()) { broadcastOptions.repliedToItemId = repliedToItemId broadcastOptions.repliedToClientContext = repliedToClientContext } return broadcast(csrfToken, userId, deviceUuid, broadcastOptions) } private suspend fun broadcastLink( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, linkText: String, urls: List, repliedToItemId: String?, repliedToClientContext: String?, ): DirectThreadBroadcastResponse { val broadcastOptions = LinkBroadcastOptions(clientContext, threadIdsOrUserIds, linkText, urls) if (!repliedToItemId.isNullOrBlank() && !repliedToClientContext.isNullOrBlank()) { broadcastOptions.repliedToItemId = repliedToItemId broadcastOptions.repliedToClientContext = repliedToClientContext } return broadcast(csrfToken, userId, deviceUuid, broadcastOptions) } suspend fun broadcastPhoto( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, uploadId: String, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, PhotoBroadcastOptions(clientContext, threadIdsOrUserIds, true, uploadId)) suspend fun broadcastVideo( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, uploadId: String, videoResult: String, sampled: Boolean, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, VideoBroadcastOptions(clientContext, threadIdsOrUserIds, videoResult, uploadId, sampled)) suspend fun broadcastVoice( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, uploadId: String, waveform: List, samplingFreq: Int, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, VoiceBroadcastOptions(clientContext, threadIdsOrUserIds, uploadId, waveform, samplingFreq)) suspend fun broadcastStoryReply( csrfToken: String, userId: Long, deviceUuid: String, threadIdsOrUserIds: ThreadIdsOrUserIds, text: String, mediaId: String, reelId: String, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, StoryReplyBroadcastOptions(UUID.randomUUID().toString(), threadIdsOrUserIds, text, mediaId, reelId)) suspend fun broadcastReaction( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, itemId: String, emoji: String?, delete: Boolean, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, ReactionBroadcastOptions(clientContext, threadIdsOrUserIds, itemId, emoji, delete)) suspend fun broadcastAnimatedMedia( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, giphyGif: GiphyGif, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, AnimatedMediaBroadcastOptions(clientContext, threadIdsOrUserIds, giphyGif)) suspend fun broadcastMediaShare( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, mediaId: String, childId: String?, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, MediaShareBroadcastOptions(clientContext, threadIdsOrUserIds, mediaId, childId)) suspend fun broadcastProfile( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, profileId: String, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, ProfileBroadcastOptions(clientContext, threadIdsOrUserIds, profileId)) suspend fun broadcastStory( csrfToken: String, userId: Long, deviceUuid: String, clientContext: String, threadIdsOrUserIds: ThreadIdsOrUserIds, mediaId: String, reelId: String, ): DirectThreadBroadcastResponse = broadcast(csrfToken, userId, deviceUuid, StoryBroadcastOptions(clientContext, threadIdsOrUserIds, mediaId, reelId)) private suspend fun broadcast( csrfToken: String, userId: Long, deviceUuid: String, broadcastOptions: BroadcastOptions, ): DirectThreadBroadcastResponse { require(broadcastOptions.clientContext.isNotBlank()) { "Broadcast requires a valid client context value" } val form = mutableMapOf( "_csrftoken" to csrfToken, "_uid" to userId.toString(10), "__uuid" to deviceUuid, "client_context" to broadcastOptions.clientContext, "mutation_token" to broadcastOptions.clientContext, ) val threadIds = broadcastOptions.threadIds val userIds = broadcastOptions.userIds require(!userIds.isNullOrEmpty() || !threadIds.isNullOrEmpty()) { "Either pass a list of thread ids or a list of lists of user ids" } if (!threadIds.isNullOrEmpty()) { form["thread_ids"] = JSONArray(threadIds).toString() } if (!userIds.isNullOrEmpty()) { form["recipient_users"] = JSONArray(userIds).toString() } val repliedToItemId = broadcastOptions.repliedToItemId val repliedToClientContext = broadcastOptions.repliedToClientContext if (!repliedToItemId.isNullOrBlank() && !repliedToClientContext.isNullOrBlank()) { form["replied_to_item_id"] = repliedToItemId form["replied_to_client_context"] = repliedToClientContext } form.putAll(broadcastOptions.formMap) form["action"] = "send_item" // val signedForm = Utils.sign(form) return service.broadcast(broadcastOptions.itemType.value, form) } suspend fun addUsers( csrfToken: String, deviceUuid: String, threadId: String, userIds: Collection, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) return service.addUsers(threadId, form) } suspend fun removeUsers( csrfToken: String, deviceUuid: String, threadId: String, userIds: Collection, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) return service.removeUsers(threadId, form) } suspend fun updateTitle( csrfToken: String, deviceUuid: String, threadId: String, title: String, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "title" to title, ) return service.updateTitle(threadId, form) } suspend fun addAdmins( csrfToken: String, deviceUuid: String, threadId: String, userIds: Collection, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) return service.addAdmins(threadId, form) } suspend fun removeAdmins( csrfToken: String, deviceUuid: String, threadId: String, userIds: Collection, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) return service.removeAdmins(threadId, form) } suspend fun deleteItem( csrfToken: String, deviceUuid: String, threadId: String, itemId: String, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.deleteItem(threadId, itemId, form) } suspend fun rankedRecipients( mode: String?, showThreads: Boolean?, query: String?, ): RankedRecipientsResponse { // String correctedMode = mode; // if (TextUtils.isEmpty(mode) || (!mode.equals("raven") && !mode.equals("reshare"))) { // correctedMode = "raven"; // } val queryMap = mutableMapOf() if (!mode.isNullOrBlank()) { queryMap["mode"] = mode } if (!query.isNullOrBlank()) { queryMap["query"] = query } if (showThreads != null) { queryMap["showThreads"] = showThreads.toString() } return service.rankedRecipients(queryMap) } suspend fun forward( toThreadId: String, itemType: String, fromThreadId: String, itemId: String, ): DirectThreadBroadcastResponse { val form = mapOf( "action" to "forward_item", "thread_id" to toThreadId, "item_type" to itemType, "forwarded_from_thread_id" to fromThreadId, "forwarded_from_thread_item_id" to itemId, ) return service.forward(form) } suspend fun createThread( csrfToken: String, userId: Long, deviceUuid: String, userIds: List, threadTitle: String?, ): DirectThread { val userIdStringList = userIds.map { it.toString() } val form = mutableMapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "_uid" to userId, "recipient_users" to JSONArray(userIdStringList).toString(), ) if (!threadTitle.isNullOrBlank()) { form["thread_title"] = threadTitle } val signedForm = Utils.sign(form) return service.createThread(signedForm) } suspend fun mute( csrfToken: String, deviceUuid: String, threadId: String, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid ) return service.mute(threadId, form) } suspend fun unmute( csrfToken: String, deviceUuid: String, threadId: String, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.unmute(threadId, form) } suspend fun muteMentions( csrfToken: String, deviceUuid: String, threadId: String, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.muteMentions(threadId, form) } suspend fun unmuteMentions( csrfToken: String, deviceUuid: String, threadId: String, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.unmuteMentions(threadId, form) } suspend fun participantRequests( threadId: String, pageSize: Int, cursor: String? = null, ): DirectThreadParticipantRequestsResponse { return service.participantRequests(threadId, pageSize, cursor) } suspend fun approveParticipantRequests( csrfToken: String, deviceUuid: String, threadId: String, userIds: List, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), // "share_join_chat_story" to String.valueOf(true) ) return service.approveParticipantRequests(threadId, form) } suspend fun declineParticipantRequests( csrfToken: String, deviceUuid: String, threadId: String, userIds: List, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) return service.declineParticipantRequests(threadId, form) } suspend fun approvalRequired( csrfToken: String, deviceUuid: String, threadId: String, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.approvalRequired(threadId, form) } suspend fun approvalNotRequired( csrfToken: String, deviceUuid: String, threadId: String, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.approvalNotRequired(threadId, form) } suspend fun leave( csrfToken: String, deviceUuid: String, threadId: String, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.leave(threadId, form) } suspend fun end( csrfToken: String, deviceUuid: String, threadId: String, ): DirectThreadDetailsChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.end(threadId, form) } suspend fun fetchPendingInbox(cursor: String?, seqId: Long): DirectInboxResponse { val queryMap = mutableMapOf( "visual_message_return_type" to "unseen", "thread_message_limit" to 20.toString(), "persistentBadging" to true.toString(), "limit" to 10.toString(), ) if (!cursor.isNullOrBlank()) { queryMap["cursor"] = cursor queryMap["direction"] = "older" } if (seqId != 0L) { queryMap["seq_id"] = seqId.toString() } return service.fetchPendingInbox(queryMap) } suspend fun approveRequest( csrfToken: String, deviceUuid: String, threadId: String, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.approveRequest(threadId, form) } suspend fun declineRequest( csrfToken: String, deviceUuid: String, threadId: String, ): String { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) return service.declineRequest(threadId, form) } suspend fun markAsSeen( csrfToken: String, deviceUuid: String, threadId: String, directItem: DirectItem, ): DirectItemSeenResponse? { val itemId = directItem.itemId ?: return null val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "use_unified_inbox" to "true", "action" to "mark_seen", "thread_id" to threadId, "item_id" to itemId, ) return service.markItemSeen(threadId, itemId, form) } companion object { @Volatile private var INSTANCE: DirectMessagesRepository? = null fun getInstance(): DirectMessagesRepository { return INSTANCE ?: synchronized(this) { val service: DirectMessagesService = RetrofitFactory.retrofit.create(DirectMessagesService::class.java) DirectMessagesRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/DiscoverService.java ================================================ package awais.instagrabber.webservices; import androidx.annotation.NonNull; import com.google.common.collect.ImmutableMap; import java.util.Objects; import awais.instagrabber.repositories.DiscoverRepository; import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; import awais.instagrabber.utils.TextUtils; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class DiscoverService { private static final String TAG = "DiscoverService"; private final DiscoverRepository repository; private static DiscoverService instance; private DiscoverService() { repository = RetrofitFactory.INSTANCE .getRetrofit() .create(DiscoverRepository.class); } public static DiscoverService getInstance() { if (instance == null) { instance = new DiscoverService(); } return instance; } public void topicalExplore(@NonNull final TopicalExploreRequest request, final ServiceCallback callback) { final ImmutableMap.Builder builder = ImmutableMap.builder() .put("module", "explore_popular"); if (!TextUtils.isEmpty(request.getModule())) { builder.put("module", request.getModule()); } if (!TextUtils.isEmpty(request.getClusterId())) { builder.put("cluster_id", request.getClusterId()); } if (!TextUtils.isEmpty(request.getMaxId())) { builder.put("max_id", request.getMaxId()); } final Call req = repository.topicalExplore(builder.build()); req.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (callback == null) return; final TopicalExploreFeedResponse feedResponse = response.body(); if (feedResponse == null) { callback.onSuccess(null); return; } callback.onSuccess(feedResponse); // try { // } catch (JSONException e) { // callback.onFailure(e); // // Log.e(TAG, "Error parsing topicalExplore response", e); // } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); } }); } // private TopicalExploreResponse parseTopicalExploreResponse(@NonNull final String body) throws JSONException { // final JSONObject root = new JSONObject(body); // final boolean moreAvailable = root.optBoolean("more_available"); // final int nextMaxId = root.optInt("next_max_id", -1); // final int numResults = root.optInt("num_results"); // final String status = root.optString("status"); // final JSONArray clustersJson = root.optJSONArray("clusters"); // final List clusters = parseClusters(clustersJson); // final JSONArray itemsJson = root.optJSONArray("items"); // final List items = parseItems(itemsJson); // return new TopicalExploreResponse( // moreAvailable, // nextMaxId, // numResults, // status, // clusters, // items // ); // } // private List parseClusters(final JSONArray clustersJson) throws JSONException { // if (clustersJson == null) { // return Collections.emptyList(); // } // final List clusters = new ArrayList<>(); // for (int i = 0; i < clustersJson.length(); i++) { // final JSONObject clusterJson = clustersJson.getJSONObject(i); // final String id = clusterJson.optString("id"); // final String title = clusterJson.optString("title"); // if (TextUtils.isEmpty(id) || TextUtils.isEmpty(title)) { // continue; // } // final String type = clusterJson.optString("type"); // final boolean canMute = clusterJson.optBoolean("can_mute"); // final boolean getMuted = clusterJson.optBoolean("is_muted"); // final JSONObject coverMediaJson = clusterJson.optJSONObject("cover_media"); // final int rankedPosition = clusterJson.optInt("ranked_position"); // final FeedModel feedModel = parseClusterCover(coverMediaJson); // final TopicCluster topicCluster = new TopicCluster( // id, // title, // type, // canMute, // getMuted, // rankedPosition, // feedModel // ); // clusters.add(topicCluster); // } // return clusters; // } // private FeedModel parseClusterCover(final JSONObject coverMediaJson) throws JSONException { // if (coverMediaJson == null) { // return null; // } // ProfileModel profileModel = null; // if (coverMediaJson.has("user")) { // final JSONObject user = coverMediaJson.getJSONObject("user"); // profileModel = new ProfileModel( // user.optBoolean("is_private"), // false, // user.optBoolean("is_verified"), // user.getString("pk"), // user.getString(Constants.EXTRAS_USERNAME), // user.optString("full_name"), // null, // null, // user.getString("profile_pic_url"), // null, // 0, // 0, // 0, // false, // false, // false, // false, // false); // } // final String resourceUrl = ResponseBodyUtils.getHighQualityImage(coverMediaJson); // final String thumbnailUrl = ResponseBodyUtils.getLowQualityImage(coverMediaJson); // final int width = coverMediaJson.optInt("original_width"); // final int height = coverMediaJson.optInt("original_height"); // return new FeedModel.Builder() // .setProfileModel(profileModel) // .setItemType(MediaItemType.MEDIA_TYPE_IMAGE) // .setViewCount(0) // .setPostId(coverMediaJson.getString(Constants.EXTRAS_ID)) // .setDisplayUrl(resourceUrl) // .setThumbnailUrl(thumbnailUrl) // .setShortCode(coverMediaJson.getString("code")) // .setPostCaption(null) // .setCommentsCount(0) // .setTimestamp(coverMediaJson.optLong("taken_at", -1)) // .setLiked(false) // .setBookmarked(false) // .setLikesCount(0) // .setLocationName(null) // .setLocationId(null) // .setImageHeight(height) // .setImageWidth(width) // .build(); // } // private List parseItems(final JSONArray items) throws JSONException { // if (items == null) { // return Collections.emptyList(); // } // final List feedModels = new ArrayList<>(); // for (int i = 0; i < items.length(); i++) { // final JSONObject itemJson = items.optJSONObject(i); // if (itemJson == null) { // continue; // } // final JSONObject mediaJson = itemJson.optJSONObject("media"); // final FeedModel feedModel = ResponseBodyUtils.parseItem(mediaJson); // if (feedModel != null) { // feedModels.add(feedModel); // } // } // return feedModels; // } public static class TopicalExploreRequest { private String module; private String clusterId; private String maxId; public TopicalExploreRequest() {} public TopicalExploreRequest(final String module, final String clusterId, final String maxId) { this.module = module; this.clusterId = clusterId; this.maxId = maxId; } public String getModule() { return module; } public TopicalExploreRequest setModule(final String module) { this.module = module; return this; } public String getClusterId() { return clusterId; } public void setClusterId(final String clusterId) { this.clusterId = clusterId; } public String getMaxId() { return maxId; } public void setMaxId(final String maxId) { this.maxId = maxId; } @Override public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final TopicalExploreRequest that = (TopicalExploreRequest) o; return maxId.equals(that.maxId) && Objects.equals(module, that.module) && Objects.equals(clusterId, that.clusterId); } @Override public int hashCode() { return Objects.hash(module, clusterId, maxId); } @NonNull @Override public String toString() { return "TopicalExploreRequest{" + "module='" + module + '\'' + ", clusterId='" + clusterId + '\'' + ", maxId=" + maxId + '}'; } } // public static class TopicalExploreResponse { // // private boolean moreAvailable; // private int nextMaxId; // private int numResults; // private String status; // private List clusters; // private List items; // // public TopicalExploreResponse() {} // // public TopicalExploreResponse(final boolean moreAvailable, // final int nextMaxId, // final int numResults, // final String status, // final List clusters, final List items) { // this.moreAvailable = moreAvailable; // this.nextMaxId = nextMaxId; // this.numResults = numResults; // this.status = status; // this.clusters = clusters; // this.items = items; // } // // public boolean isMoreAvailable() { // return moreAvailable; // } // // public TopicalExploreResponse setMoreAvailable(final boolean moreAvailable) { // this.moreAvailable = moreAvailable; // return this; // } // // public int getNextMaxId() { // return nextMaxId; // } // // public TopicalExploreResponse setNextMaxId(final int nextMaxId) { // this.nextMaxId = nextMaxId; // return this; // } // // public int getNumResults() { // return numResults; // } // // public TopicalExploreResponse setNumResults(final int numResults) { // this.numResults = numResults; // return this; // } // // public String getStatus() { // return status; // } // // public TopicalExploreResponse setStatus(final String status) { // this.status = status; // return this; // } // // public List getClusters() { // return clusters; // } // // public TopicalExploreResponse setClusters(final List clusters) { // this.clusters = clusters; // return this; // } // // public List getItems() { // return items; // } // // public TopicalExploreResponse setItems(final List items) { // this.items = items; // return this; // } // // @Override // public boolean equals(final Object o) { // if (this == o) return true; // if (o == null || getClass() != o.getClass()) return false; // final TopicalExploreResponse that = (TopicalExploreResponse) o; // return moreAvailable == that.moreAvailable && // nextMaxId == that.nextMaxId && // numResults == that.numResults && // Objects.equals(status, that.status) && // Objects.equals(clusters, that.clusters) && // Objects.equals(items, that.items); // } // // @Override // public int hashCode() { // return Objects.hash(moreAvailable, nextMaxId, numResults, status, clusters, items); // } // // @Override // public String toString() { // return "TopicalExploreResponse{" + // "moreAvailable=" + moreAvailable + // ", nextMaxId=" + nextMaxId + // ", numResults=" + numResults + // ", status='" + status + '\'' + // ", clusters=" + clusters + // ", items=" + items + // '}'; // } // } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/FeedService.java ================================================ package awais.instagrabber.webservices; import android.util.Log; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import awais.instagrabber.repositories.FeedRepository; import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.repositories.responses.feed.EndOfFeedDemarcator; import awais.instagrabber.repositories.responses.feed.EndOfFeedGroup; import awais.instagrabber.repositories.responses.feed.EndOfFeedGroupSet; import awais.instagrabber.repositories.responses.feed.FeedFetchResponse; import awais.instagrabber.utils.TextUtils; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class FeedService { private static final String TAG = "FeedService"; private final FeedRepository repository; private static FeedService instance; private FeedService() { repository = RetrofitFactory.INSTANCE .getRetrofit() .create(FeedRepository.class); } public static FeedService getInstance() { if (instance == null) { instance = new FeedService(); } return instance; } public void fetch(final String csrfToken, final String deviceUuid, final String cursor, final ServiceCallback callback) { final Map form = new HashMap<>(); form.put("_uuid", deviceUuid); form.put("_csrftoken", csrfToken); form.put("phone_id", UUID.randomUUID().toString()); form.put("device_id", UUID.randomUUID().toString()); form.put("client_session_id", UUID.randomUUID().toString()); form.put("is_prefetch", "0"); if (!TextUtils.isEmpty(cursor)) { form.put("max_id", cursor); form.put("reason", "pagination"); } else { form.put("is_pull_to_refresh", "1"); form.put("reason", "pull_to_refresh"); } final Call request = repository.fetch(form); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { try { // Log.d(TAG, "onResponse: body: " + response.body()); final PostsFetchResponse postsFetchResponse = parseResponse(response); if (callback != null) { callback.onSuccess(postsFetchResponse); } } catch (Exception e) { Log.e(TAG, "onResponse", e); if (callback != null) { callback.onFailure(e); } } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { if (callback != null) { callback.onFailure(t); } } }); } @NonNull private PostsFetchResponse parseResponse(@NonNull final Response response) { final FeedFetchResponse feedFetchResponse = response.body(); if (feedFetchResponse == null) { Log.e(TAG, "parseResponse: feed response body is empty with status code: " + response.code()); return new PostsFetchResponse(Collections.emptyList(), false, null); } return parseResponseBody(feedFetchResponse); } @NonNull private PostsFetchResponse parseResponseBody(@NonNull final FeedFetchResponse feedFetchResponse) { final boolean moreAvailable = feedFetchResponse.isMoreAvailable(); String nextMaxId = feedFetchResponse.getNextMaxId(); final boolean needNewMaxId = nextMaxId.equals("feed_recs_head_load"); final List allPosts = new ArrayList<>(); final List items = feedFetchResponse.getItems(); for (final Media media : items) { if (needNewMaxId && media.getEndOfFeedDemarcator() != null) { final EndOfFeedDemarcator endOfFeedDemarcator = media.getEndOfFeedDemarcator(); final EndOfFeedGroupSet groupSet = endOfFeedDemarcator.getGroupSet(); if (groupSet == null) continue; final List groups = groupSet.getGroups(); if (groups == null) continue; for (final EndOfFeedGroup group : groups) { final String id = group.getId(); if (id == null || !id.equals("past_posts")) continue; nextMaxId = group.getNextMaxId(); final List feedItems = group.getFeedItems(); for (final Media feedItem : feedItems) { if (feedItem == null || feedItem.isInjected() || feedItem.getType() == null) continue; allPosts.add(feedItem); } } continue; } if (media == null || media.isInjected() || media.getType() == null) continue; allPosts.add(media); } return new PostsFetchResponse(allPosts, moreAvailable, nextMaxId); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/FriendshipRepository.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.repositories.FriendshipService import awais.instagrabber.repositories.responses.FriendshipChangeResponse import awais.instagrabber.repositories.responses.FriendshipListFetchResponse import awais.instagrabber.repositories.responses.FriendshipRestrictResponse import awais.instagrabber.utils.Utils import awais.instagrabber.webservices.RetrofitFactory.retrofit class FriendshipRepository(private val service: FriendshipService) { suspend fun follow( csrfToken: String, userId: Long, deviceUuid: String, targetUserId: Long, ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "create", targetUserId) suspend fun unfollow( csrfToken: String, userId: Long, deviceUuid: String, targetUserId: Long, ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "destroy", targetUserId) suspend fun changeBlock( csrfToken: String, userId: Long, deviceUuid: String, unblock: Boolean, targetUserId: Long, ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, if (unblock) "unblock" else "block", targetUserId) suspend fun toggleRestrict( csrfToken: String, deviceUuid: String, targetUserId: Long, restrict: Boolean, ): FriendshipRestrictResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uuid" to deviceUuid, "target_user_id" to targetUserId.toString(), ) val action = if (restrict) "restrict" else "unrestrict" return service.toggleRestrict(action, form) } suspend fun approve( csrfToken: String, userId: Long, deviceUuid: String, targetUserId: Long, ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "approve", targetUserId) suspend fun ignore( csrfToken: String, userId: Long, deviceUuid: String, targetUserId: Long, ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "ignore", targetUserId) suspend fun removeFollower( csrfToken: String, userId: Long, deviceUuid: String, targetUserId: Long, ): FriendshipChangeResponse = change(csrfToken, userId, deviceUuid, "remove_follower", targetUserId) private suspend fun change( csrfToken: String, userId: Long, deviceUuid: String, action: String, targetUserId: Long, ): FriendshipChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uid" to userId, "_uuid" to deviceUuid, "radio_type" to "wifi-none", "user_id" to targetUserId, ) val signedForm = Utils.sign(form) return service.change(action, targetUserId, signedForm) } suspend fun changeMute( csrfToken: String, userId: Long, deviceUuid: String, unmute: Boolean, targetUserId: Long, story: Boolean, // true for story, false for posts ): FriendshipChangeResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uid" to userId.toString(), "_uuid" to deviceUuid, (if (story) "target_reel_author_id" else "target_posts_author_id") to targetUserId.toString(), ) return service.changeMute( if (unmute) "unmute_posts_or_story_from_follow" else "mute_posts_or_story_from_follow", form ) } suspend fun getList( follower: Boolean, targetUserId: Long, maxId: String?, query: String? ): FriendshipListFetchResponse { val queryMap: MutableMap = mutableMapOf() if (!maxId.isNullOrEmpty()) queryMap.set("max_id", maxId) if (!query.isNullOrEmpty()) queryMap.set("query", query) return service.getList(targetUserId, if (follower) "followers" else "following", queryMap.toMap()) } companion object { @Volatile private var INSTANCE: FriendshipRepository? = null fun getInstance(): FriendshipRepository { return INSTANCE ?: synchronized(this) { val service: FriendshipService = retrofit.create(FriendshipService::class.java) FriendshipRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/GifService.java ================================================ package awais.instagrabber.webservices; import awais.instagrabber.repositories.GifRepository; import awais.instagrabber.repositories.responses.giphy.GiphyGifResponse; import retrofit2.Call; public class GifService { private final GifRepository repository; private static GifService instance; private GifService() { repository = RetrofitFactory.INSTANCE .getRetrofit() .create(GifRepository.class); } public static GifService getInstance() { if (instance == null) { instance = new GifService(); } return instance; } public Call searchGiphyGifs(final String query, final boolean includeGifs) { final String mediaTypes = includeGifs ? "[\"giphy_gifs\",\"giphy\"]" : "[\"giphy\"]"; return repository.searchGiphyGifs("direct", query, mediaTypes); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt ================================================ package awais.instagrabber.webservices import android.util.Log import awais.instagrabber.models.enums.FollowingType import awais.instagrabber.repositories.GraphQLService import awais.instagrabber.repositories.responses.* import awais.instagrabber.utils.Constants import awais.instagrabber.utils.ResponseBodyUtils import awais.instagrabber.utils.extensions.TAG import org.json.JSONException import org.json.JSONObject import java.util.* open class GraphQLRepository(private val service: GraphQLService) { // TODO convert string response to a response class private suspend fun fetch( queryHash: String, variables: String, arg1: String, arg2: String, backup: User?, ): PostsFetchResponse { val queryMap = mapOf( "query_hash" to queryHash, "variables" to variables, ) val response = service.fetch(queryMap) return parsePostResponse(response, arg1, arg2, backup) } suspend fun fetchLocationPosts( locationId: Long, maxId: String?, ): PostsFetchResponse = fetch( "36bd0f2bf5911908de389b8ceaa3be6d", "{\"id\":\"" + locationId + "\"," + "\"first\":25," + "\"after\":\"" + (maxId ?: "") + "\"}", Constants.EXTRAS_LOCATION, "edge_location_to_media", null ) suspend fun fetchHashtagPosts( tag: String, maxId: String?, ): PostsFetchResponse = fetch( "9b498c08113f1e09617a1703c22b2f32", "{\"tag_name\":\"" + tag + "\"," + "\"first\":25," + "\"after\":\"" + (maxId ?: "") + "\"}", Constants.EXTRAS_HASHTAG, "edge_hashtag_to_media", null, ) suspend fun fetchProfilePosts( profileId: Long, postsPerPage: Int, maxId: String?, backup: User?, ): PostsFetchResponse = fetch( "02e14f6a7812a876f7d133c9555b1151", "{\"id\":\"" + profileId + "\"," + "\"first\":" + postsPerPage + "," + "\"after\":\"" + (maxId ?: "") + "\"}", Constants.EXTRAS_USER, "edge_owner_to_timeline_media", backup, ) suspend fun fetchTaggedPosts( profileId: Long, postsPerPage: Int, maxId: String?, ): PostsFetchResponse = fetch( "31fe64d9463cbbe58319dced405c6206", "{\"id\":\"" + profileId + "\"," + "\"first\":" + postsPerPage + "," + "\"after\":\"" + (maxId ?: "") + "\"}", Constants.EXTRAS_USER, "edge_user_to_photos_of_you", null, ) @Throws(JSONException::class) private fun parsePostResponse( response: String, arg1: String, arg2: String, backup: User?, ): PostsFetchResponse { if (response.isBlank()) { Log.e(TAG, "parseResponse: feed response body is empty") return PostsFetchResponse(emptyList(), false, null) } return parseResponseBody(response, arg1, arg2, backup) } @Throws(JSONException::class) private fun parseResponseBody( body: String, arg1: String, arg2: String, backup: User?, ): PostsFetchResponse { val items: MutableList = ArrayList() val timelineFeed = JSONObject(body) .getJSONObject("data") .getJSONObject(arg1) .getJSONObject(arg2) val endCursor: String? val hasNextPage: Boolean val pageInfo = timelineFeed.getJSONObject("page_info") if (pageInfo.has("has_next_page")) { hasNextPage = pageInfo.getBoolean("has_next_page") endCursor = if (hasNextPage) pageInfo.getString("end_cursor") else null } else { hasNextPage = false endCursor = null } val feedItems = timelineFeed.getJSONArray("edges") for (i in 0 until feedItems.length()) { val itemJson = feedItems.optJSONObject(i) ?: continue val media = ResponseBodyUtils.parseGraphQLItem(itemJson, backup) if (media != null) { items.add(media) } } return PostsFetchResponse(items, hasNextPage, endCursor) } // TODO convert string response to a response class suspend fun fetchCommentLikers( commentId: String, endCursor: String?, ): GraphQLUserListFetchResponse { val queryMap = mapOf( "query_hash" to "5f0b1f6281e72053cbc07909c8d154ae", "variables" to "{\"comment_id\":\"" + commentId + "\"," + "\"first\":30," + "\"after\":\"" + (endCursor ?: "") + "\"}" ) val response = service.fetch(queryMap) val body = JSONObject(response) val status = body.getString("status") val data = body.getJSONObject("data").getJSONObject("comment").getJSONObject("edge_liked_by") val pageInfo = data.getJSONObject("page_info") val newEndCursor = if (pageInfo.getBoolean("has_next_page")) pageInfo.getString("end_cursor") else null val users = data.getJSONArray("edges") val usersLen = users.length() val userModels: MutableList = ArrayList() for (j in 0 until usersLen) { val userObject = users.getJSONObject(j).getJSONObject("node") userModels.add( User( userObject.getLong("id"), userObject.getString("username"), userObject.optString("full_name"), userObject.optBoolean("is_private"), userObject.getString("profile_pic_url"), userObject.optBoolean("is_verified") ) ) } return GraphQLUserListFetchResponse(newEndCursor, status, userModels) } suspend fun fetchComments( shortCodeOrCommentId: String?, root: Boolean, cursor: String?, ): String { val variables = mapOf( (if (root) "shortcode" else "comment_id") to shortCodeOrCommentId, "first" to 50, "after" to (cursor ?: "") ) val queryMap = mapOf( "query_hash" to if (root) "bc3296d1ce80a24b1b6e40b1e72903f5" else "51fdd02b67508306ad4484ff574a0b62", "variables" to JSONObject(variables).toString() ) return service.fetch(queryMap) } // TODO convert string response to a response class open suspend fun fetchUser( username: String, ): User? { val response = service.getUser(username) try { val body = JSONObject( response .split("").get(0) .trim().replace(Regex("\\};$"), "}") ) val userJson = body .getJSONObject("entry_data") .getJSONArray("ProfilePage") .getJSONObject(0) .getJSONObject("graphql") .getJSONObject(Constants.EXTRAS_USER) val isPrivate = userJson.getBoolean("is_private") val id = userJson.optLong(Constants.EXTRAS_ID, 0) val timelineMedia = userJson.getJSONObject("edge_owner_to_timeline_media") // if (timelineMedia.has("edges")) { // final JSONArray edges = timelineMedia.getJSONArray("edges"); // } var url: String? = userJson.optString("external_url") if (url.isNullOrBlank()) url = null return User( id, username, userJson.getString("full_name"), isPrivate, userJson.getString("profile_pic_url_hd"), userJson.getBoolean("is_verified"), friendshipStatus = FriendshipStatus( userJson.optBoolean("followed_by_viewer"), userJson.optBoolean("follows_viewer"), userJson.optBoolean("blocked_by_viewer"), false, isPrivate, userJson.optBoolean("has_requested_viewer"), userJson.optBoolean("requested_by_viewer"), false, userJson.optBoolean("restricted_by_viewer"), false ), mediaCount = timelineMedia.getLong("count"), followerCount = userJson.getJSONObject("edge_followed_by").getLong("count"), followingCount = userJson.getJSONObject("edge_follow").getLong("count"), biography = userJson.getString("biography"), externalUrl = url, ) } catch (e: Exception) { Log.e(TAG, "fetchUser failed", e) return null } } // TODO convert string response to a response class suspend fun fetchPost( shortcode: String, ): Media { val response = service.getPost(shortcode) val body = JSONObject(response) val media = body.getJSONObject("graphql").getJSONObject("shortcode_media") return ResponseBodyUtils.parseGraphQLItem(media, null) } // TODO convert string response to a response class suspend fun fetchTag( tag: String, ): Hashtag { val response = service.getTag(tag) val body = JSONObject(response .split("").get(0) .trim().replace(Regex("\\};$"), "}")) .getJSONObject("entry_data") .getJSONArray("TagPage") .getJSONObject(0) .getJSONObject("graphql") .getJSONObject(Constants.EXTRAS_HASHTAG) val timelineMedia = body.getJSONObject("edge_hashtag_to_media") return Hashtag( body.getString(Constants.EXTRAS_ID), body.getString("name"), timelineMedia.getLong("count"), if (body.optBoolean("is_following")) FollowingType.FOLLOWING else FollowingType.NOT_FOLLOWING, null ) } // TODO convert string response to a response class suspend fun fetchLocation( locationId: Long, ): Location { val response = service.getLocation(locationId) val body = JSONObject(response .split("").get(1) .trim().replace(Regex("};$"), "}")) .getJSONObject("entry_data") .getJSONArray("LocationsPage") .getJSONObject(0) .getJSONObject("graphql") .getJSONObject(Constants.EXTRAS_LOCATION) // val timelineMedia = body.getJSONObject("edge_location_to_media") val address = JSONObject(body.getString("address_json")) return Location( body.getLong(Constants.EXTRAS_ID), body.getString("slug"), body.getString("name"), address.optString("street_address"), address.optString("city_name"), body.optDouble("lng", 0.0), body.optDouble("lat", 0.0) ) } companion object { @Volatile private var INSTANCE: GraphQLRepository? = null fun getInstance(): GraphQLRepository { return INSTANCE ?: synchronized(this) { val service: GraphQLService = RetrofitFactory.retrofitWeb.create(GraphQLService::class.java) GraphQLRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/LocationService.java ================================================ package awais.instagrabber.webservices; import androidx.annotation.NonNull; import com.google.common.collect.ImmutableMap; import awais.instagrabber.repositories.LocationRepository; import awais.instagrabber.repositories.responses.Location; import awais.instagrabber.repositories.responses.LocationFeedResponse; import awais.instagrabber.repositories.responses.Place; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.utils.TextUtils; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class LocationService { private static final String TAG = "LocationService"; private final LocationRepository repository; private static LocationService instance; private LocationService() { repository = RetrofitFactory.INSTANCE .getRetrofit() .create(LocationRepository.class); } public static LocationService getInstance() { if (instance == null) { instance = new LocationService(); } return instance; } public void fetchPosts(final long locationId, final String maxId, final ServiceCallback callback) { final ImmutableMap.Builder builder = ImmutableMap.builder(); if (!TextUtils.isEmpty(maxId)) { builder.put("max_id", maxId); } final Call request = repository.fetchPosts(locationId, builder.build()); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (callback == null) return; final LocationFeedResponse body = response.body(); if (body == null) { callback.onSuccess(null); return; } final PostsFetchResponse postsFetchResponse = new PostsFetchResponse( body.getItems(), body.getMoreAvailable(), body.getNextMaxId() ); callback.onSuccess(postsFetchResponse); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { if (callback != null) { callback.onFailure(t); } } }); } public void fetch(@NonNull final long locationId, final ServiceCallback callback) { final Call request = repository.fetch(locationId); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (callback == null) { return; } callback.onSuccess(response.body() == null ? null : response.body().getLocation()); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { if (callback != null) { callback.onFailure(t); } } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.repositories.MediaService import awais.instagrabber.repositories.requests.Clip import awais.instagrabber.repositories.requests.UploadFinishOptions import awais.instagrabber.repositories.responses.Media import awais.instagrabber.repositories.responses.User import awais.instagrabber.utils.DateUtils import awais.instagrabber.utils.Utils import awais.instagrabber.utils.retryContextString import awais.instagrabber.webservices.RetrofitFactory.retrofit import org.json.JSONObject class MediaRepository(private val service: MediaService) { suspend fun fetch( mediaId: Long, ): Media? { val response = service.fetch(mediaId) return if (response.items.isNullOrEmpty()) { null } else response.items[0] } suspend fun like( csrfToken: String, userId: Long, deviceUuid: String, mediaId: String, ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "like", null) suspend fun unlike( csrfToken: String, userId: Long, deviceUuid: String, mediaId: String, ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "unlike", null) suspend fun save( csrfToken: String, userId: Long, deviceUuid: String, mediaId: String, collection: String?, ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "save", collection) suspend fun unsave( csrfToken: String, userId: Long, deviceUuid: String, mediaId: String, ): Boolean = action(csrfToken, userId, deviceUuid, mediaId, "unsave", null) private suspend fun action( csrfToken: String, userId: Long, deviceUuid: String, mediaId: String, action: String, collection: String?, ): Boolean { val form: MutableMap = mutableMapOf( "media_id" to mediaId, "_csrftoken" to csrfToken, "_uid" to userId, "_uuid" to deviceUuid, ) // form.put("radio_type", "wifi-none"); if (action == "save" && !collection.isNullOrBlank()) { form["added_collection_ids"] = "[$collection]" } // there also exists "removed_collection_ids" which can be used with "save" and "unsave" val signedForm = Utils.sign(form) val response = service.action(action, mediaId, signedForm) val jsonObject = JSONObject(response) val status = jsonObject.optString("status") return status == "ok" } suspend fun editCaption( csrfToken: String, userId: Long, deviceUuid: String, postId: String, newCaption: String, ): Boolean { val form = mapOf( "_csrftoken" to csrfToken, "_uid" to userId, "_uuid" to deviceUuid, "igtv_feed_preview" to "false", "media_id" to postId, "caption_text" to newCaption, ) val signedForm = Utils.sign(form) val response = service.editCaption(postId, signedForm) val jsonObject = JSONObject(response) val status = jsonObject.optString("status") return status == "ok" } suspend fun fetchLikes( mediaId: String, isComment: Boolean, ): List { val response = service.fetchLikes(mediaId, if (isComment) "comment_likers" else "likers") return response.users } suspend fun translate( id: String, type: String, // 1 caption 2 comment 3 bio ): String? { val form = mapOf( "id" to id, "type" to type, ) val response = service.translate(form) val jsonObject = JSONObject(response) if (!jsonObject.has("translation") || jsonObject.isNull("translation")) { return null } return jsonObject.getString("translation") } suspend fun uploadFinish( csrfToken: String, userId: Long, deviceUuid: String, options: UploadFinishOptions, ): String { if (options.videoOptions != null) { val videoOptions = options.videoOptions if (videoOptions.clips.isEmpty()) { videoOptions.clips = listOf(Clip(videoOptions.length, options.sourceType)) } } val timezoneOffset = DateUtils.timezoneOffset.toString() val form = mutableMapOf( "timezone_offset" to timezoneOffset, "_csrftoken" to csrfToken, "source_type" to options.sourceType, "_uid" to userId.toString(), "_uuid" to deviceUuid, "upload_id" to options.uploadId, ) if (options.videoOptions != null) { form.putAll(options.videoOptions.map) } val queryMap = if (options.videoOptions != null) mapOf("video" to "1") else emptyMap() val signedForm = Utils.sign(form) return service.uploadFinish(retryContextString, queryMap, signedForm) } suspend fun delete( csrfToken: String, userId: Long, deviceUuid: String, postId: String, type: MediaItemType, ): String? { if (!DELETABLE_ITEMS_TYPES.contains(type)) return null val form = mapOf( "_csrftoken" to csrfToken, "_uid" to userId, "_uuid" to deviceUuid, "igtv_feed_preview" to "false", "media_id" to postId, ) val signedForm = Utils.sign(form) val mediaType: String = when (type) { MediaItemType.MEDIA_TYPE_IMAGE -> "PHOTO" MediaItemType.MEDIA_TYPE_VIDEO -> "VIDEO" MediaItemType.MEDIA_TYPE_SLIDER -> "CAROUSEL" else -> return null } return service.delete(postId, mediaType, signedForm) } companion object { @Volatile private var INSTANCE: MediaRepository? = null private val DELETABLE_ITEMS_TYPES = listOf( MediaItemType.MEDIA_TYPE_IMAGE, MediaItemType.MEDIA_TYPE_VIDEO, MediaItemType.MEDIA_TYPE_SLIDER ) fun getInstance(): MediaRepository { return INSTANCE ?: synchronized(this) { val service: MediaService = retrofit.create(MediaService::class.java) MediaRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/NewsService.java ================================================ package awais.instagrabber.webservices; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; import awais.instagrabber.repositories.NewsRepository; import awais.instagrabber.repositories.responses.AymlResponse; import awais.instagrabber.repositories.responses.AymlUser; import awais.instagrabber.repositories.responses.NewsInboxResponse; import awais.instagrabber.repositories.responses.User; import awais.instagrabber.repositories.responses.UserSearchResponse; import awais.instagrabber.repositories.responses.notification.Notification; import awais.instagrabber.repositories.responses.notification.NotificationArgs; import awais.instagrabber.repositories.responses.notification.NotificationCounts; import awais.instagrabber.utils.Constants; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class NewsService { private static final String TAG = "NewsService"; private final NewsRepository repository; private static NewsService instance; private NewsService() { repository = RetrofitFactory.INSTANCE .getRetrofit() .create(NewsRepository.class); } public static NewsService getInstance() { if (instance == null) { instance = new NewsService(); } return instance; } public void fetchAppInbox(final boolean markAsSeen, final ServiceCallback> callback) { final Call request = repository.appInbox(markAsSeen, Constants.X_IG_APP_ID); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final NewsInboxResponse body = response.body(); if (body == null) { callback.onSuccess(null); return; } final List result = new ArrayList(); final List newStories = body.getNewStories(); if (newStories != null) result.addAll(newStories); final List oldStories = body.getOldStories(); if (oldStories != null) result.addAll(oldStories); callback.onSuccess(result); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); // Log.e(TAG, "onFailure: ", t); } }); } public void fetchActivityCounts(final ServiceCallback callback) { final Call request = repository.appInbox(false, null); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final NewsInboxResponse body = response.body(); if (body == null) { callback.onSuccess(null); return; } callback.onSuccess(body.getCounts()); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); // Log.e(TAG, "onFailure: ", t); } }); } public void fetchSuggestions(final String csrfToken, final String deviceUuid, final ServiceCallback> callback) { final Map form = new HashMap<>(); form.put("_uuid", UUID.randomUUID().toString()); form.put("_csrftoken", csrfToken); form.put("phone_id", UUID.randomUUID().toString()); form.put("device_id", UUID.randomUUID().toString()); form.put("module", "discover_people"); form.put("paginate", "false"); final Call request = repository.getAyml(form); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final AymlResponse body = response.body(); if (body == null) { callback.onSuccess(null); return; } final List aymlUsers = new ArrayList(); final List newSuggestions = body.getNewSuggestedUsers().getSuggestions(); if (newSuggestions != null) { aymlUsers.addAll(newSuggestions); } final List oldSuggestions = body.getSuggestedUsers().getSuggestions(); if (oldSuggestions != null) { aymlUsers.addAll(oldSuggestions); } final List newsItems = aymlUsers .stream() .map(i -> { final User u = i.getUser(); return new Notification( new NotificationArgs( i.getSocialContext(), i.getAlgorithm(), u.getPk(), u.getProfilePicUrl(), null, 0L, u.getUsername(), u.getFullName(), u.isVerified() ), 9999, String.valueOf(u.getPk()) // placeholder ); }) .collect(Collectors.toList()); callback.onSuccess(newsItems); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); // Log.e(TAG, "onFailure: ", t); } }); } public void fetchChaining(final long targetId, final ServiceCallback> callback) { final Call request = repository.getChaining(targetId); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final UserSearchResponse body = response.body(); if (body == null) { callback.onSuccess(null); return; } final List newsItems = body .getUsers() .stream() .map(u -> { return new Notification( new NotificationArgs( u.getSocialContext(), null, u.getPk(), u.getProfilePicUrl(), null, 0L, u.getUsername(), u.getFullName(), u.isVerified() ), 9999, u.getProfilePicId() // placeholder ); }) .collect(Collectors.toList()); callback.onSuccess(newsItems); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { callback.onFailure(t); // Log.e(TAG, "onFailure: ", t); } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/ProfileRepository.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.repositories.ProfileService import awais.instagrabber.repositories.responses.Media import awais.instagrabber.repositories.responses.PostsFetchResponse import awais.instagrabber.repositories.responses.WrappedMedia import awais.instagrabber.repositories.responses.saved.CollectionsListResponse import awais.instagrabber.utils.Utils import com.google.common.collect.ImmutableMap import java.util.* import java.util.stream.Collectors class ProfileRepository private constructor(private val repository: ProfileService) { suspend fun fetchPosts( userId: Long, maxId: String? ): PostsFetchResponse? { val builder = ImmutableMap.builder() if (!maxId.isNullOrEmpty()) { builder.put("max_id", maxId) } val body = repository.fetch(userId, builder.build()) ?: return null return PostsFetchResponse( body.items, body.moreAvailable, body.nextMaxId ) } suspend fun fetchSaved(maxId: String?, collectionId: String): PostsFetchResponse? { val builder = ImmutableMap.builder() if (!maxId.isNullOrEmpty()) { builder.put("max_id", maxId) } val userFeedResponse = if (collectionId.isNullOrEmpty() || collectionId == "ALL_MEDIA_AUTO_COLLECTION") (repository.fetchSaved(builder.build()) ?: return null) else repository.fetchSavedCollection(collectionId, builder.build()) ?: return null val items = userFeedResponse.items val posts: List = if (items == null) { emptyList() } else { items.stream() .map(WrappedMedia::media) .filter { obj: Media? -> Objects.nonNull(obj) } .collect(Collectors.toList()) } return PostsFetchResponse( posts, userFeedResponse.isMoreAvailable, userFeedResponse.nextMaxId ) } suspend fun fetchCollections(maxId: String?): CollectionsListResponse? { val builder = ImmutableMap.builder() if (!maxId.isNullOrEmpty()) { builder.put("max_id", maxId) } builder.put( "collection_types", "[\"ALL_MEDIA_AUTO_COLLECTION\",\"MEDIA\",\"PRODUCT_AUTO_COLLECTION\"]" ) return repository.fetchCollections(builder.build()) } suspend fun createCollection( name: String, deviceUuid: String, userId: Long, csrfToken: String ): String? { val form: MutableMap = HashMap(6) form["_csrftoken"] = csrfToken form["_uuid"] = deviceUuid form["_uid"] = userId form["collection_visibility"] = "0" // 1 for public, planned for future but currently inexistant form["module_name"] = "collection_create" form["name"] = name val signedForm = Utils.sign(form) return repository.createCollection(signedForm) } suspend fun fetchLiked(maxId: String?): PostsFetchResponse? { val builder = ImmutableMap.builder() if (!maxId.isNullOrEmpty()) { builder.put("max_id", maxId) } val userFeedResponse = repository.fetchLiked(builder.build()) ?: return null return PostsFetchResponse( userFeedResponse.items, userFeedResponse.moreAvailable, userFeedResponse.nextMaxId ) } suspend fun fetchTagged(profileId: Long, maxId: String?): PostsFetchResponse? { val builder = ImmutableMap.builder() if (!maxId.isNullOrEmpty()) { builder.put("max_id", maxId) } val userFeedResponse = repository.fetchTagged(profileId, builder.build()) ?: return null return PostsFetchResponse( userFeedResponse.items, userFeedResponse.moreAvailable, userFeedResponse.nextMaxId ) } companion object { @Volatile private var INSTANCE: ProfileRepository? = null fun getInstance(): ProfileRepository { return INSTANCE ?: synchronized(this) { val service: ProfileService = RetrofitFactory.retrofit.create(ProfileService::class.java) ProfileRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.BuildConfig import awais.instagrabber.repositories.responses.Caption import awais.instagrabber.repositories.serializers.CaptionDeserializer import awais.instagrabber.utils.Utils import awais.instagrabber.webservices.interceptors.AddCookiesInterceptor import awais.instagrabber.webservices.interceptors.IgErrorsInterceptor //import awais.instagrabber.webservices.interceptors.LoggingInterceptor import com.google.gson.FieldNamingPolicy import com.google.gson.GsonBuilder import okhttp3.Cache import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory import java.io.File object RetrofitFactory { private const val cacheSize: Long = 10 * 1024 * 1024 // 10 MB private val cache = Cache(File(Utils.cacheDir), cacheSize) private val igErrorsInterceptor: IgErrorsInterceptor by lazy { IgErrorsInterceptor() } private val retrofitBuilder: Retrofit.Builder by lazy { val clientBuilder = OkHttpClient.Builder().apply { followRedirects(false) followSslRedirects(false) cache(cache) addInterceptor(AddCookiesInterceptor()) addInterceptor(igErrorsInterceptor) if (BuildConfig.DEBUG) { // addInterceptor(LoggingInterceptor()) } } val gson = GsonBuilder().apply { setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) registerTypeAdapter(Caption::class.java, CaptionDeserializer()) setLenient() }.create() Retrofit.Builder().apply { addConverterFactory(ScalarsConverterFactory.create()) addConverterFactory(GsonConverterFactory.create(gson)) client(clientBuilder.build()) } } val retrofit: Retrofit by lazy { retrofitBuilder .baseUrl("https://i.instagram.com") .build() } val retrofitWeb: Retrofit by lazy { retrofitBuilder .baseUrl("https://www.instagram.com") .build() } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/SearchRepository.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.repositories.SearchService import awais.instagrabber.repositories.responses.search.SearchResponse import awais.instagrabber.webservices.RetrofitFactory.retrofitWeb import com.google.common.collect.ImmutableMap import retrofit2.Call class SearchRepository(private val service: SearchService) { suspend fun search( isLoggedIn: Boolean, query: String, context: String ): SearchResponse { val builder = ImmutableMap.builder() builder.put("query", query) // context is one of: "blended", "user", "place", "hashtag" // note that "place" and "hashtag" can contain ONE user result, who knows why builder.put("context", context) builder.put("count", "50") return service.search( if (isLoggedIn) "https://i.instagram.com/api/v1/fbsearch/topsearch_flat/" else "https://www.instagram.com/web/search/topsearch/", builder.build() ) } companion object { @Volatile private var INSTANCE: SearchRepository? = null fun getInstance(): SearchRepository { return INSTANCE ?: synchronized(this) { val service: SearchService = RetrofitFactory.retrofit.create(SearchService::class.java) SearchRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/ServiceCallback.java ================================================ package awais.instagrabber.webservices; public interface ServiceCallback { void onSuccess(T result); void onFailure(Throwable t); } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/StoriesRepository.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.fragments.settings.PreferenceKeys import awais.instagrabber.repositories.StoriesService import awais.instagrabber.repositories.requests.StoryViewerOptions import awais.instagrabber.repositories.responses.stories.ArchiveResponse import awais.instagrabber.repositories.responses.stories.Story import awais.instagrabber.repositories.responses.stories.StoryMedia import awais.instagrabber.repositories.responses.stories.StoryStickerResponse import awais.instagrabber.utils.Utils import awais.instagrabber.webservices.RetrofitFactory.retrofit import java.util.UUID open class StoriesRepository(private val service: StoriesService) { suspend fun fetch(mediaId: Long): StoryMedia? { val response = service.fetch(mediaId) return response.items?.get(0) } suspend fun getFeedStories(): List { val response = service.getFeedStories() val result: MutableList = mutableListOf() if (response?.broadcasts != null) { val length = response.broadcasts.size for (i in 0 until length) { val broadcast = response.broadcasts.get(i) result.add( Story( broadcast.id, broadcast.publishedTime, 1, 0L, broadcast.broadcastOwner, broadcast.muted, false, // unclear null, null, null, null, broadcast ) ) } } if (response?.tray != null) result.addAll(response.tray) return sort(result.toList()) } open suspend fun fetchHighlights(profileId: Long): List { val response = service.fetchHighlights(profileId) val highlightModels = response?.tray ?: listOf() return highlightModels } suspend fun fetchArchive(maxId: String): ArchiveResponse? { val form = mutableMapOf( "include_suggested_highlights" to "false", "is_in_archive_home" to "true", "include_cover" to "1", ) if (!maxId.isNullOrEmpty()) { form["max_id"] = maxId // NOT TESTED } return service.fetchArchive(form) } open suspend fun getStories(options: StoryViewerOptions): Story? { return when (options.type) { StoryViewerOptions.Type.HIGHLIGHT, StoryViewerOptions.Type.STORY_ARCHIVE -> { val response = service.getReelsMedia(options.name) response.reels?.get(options.name) } StoryViewerOptions.Type.USER -> { val response = service.getUserStories(options.id) response.reel } // should not reach beyond this point StoryViewerOptions.Type.LOCATION -> { val response = service.getStories("locations", options.id.toString()) response.story } StoryViewerOptions.Type.HASHTAG -> { val response = service.getStories("tags", options.name) response.story } else -> null } } private suspend fun respondToSticker( csrfToken: String, userId: Long, deviceUuid: String, storyId: Long, stickerId: Long, action: String, arg1: String, arg2: String, ): StoryStickerResponse { val form = mapOf( "_csrftoken" to csrfToken, "_uid" to userId, "_uuid" to deviceUuid, "mutation_token" to UUID.randomUUID().toString(), "client_context" to UUID.randomUUID().toString(), "radio_type" to "wifi-none", arg1 to arg2, ) val signedForm = Utils.sign(form) return service.respondToSticker(storyId, stickerId, action, signedForm) } suspend fun respondToQuestion( csrfToken: String, userId: Long, deviceUuid: String, storyId: Long, stickerId: Long, answer: String, ): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_question_response", "response", answer) suspend fun respondToQuiz( csrfToken: String, userId: Long, deviceUuid: String, storyId: Long, stickerId: Long, answer: Int, ): StoryStickerResponse { return respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_quiz_answer", "answer", answer.toString()) } suspend fun respondToPoll( csrfToken: String, userId: Long, deviceUuid: String, storyId: Long, stickerId: Long, answer: Int, ): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_poll_vote", "vote", answer.toString()) suspend fun respondToSlider( csrfToken: String, userId: Long, deviceUuid: String, storyId: Long, stickerId: Long, answer: Double, ): StoryStickerResponse = respondToSticker(csrfToken, userId, deviceUuid, storyId, stickerId, "story_slider_vote", "vote", answer.toString()) suspend fun seen( csrfToken: String, userId: Long, deviceUuid: String, storyMediaId: String, takenAt: Long, seenAt: Long, ): String { val reelsForm = mapOf(storyMediaId to listOf(takenAt.toString() + "_" + seenAt)) val form = mutableMapOf( "_csrftoken" to csrfToken, "_uid" to userId, "_uuid" to deviceUuid, "container_module" to "feed_timeline", "reels" to reelsForm, ) val signedForm = Utils.sign(form) val queryMap = mapOf( "reel" to "1", "live_vod" to "0", ) return service.seen(queryMap, signedForm) } private fun sort(list: List): List { val listCopy = ArrayList(list) listCopy.sortWith { o1, o2 -> if (o1.latestReelMedia == null || o2.latestReelMedia == null) return@sortWith 0 else when (Utils.settingsHelper.getString(PreferenceKeys.STORY_SORT)) { "1" -> return@sortWith o2.latestReelMedia.compareTo(o1.latestReelMedia) "2" -> return@sortWith o1.latestReelMedia.compareTo(o2.latestReelMedia) else -> return@sortWith 0 } } return listCopy } companion object { @Volatile private var INSTANCE: StoriesRepository? = null fun getInstance(): StoriesRepository { return INSTANCE ?: synchronized(this) { val service: StoriesService = retrofit.create(StoriesService::class.java) StoriesRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/TagsService.java ================================================ package awais.instagrabber.webservices; import android.util.Log; import androidx.annotation.NonNull; import com.google.common.collect.ImmutableMap; import org.json.JSONException; import org.json.JSONObject; import java.util.HashMap; import java.util.Map; import awais.instagrabber.repositories.TagsRepository; import awais.instagrabber.repositories.responses.Hashtag; import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.repositories.responses.TagFeedResponse; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; public class TagsService { private static final String TAG = "TagsService"; private static TagsService instance; private final TagsRepository repository; private TagsService() { repository = RetrofitFactory.INSTANCE .getRetrofit() .create(TagsRepository.class); } public static TagsService getInstance() { if (instance == null) { instance = new TagsService(); } return instance; } public void fetch(@NonNull final String tag, final ServiceCallback callback) { final Call request = repository.fetch(tag); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (callback == null) { return; } callback.onSuccess(response.body()); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { if (callback != null) { callback.onFailure(t); } } }); } public void changeFollow(@NonNull final String action, @NonNull final String tag, @NonNull final String csrfToken, @NonNull final long userId, @NonNull final String deviceUuid, final ServiceCallback callback) { final Map form = new HashMap<>(3); form.put("_csrftoken", csrfToken); form.put("_uid", userId); form.put("_uuid", deviceUuid); final Map signedForm = Utils.sign(form); final Call request = repository.changeFollow(signedForm, action, tag); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { final String body = response.body(); if (body == null) { callback.onFailure(new RuntimeException("body is null")); return; } try { final JSONObject jsonObject = new JSONObject(body); final String status = jsonObject.optString("status"); callback.onSuccess(status.equals("ok")); } catch (JSONException e) { Log.e(TAG, "onResponse: ", e); } } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { // Log.e(TAG, "onFailure: ", t); callback.onFailure(t); } }); } public void fetchPosts(@NonNull final String tag, final String maxId, final ServiceCallback callback) { final ImmutableMap.Builder builder = ImmutableMap.builder(); if (!TextUtils.isEmpty(maxId)) { builder.put("max_id", maxId); } final Call request = repository.fetchPosts(tag, builder.build()); request.enqueue(new Callback() { @Override public void onResponse(@NonNull final Call call, @NonNull final Response response) { if (callback == null) { return; } final TagFeedResponse body = response.body(); if (body == null) { callback.onSuccess(null); return; } callback.onSuccess(new PostsFetchResponse( body.getItems(), body.getMoreAvailable(), body.getNextMaxId() )); } @Override public void onFailure(@NonNull final Call call, @NonNull final Throwable t) { if (callback != null) { callback.onFailure(t); } } }); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/UserRepository.kt ================================================ package awais.instagrabber.webservices import awais.instagrabber.repositories.UserService import awais.instagrabber.repositories.responses.FriendshipStatus import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.UserSearchResponse import awais.instagrabber.webservices.RetrofitFactory.retrofit import java.util.* open class UserRepository(private val service: UserService) { suspend fun getUserInfo(uid: Long): User { val response = service.getUserInfo(uid) return response.user } open suspend fun getUsernameInfo(username: String): User { val response = service.getUsernameInfo(username) return response.user } open suspend fun getUserFriendship(uid: Long): FriendshipStatus = service.getUserFriendship(uid) suspend fun search(query: String): UserSearchResponse { val timezoneOffset = TimeZone.getDefault().rawOffset.toFloat() / 1000 return service.search(timezoneOffset, query) } companion object { @Volatile private var INSTANCE: UserRepository? = null fun getInstance(): UserRepository { return INSTANCE ?: synchronized(this) { val service: UserService = retrofit.create(UserService::class.java) UserRepository(service).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/interceptors/AddCookiesInterceptor.java ================================================ package awais.instagrabber.webservices.interceptors; import androidx.annotation.NonNull; import java.io.IOException; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.LocaleUtils; import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.Utils; import okhttp3.Interceptor; import okhttp3.Request; import okhttp3.Response; public class AddCookiesInterceptor implements Interceptor { @NonNull @Override public Response intercept(@NonNull final Chain chain) throws IOException { final Request request = chain.request(); final Request.Builder builder = request.newBuilder(); final String cookie = Utils.settingsHelper.getString(Constants.COOKIE); final boolean hasCookie = !TextUtils.isEmpty(cookie); if (hasCookie) { builder.addHeader("Cookie", cookie); } final String userAgentHeader = "User-Agent"; if (request.header(userAgentHeader) == null) { builder.addHeader(userAgentHeader, Utils.settingsHelper.getString(hasCookie ? Constants.APP_UA : Constants.BROWSER_UA)); } final String languageHeader = "Accept-Language"; if (request.header(languageHeader) == null) { builder.addHeader(languageHeader, LocaleUtils.getCurrentLocale().getLanguage() + ",en-US;q=0.8"); } final Request updatedRequest = builder.build(); return chain.proceed(updatedRequest); } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/interceptors/IgErrorsInterceptor.java ================================================ package awais.instagrabber.webservices.interceptors; import android.text.Html; import android.text.Spanned; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.fragment.app.FragmentManager; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import awais.instagrabber.R; import awais.instagrabber.activities.MainActivity; import awais.instagrabber.dialogs.ConfirmDialogFragment; import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.TextUtils; import okhttp3.Interceptor; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; public class IgErrorsInterceptor implements Interceptor { private static final String TAG = IgErrorsInterceptor.class.getSimpleName(); public IgErrorsInterceptor() { } @NonNull @Override public Response intercept(@NonNull final Chain chain) throws IOException { final Request request = chain.request(); final Response response = chain.proceed(request); if (response.isSuccessful()) { return response; } checkError(response); return response; } private void checkError(@NonNull final Response response) { final int errorCode = response.code(); switch (errorCode) { case 429: // "429 Too Many Requests" // ('Throttled by Instagram because of too many API requests.'); showErrorDialog(R.string.throttle_error); return; case 431: // "431 Request Header Fields Too Large" // show dialog? Log.e(TAG, "Network error: " + getMessage(errorCode, "The request start-line and/or headers are too large to process.")); return; case 404: showErrorDialog(R.string.not_found); return; case 302: // redirect final String location = response.header("location"); if (location != null && location.equals("https://www.instagram.com/accounts/login/")) { // rate limited final String message = MainActivity.getInstance().getString(R.string.rate_limit); final Spanned spanned = Html.fromHtml(message); showErrorDialog(spanned); } return; } final ResponseBody body = response.body(); if (body == null) return; try { final String bodyString = body.string(); Log.d(TAG, "checkError: " + bodyString); JSONObject jsonObject = null; try { jsonObject = new JSONObject(bodyString); } catch (JSONException e) { Log.e(TAG, "checkError: ", e); } String message; if (jsonObject != null) { message = jsonObject.optString("message"); } else { message = bodyString; } if (!TextUtils.isEmpty(message)) { message = message.toLowerCase(); switch (message) { case "user_has_logged_out": showErrorDialog(R.string.account_logged_out); return; case "login_required": showErrorDialog(R.string.login_required); return; case "execution failure": showSnackbar(message); return; case "not authorized to view user": // Do we handle this in profile view fragment? case "challenge_required": // Since we make users login using browser, we should not be getting this error in api requests default: showSnackbar(message); Log.e(TAG, "checkError: " + bodyString); return; } } final String errorType = jsonObject.optString("error_type"); if (TextUtils.isEmpty(errorType)) return; if (errorType.equals("sentry_block")) { showErrorDialog("\"sentry_block\". Please contact developers."); return; } if (errorType.equals("inactive user")) { showErrorDialog(R.string.inactive_user); } } catch (Exception e) { Log.e(TAG, "checkError: ", e); } } private void showSnackbar(final String message) { final MainActivity mainActivity = MainActivity.getInstance(); if (mainActivity == null) return; // final View view = mainActivity.getRootView(); // if (view == null) return; try { AppExecutors.INSTANCE .getMainThread() .execute(() -> Toast.makeText(mainActivity.getApplicationContext(), message, Toast.LENGTH_LONG).show()); } catch (Exception e) { Log.e(TAG, "showSnackbar: ", e); } } @NonNull private String getMessage(final int errorCode, final String message) { return String.format("code: %s, internalMessage: %s", errorCode, message); } private void showErrorDialog(@NonNull final CharSequence message) { final MainActivity mainActivity = MainActivity.getInstance(); if (mainActivity == null) return; final FragmentManager fragmentManager = mainActivity.getSupportFragmentManager(); if (fragmentManager.isStateSaved()) return; final ConfirmDialogFragment dialogFragment = ConfirmDialogFragment.newInstance( Constants.GLOBAL_NETWORK_ERROR_DIALOG_REQUEST_CODE, R.string.error, message, R.string.ok, 0, 0 ); dialogFragment.show(fragmentManager, "network_error_dialog"); } private void showErrorDialog(@StringRes final int messageResId) { showErrorDialog(MainActivity.getInstance().getString(messageResId)); } public void destroy() { // mainActivity = null; } } ================================================ FILE: app/src/main/java/awais/instagrabber/webservices/interceptors/LoggingInterceptor.java ================================================ package awais.instagrabber.webservices.interceptors; import android.util.Log; import androidx.annotation.NonNull; import java.io.IOException; import okhttp3.Interceptor; import okhttp3.MediaType; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; public class LoggingInterceptor implements Interceptor { private static final String TAG = "LoggingInterceptor"; @NonNull @Override public Response intercept(Interceptor.Chain chain) throws IOException { final Request request = chain.request(); long t1 = System.nanoTime(); Log.i(TAG, String.format("Sending request %s on %s%n%s", request.url(), chain.connection(), request.headers())); final Response response = chain.proceed(request); long t2 = System.nanoTime(); Log.i(TAG, String.format("Received response for %s in %.1fms%n%s", response.request().url(), (t2 - t1) / 1e6d, response.headers())); final ResponseBody body = response.body(); MediaType contentType = null; String content = ""; if (body != null) { contentType = body.contentType(); try { content = body.string(); } catch (Exception e) { Log.e(TAG, "intercept: ", e); } Log.d(TAG, content); } final ResponseBody wrappedBody = ResponseBody.create(contentType, content); return response.newBuilder() .body(wrappedBody) .build(); } } ================================================ FILE: app/src/main/java/awais/instagrabber/workers/DownloadWorker.kt ================================================ package awais.instagrabber.workers import android.app.Notification import android.app.PendingIntent import android.content.ContentResolver import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.documentfile.provider.DocumentFile import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import awais.instagrabber.BuildConfig import awais.instagrabber.R import awais.instagrabber.services.DeleteImageIntentService import awais.instagrabber.utils.BitmapUtils import awais.instagrabber.utils.Constants.DOWNLOAD_CHANNEL_ID import awais.instagrabber.utils.Constants.NOTIF_GROUP_NAME import awais.instagrabber.utils.DownloadUtils import awais.instagrabber.utils.TextUtils.isEmpty import awais.instagrabber.utils.Utils import awais.instagrabber.utils.extensions.TAG import com.google.gson.Gson import com.google.gson.JsonSyntaxException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.apache.commons.imaging.formats.jpeg.iptc.JpegIptcRewriter import java.io.BufferedInputStream import java.io.File import java.net.URL import java.util.* import java.util.concurrent.ExecutionException import java.util.stream.Collectors import kotlin.collections.Map import kotlin.math.abs class DownloadWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context) override suspend fun doWork(): Result { val downloadRequestFilePath = inputData.getString(KEY_DOWNLOAD_REQUEST_JSON) if (downloadRequestFilePath.isNullOrBlank()) { return Result.failure(Data.Builder() .putString("error", "downloadRequest is empty or null") .build()) } val downloadRequestString: String val requestFile = Uri.parse(downloadRequestFilePath) val context = applicationContext val contentResolver = context.contentResolver ?: return Result.failure(Data.Builder() .putString("error", "contentResolver is null") .build()) try { val scanner = Scanner(contentResolver.openInputStream(requestFile)) downloadRequestString = scanner.useDelimiter("\\A").next() } catch (e: Exception) { Log.e(TAG, "doWork: ", e) return Result.failure(Data.Builder() .putString("error", e.localizedMessage) .build()) } if (downloadRequestString.isBlank()) { return Result.failure(Data.Builder() .putString("error", "downloadRequest is empty") .build()) } val downloadRequest: DownloadRequest = try { Gson().fromJson(downloadRequestString, DownloadRequest::class.java) } catch (e: JsonSyntaxException) { Log.e(TAG, "doWork", e) return Result.failure(Data.Builder() .putString("error", e.localizedMessage) .build()) } ?: return Result.failure(Data.Builder() .putString("error", "downloadRequest is null") .build()) val urlToFilePathMap = downloadRequest.urlToFilePathMap download(urlToFilePathMap) Handler(Looper.getMainLooper()).postDelayed({ showSummary(urlToFilePathMap) }, 500) val deleted = DocumentFile.fromSingleUri(context, requestFile)!!.delete() if (!deleted) { Log.w(TAG, "doWork: requestFile not deleted!") } return Result.success() } private suspend fun download(urlToFilePathMap: Map) { val notificationId = notificationId val entries = urlToFilePathMap.entries var count = 1 val total = urlToFilePathMap.size for ((url, uriString) in entries) { updateDownloadProgress(notificationId, count, total, 0f) withContext(Dispatchers.IO) { val file = DocumentFile.fromSingleUri(applicationContext, Uri.parse(uriString)) download(notificationId, count, total, url, file!!) } count++ } } private val notificationId: Int get() = abs(id.hashCode()) private fun download( notificationId: Int, position: Int, total: Int, url: String, filePath: DocumentFile, ) { val context = applicationContext.let { it } val contentResolver = context.contentResolver?.let { it } ?: return val filePathType = filePath.type?.let { it } ?: return val isJpg = filePathType.startsWith("image") // using temp file approach to remove IPTC so that download progress can be reported val outFile = if (isJpg) DownloadUtils.getTempFile(null, "jpg") else filePath try { val urlConnection = URL(url).openConnection() val fileSize = if (Build.VERSION.SDK_INT >= 24) urlConnection.contentLengthLong else urlConnection.contentLength.toLong() var totalRead = 0f try { BufferedInputStream(urlConnection.getInputStream()).use { bis -> contentResolver.openOutputStream(outFile!!.uri).use { fos -> val buffer = ByteArray(0x2000) var count: Int while (bis.read(buffer, 0, 0x2000).also { count = it } != -1) { totalRead += count fos!!.write(buffer, 0, count) setProgressAsync(Data.Builder().putString(URL, url) .putFloat(PROGRESS, totalRead * 100f / fileSize) .build()) updateDownloadProgress(notificationId, position, total, totalRead * 100f / fileSize) } fos!!.flush() } } } catch (e: Exception) { Log.e(TAG, "Error while writing data from url: " + url + " to file: " + outFile!!.name, e) } if (isJpg) { try { contentResolver.openInputStream(outFile!!.uri).use { fis -> contentResolver.openOutputStream(filePath.uri).use { fos -> val jpegIptcRewriter = JpegIptcRewriter() jpegIptcRewriter.removeIPTC(fis, fos) } } } catch (e: Exception) { Log.e(TAG, "Error while removing iptc: url: " + url + ", tempFile: " + outFile!!.name + ", finalFile: " + filePath.name, e) } val deleted = outFile!!.delete() if (!deleted) { Log.w(TAG, "download: tempFile not deleted!") } } } catch (e: Exception) { Log.e(TAG, "Error while downloading: $url", e) } setProgressAsync(Data.Builder().putString(URL, url) .putFloat(PROGRESS, 100f) .build()) updateDownloadProgress(notificationId, position, total, 100f) } private fun updateDownloadProgress( notificationId: Int, position: Int, total: Int, percent: Float, ) { val notification = createProgressNotification(position, total, percent) try { if (notification == null) { notificationManager.cancel(notificationId) return } setForegroundAsync(ForegroundInfo(notificationId, notification)).get() } catch (e: ExecutionException) { Log.e(TAG, "updateDownloadProgress", e) } catch (e: InterruptedException) { Log.e(TAG, "updateDownloadProgress", e) } } private fun createProgressNotification(position: Int, total: Int, percent: Float): Notification? { val context = applicationContext var ongoing = true val totalPercent: Int if (position == total && percent == 100f) { ongoing = false totalPercent = 100 } else { totalPercent = (100f * (position - 1) / total + 1f / total * percent).toInt() } if (totalPercent == 100) { return null } // Log.d(TAG, "createProgressNotification: position: " + position // + ", total: " + total // + ", percent: " + percent // + ", totalPercent: " + totalPercent); val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setCategory(NotificationCompat.CATEGORY_PROGRESS) .setSmallIcon(R.drawable.ic_download) .setOngoing(ongoing) .setProgress(100, totalPercent, totalPercent < 0) .setAutoCancel(false) .setOnlyAlertOnce(true) .setContentTitle(context.getString(R.string.downloader_downloading_post)) if (total > 1) { builder.setContentText(context.getString(R.string.downloader_downloading_child, position, total)) } return builder.build() } private fun showSummary(urlToFilePathMap: Map) { val context = applicationContext val filePaths = urlToFilePathMap.mapNotNull { DocumentFile.fromSingleUri(context, Uri.parse(it.value)) } val notifications: MutableList = LinkedList() val notificationIds: MutableList = LinkedList() var count = 1 for (filePath: DocumentFile in filePaths) { // final File file = new File(filePath); // context.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, filePath.getUri())); // Utils.scanDocumentFile(context, filePath, (path, uri) -> {}); // final Uri uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file); val contentResolver = context.contentResolver var bitmap: Bitmap? = null val mimeType = filePath.type // Utils.getMimeType(uri, contentResolver); if (!isEmpty(mimeType)) { if (mimeType!!.startsWith("image")) { try { contentResolver.openInputStream(filePath.uri).use { inputStream -> bitmap = BitmapFactory.decodeStream(inputStream) } } catch (e: java.lang.Exception) { if (BuildConfig.DEBUG) Log.e(TAG, "", e) } } else if (mimeType.startsWith("video")) { val retriever = MediaMetadataRetriever() try { try { retriever.setDataSource(context, filePath.uri) } catch (e: java.lang.Exception) { // retriever.setDataSource(file.getAbsolutePath()); Log.e(TAG, "showSummary: ", e) } bitmap = retriever.frameAtTime if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) try { retriever.close() } catch (e: java.lang.Exception) { Log.e(TAG, "showSummary: ", e) } } catch (e: java.lang.Exception) { Log.e(TAG, "", e) } } } val downloadComplete = context.getString(R.string.downloader_complete) val intent = Intent(Intent.ACTION_VIEW, filePath.uri) .addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_FROM_BACKGROUND or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) .putExtra(Intent.EXTRA_STREAM, filePath.uri) val pendingIntent = PendingIntent.getActivity( context, DOWNLOAD_NOTIFICATION_INTENT_REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_ONE_SHOT ) val notificationId: Int = notificationId + count notificationIds.add(notificationId) count++ val builder: NotificationCompat.Builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setSmallIcon(R.drawable.ic_download) .setContentText(null) .setContentTitle(downloadComplete) .setWhen(System.currentTimeMillis()) .setOnlyAlertOnce(true) .setAutoCancel(true) .setGroup(NOTIF_GROUP_NAME + "_" + id) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) .setContentIntent(pendingIntent) .addAction( R.drawable.ic_delete, context.getString(R.string.delete), DeleteImageIntentService.pendingIntent(context, filePath, notificationId) ) if (bitmap != null) { builder.setLargeIcon(bitmap) .setStyle( NotificationCompat.BigPictureStyle() .bigPicture(bitmap) .bigLargeIcon(null) ) .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) } notifications.add(builder) } var summaryNotification: Notification? = null if (urlToFilePathMap.size != 1) { val text = "Downloaded " + urlToFilePathMap.size + " items" summaryNotification = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setContentTitle("Downloaded") .setContentText(text) .setSmallIcon(R.drawable.ic_download) .setStyle(NotificationCompat.InboxStyle().setSummaryText(text)) .setGroup(NOTIF_GROUP_NAME + "_" + id) .setGroupSummary(true) .build() } for (i in notifications.indices) { val builder = notifications[i] // only make sound and vibrate for the last notification if (i != notifications.size - 1) { builder.setSound(null) .setVibrate(null) } notificationManager.notify(notificationIds[i], builder.build()) } if (summaryNotification != null) { notificationManager.notify(notificationId + count, summaryNotification) } } private fun getThumbnail( context: Context, file: File, uri: Uri, contentResolver: ContentResolver, ): Bitmap? { val mimeType = Utils.getMimeType(uri, contentResolver) if (isEmpty(mimeType)) return null var bitmap: Bitmap? = null if (mimeType.startsWith("image")) { try { val bitmapResult = BitmapUtils.getBitmapResult( context.contentResolver, uri, BitmapUtils.THUMBNAIL_SIZE, BitmapUtils.THUMBNAIL_SIZE, -1f, true ) ?: return null bitmap = bitmapResult.bitmap } catch (e: Exception) { Log.e(TAG, "", e) } return bitmap } if (mimeType.startsWith("video")) { try { val retriever = MediaMetadataRetriever() bitmap = try { try { retriever.setDataSource(context, uri) } catch (e: Exception) { retriever.setDataSource(file.absolutePath) } retriever.frameAtTime } finally { try { retriever.release() } catch (e: Exception) { Log.e(TAG, "getThumbnail: ", e) } } } catch (e: Exception) { Log.e(TAG, "", e) } } return bitmap } class DownloadRequest private constructor(val urlToFilePathMap: Map) { class Builder { private var urlToFilePathMap: MutableMap = mutableMapOf() fun setUrlToFilePathMap(urlToFilePathMap: Map): Builder { this.urlToFilePathMap = urlToFilePathMap .filter{ it.value != null } .mapValues { it.value!!.uri.toString() } .toMutableMap() return this } fun addUrl(url: String, filePath: DocumentFile): Builder { urlToFilePathMap[url] = filePath.uri.toString() return this } fun build(): DownloadRequest { return DownloadRequest(urlToFilePathMap) } } companion object { @JvmStatic fun builder(): Builder { return Builder() } } } companion object { const val PROGRESS = "PROGRESS" const val URL = "URL" const val KEY_DOWNLOAD_REQUEST_JSON = "download_request_json" private const val DOWNLOAD_GROUP = "DOWNLOAD_GROUP" private const val DOWNLOAD_NOTIFICATION_INTENT_REQUEST_CODE = 2020 private const val DELETE_IMAGE_REQUEST_CODE = 2030 } } ================================================ FILE: app/src/main/java/awaisomereport/CrashReporter.kt ================================================ package awaisomereport import android.app.Application class CrashReporter private constructor(application: Application) : Thread.UncaughtExceptionHandler { private val crashHandler: CrashHandler? private var startAttempted = false private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null init { crashHandler = CrashHandler(application) } fun start() { if (startAttempted) return startAttempted = true defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler(this) } override fun uncaughtException(t: Thread, exception: Throwable) { if (crashHandler == null) { defaultExceptionHandler?.uncaughtException(t, exception) return } crashHandler.uncaughtException(t, exception, defaultExceptionHandler ?: return) } companion object { @Volatile private var INSTANCE: CrashReporter? = null fun getInstance(application: Application): CrashReporter { return INSTANCE ?: synchronized(this) { CrashReporter(application).also { INSTANCE = it } } } } } ================================================ FILE: app/src/main/java/awaisomereport/CrashReporterHelper.kt ================================================ package awaisomereport import android.app.Application import android.content.Context import android.content.Intent import android.os.Build import android.util.Log import awais.instagrabber.BuildConfig import awais.instagrabber.R import awais.instagrabber.utils.Constants import awais.instagrabber.utils.extensions.TAG import java.io.* import java.time.LocalDateTime object CrashReporterHelper { private val shortBorder = "=".repeat(14) private val longBorder = "=".repeat(21) private const val prefix = "stack-" private const val suffix = ".stacktrace" fun startErrorReporterActivity( application: Application, exception: Throwable ) { try { application.openFileOutput( "$prefix${System.currentTimeMillis()}$suffix", Context.MODE_PRIVATE ).use { it.write(getReportContent(exception).toByteArray()) } } catch (ex: Exception) { if (BuildConfig.DEBUG) Log.e(TAG, "", ex) } application.startActivity(Intent(application, ErrorReporterActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } fun getReportContent(exception: Throwable): String { var reportContent = """ IMPORTANT: If sending by email, your email address and the entire content will be made public at https://github.com/austinhuang0131/barinsta/issues. When possible, please describe the steps leading to this crash. Thank you for your cooperation. Error report collected on: ${LocalDateTime.now()} Information: $shortBorder VERSION : ${BuildConfig.VERSION_NAME} VERSION_CODE : ${BuildConfig.VERSION_CODE} PHONE-MODEL : ${Build.MODEL} ANDROID_VERS : ${Build.VERSION.RELEASE} ANDROID_REL : ${Build.VERSION.SDK_INT} BRAND : ${Build.BRAND} MANUFACTURER : ${Build.MANUFACTURER} BOARD : ${Build.BOARD} DEVICE : ${Build.DEVICE} PRODUCT : ${Build.PRODUCT} HOST : ${Build.HOST} TAGS : ${Build.TAGS} Stack: $shortBorder """.trimIndent() reportContent = "$reportContent${getStackStrace(exception)}\n\n*** End of current Report ***" return reportContent.replace("\n", "\r\n") } private fun getStackStrace(exception: Throwable): String { val writer = StringWriter() return PrintWriter(writer).use { val reportBuilder = StringBuilder("\n") exception.printStackTrace(it) reportBuilder.append(writer.toString()) var cause = exception.cause if (cause != null) reportBuilder.append("\nCause:\n$shortBorder\n") while (cause != null) { cause.printStackTrace(it) reportBuilder.append(writer.toString()) cause = cause.cause } return@use reportBuilder.toString() } } @JvmStatic fun startCrashEmailIntent(context: Context) { try { val filePath = context.filesDir.absolutePath val errorFileList: Array? = try { val dir = File(filePath) if (dir.exists() && !dir.isDirectory) { dir.delete() } dir.mkdirs() dir.list { _: File?, name: String -> name.endsWith(suffix) } } catch (e: Exception) { null } if (errorFileList == null || errorFileList.isEmpty()) { return } val errorStringBuilder: StringBuilder = StringBuilder("\n\n") val maxSendMail = 5 for ((curIndex, curString) in errorFileList.withIndex()) { val file = File("$filePath/$curString") if (curIndex <= maxSendMail) { errorStringBuilder.append("New Trace collected:\n$longBorder\n") BufferedReader(FileReader(file)).use { input -> var line: String? while (input.readLine().also { line = it } != null) errorStringBuilder.append(line).append("\n") } } file.delete() } errorStringBuilder.append("\n\n") val resources = context.resources context.startActivity( Intent.createChooser( Intent(Intent.ACTION_SEND).apply { type = "message/rfc822" flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION putExtra(Intent.EXTRA_EMAIL, arrayOf(Constants.CRASH_REPORT_EMAIL)) putExtra(Intent.EXTRA_SUBJECT, resources.getString(R.string.crash_report_subject)) putExtra(Intent.EXTRA_TEXT, errorStringBuilder.toString().replace("\n", "\r\n")) }, context.resources.getString(R.string.crash_report_title) ) ) } catch (e: Exception) { Log.e(TAG, "", e) } } @JvmStatic fun deleteAllStacktraceFiles(context: Context) { val filePath = context.filesDir.absolutePath val errorFileList: Array? = try { val dir = File(filePath) if (dir.exists() && !dir.isDirectory) { dir.delete() } dir.mkdirs() dir.listFiles { _: File?, name: String -> name.endsWith(suffix) } } catch (e: Exception) { null } errorFileList?.forEach { it.delete() } } } ================================================ FILE: app/src/main/java/awaisomereport/ErrorReporterActivity.kt ================================================ package awaisomereport import android.app.Activity import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.Paint.FontMetricsInt import android.graphics.drawable.Drawable import android.os.Bundle import android.text.Spannable import android.text.SpannableString import android.text.style.ImageSpan import android.view.View import android.view.ViewGroup import androidx.annotation.DrawableRes import awais.instagrabber.R import awais.instagrabber.databinding.ActivityCrashErrorBinding import awaisomereport.CrashReporterHelper.startCrashEmailIntent import java.lang.ref.WeakReference import kotlin.system.exitProcess class ErrorReporterActivity : Activity(), View.OnClickListener { private lateinit var binding: ActivityCrashErrorBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityCrashErrorBinding.inflate(layoutInflater) setContentView(binding.root) setFinishOnTouchOutside(false) window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) val crashTitle = SpannableString(" " + getString(R.string.crash_title)) crashTitle.setSpan( CenteredImageSpan(this, android.R.drawable.stat_notify_error), 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE ) title = crashTitle binding.btnReport.setOnClickListener(this) binding.btnCancel.setOnClickListener(this) } override fun onClick(v: View) { if (v === binding.btnReport) { startCrashEmailIntent(this) } finish() exitProcess(10) } private class CenteredImageSpan(context: Context, @DrawableRes drawableRes: Int) : ImageSpan(context, drawableRes) { private var drawable: WeakReference? = null override fun getSize( paint: Paint, text: CharSequence, start: Int, end: Int, fm: FontMetricsInt? ): Int { fm?.apply { val pfm = paint.fontMetricsInt ascent = pfm.ascent descent = pfm.descent top = pfm.top bottom = pfm.bottom } return cachedDrawable.bounds.right } override fun draw( canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint ) { canvas.save() val drawableHeight = cachedDrawable.intrinsicHeight val fontMetricsInt = paint.fontMetricsInt val transY = bottom - cachedDrawable.bounds.bottom + (drawableHeight - fontMetricsInt.descent + fontMetricsInt.ascent) / 2 canvas.translate(x, transY.toFloat()) cachedDrawable.draw(canvas) canvas.restore() } private val cachedDrawable: Drawable get() = drawable?.get() ?: getDrawable().also { drawable = WeakReference(it) } } } ================================================ FILE: app/src/main/java/awaisomereport/ICrashHandler.kt ================================================ package awaisomereport interface ICrashHandler { fun uncaughtException( t: Thread, exception: Throwable, defaultExceptionHandler: Thread.UncaughtExceptionHandler ) } ================================================ FILE: app/src/main/java/thoughtbot/expandableadapter/ExpandableGroup.java ================================================ package thoughtbot.expandableadapter; import java.util.List; import awais.instagrabber.repositories.responses.User; public class ExpandableGroup { private final String title; private final List items; public ExpandableGroup(final String title, final List items) { this.title = title; this.items = items; } public String getTitle() { return title; } public List getItems() { return items; } public int getItemCount() { if (items != null) { return items.size(); } return 0; } } ================================================ FILE: app/src/main/java/thoughtbot/expandableadapter/ExpandableList.java ================================================ package thoughtbot.expandableadapter; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; public final class ExpandableList { private final int groupsSize; public final ArrayList groups; public final boolean[] expandedGroupIndexes; public ExpandableList(@NonNull final ArrayList groups) { this.groups = groups; this.groupsSize = groups.size(); this.expandedGroupIndexes = new boolean[groupsSize]; } public ExpandableList(@NonNull final ArrayList groups, @Nullable final boolean[] expandedGroupIndexes) { this.groups = groups; this.groupsSize = groups.size(); this.expandedGroupIndexes = expandedGroupIndexes; } public int getVisibleItemCount() { int count = 0; for (int i = 0; i < groupsSize; i++) count = count + numberOfVisibleItemsInGroup(i); return count; } @NonNull public ExpandableListPosition getUnflattenedPosition(final int flPos) { int adapted = flPos; for (int i = 0; i < groupsSize; i++) { final int groupItemCount = numberOfVisibleItemsInGroup(i); if (adapted == 0) return ExpandableListPosition.obtain(ExpandableListPosition.GROUP, i, -1, flPos); else if (adapted < groupItemCount) return ExpandableListPosition.obtain(ExpandableListPosition.CHILD, i, adapted - 1, flPos); adapted = adapted - groupItemCount; } throw new RuntimeException("Unknown state"); } private int numberOfVisibleItemsInGroup(final int group) { return expandedGroupIndexes[group] ? groups.get(group).getItemCount() + 1 : 1; } public int getFlattenedGroupIndex(@NonNull final ExpandableListPosition listPosition) { int runningTotal = 0; for (int i = 0; i < listPosition.groupPos; i++) runningTotal = runningTotal + numberOfVisibleItemsInGroup(i); return runningTotal; } public ExpandableGroup getExpandableGroup(@NonNull ExpandableListPosition listPosition) { return groups.get(listPosition.groupPos); } } ================================================ FILE: app/src/main/java/thoughtbot/expandableadapter/ExpandableListPosition.java ================================================ package thoughtbot.expandableadapter; import androidx.annotation.NonNull; public class ExpandableListPosition { private static final ExpandableListPosition LIST_POSITION = new ExpandableListPosition(); public final static int CHILD = 1; public final static int GROUP = 2; private int flatListPos; public int groupPos; public int childPos; public int type; @NonNull public static ExpandableListPosition obtain(final int type, final int groupPos, final int childPos, final int flatListPos) { LIST_POSITION.type = type; LIST_POSITION.groupPos = groupPos; LIST_POSITION.childPos = childPos; LIST_POSITION.flatListPos = flatListPos; return LIST_POSITION; } @Override public boolean equals(final Object o) { if (this == o) return true; //if (o != null && getClass() == o.getClass()) { if (o instanceof ExpandableListPosition) { final ExpandableListPosition that = (ExpandableListPosition) o; if (groupPos != that.groupPos) return false; if (childPos != that.childPos) return false; if (flatListPos != that.flatListPos) return false; return type == that.type; } return false; } @Override public int hashCode() { return 31 * (31 * (31 * groupPos + childPos) + flatListPos) + type; } } ================================================ FILE: app/src/main/java/thoughtbot/expandableadapter/GroupViewHolder.java ================================================ package thoughtbot.expandableadapter; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import awais.instagrabber.R; import awais.instagrabber.interfaces.OnGroupClickListener; public class GroupViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { private final OnGroupClickListener listener; private final TextView title; private final ImageView arrow; public GroupViewHolder(@NonNull final View itemView, final OnGroupClickListener listener) { super(itemView); this.listener = listener; this.title = itemView.findViewById(android.R.id.text1); this.arrow = itemView.findViewById(R.id.collapsingArrow); this.title.setBackgroundColor(0x80_1565C0); itemView.setOnClickListener(this); } public void setTitle(@NonNull final String title) { this.title.setText(title); } @Override public void onClick(final View v) { if (listener != null) listener.toggleGroup(getLayoutPosition()); } public void toggle(final boolean expand) { arrow.setImageResource(expand ? R.drawable.ic_keyboard_arrow_up_24 : R.drawable.ic_keyboard_arrow_down_24); } } ================================================ FILE: app/src/main/res/anim/dialog_anim_in.xml ================================================ ================================================ FILE: app/src/main/res/anim/dialog_anim_out.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_in_right.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_left.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_out_left.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_right.xml ================================================ ================================================ FILE: app/src/main/res/animator/basket_path.xml ================================================ ================================================ FILE: app/src/main/res/animator/delete_mic_animation.xml ================================================ ================================================ FILE: app/src/main/res/color/emoji_picker_tab_color.xml ================================================ ================================================ FILE: app/src/main/res/color/filter_name_color.xml ================================================ ================================================ FILE: app/src/main/res/color/ic_circle_check_tint.xml ================================================ ================================================ FILE: app/src/main/res/color/ic_read_button_tint.xml ================================================ ================================================ FILE: app/src/main/res/color/image_edit_tab_tint.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_mic_to_send_anim.xml ================================================ ================================================ FILE: app/src/main/res/drawable/avd_send_to_mic_anim.xml ================================================ ================================================ FILE: app/src/main/res/drawable/background_grey_ripple.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_dm_date_header.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_dm_time.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_indicator.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_input.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_media_share_bottom.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_media_share_top_incoming.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_media_share_top_outgoing.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_quote_line.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_reply_text.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_rounded_corner.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_speech_bubble_incoming.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_speech_bubble_outgoing.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_user_search_input.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_account_clock_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_account_multiple_remove_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_archive.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_drop_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_upward_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_check_circle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_block_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_border_style_flipped_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_camera_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cancel.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check_all_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_checkbox_multiple_blank.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_checkbox_multiple_blank_stroke.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_circle_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_class_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_clock_alert_outline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_close_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloud_download_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_dashboard_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_download_circle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_explore_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_face_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_file_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_folder_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_forward_5_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_forward_5_24_a50.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_forward_5_24_states.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_highlight_off_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_image_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_keyboard_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_keyboard_arrow_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_keyboard_arrow_up_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_like.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_logout_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_message_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_more_horiz_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_more_vert_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_not_liked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notes_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notif.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_open_in_new_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_class_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_comments_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_info_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_map_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_person_add_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_person_add_disabled_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_person_pin_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_settings_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_star_plus_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_views_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pause_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_person_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_photo_filter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_arrow_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_arrow_24_a50.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_circle_outline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_states.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_profile_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_profile_40.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_profile_48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_radio_button_unchecked_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_refresh_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_replay_5_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_replay_5_24_a50.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_replay_5_24_states.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_add_circle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_arrow_back_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_attach_file_rot45_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_backspace_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_bookmark_border_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_check_circle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_crop_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_drag_handle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_edit_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_emotions_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_events_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_flags_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_food_beverage_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_nature_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_objects_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_symbols_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_emoji_transportation_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_flip_camera_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_gif_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_location_on_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_mode_comment_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_pause_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_play_arrow_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_remove_circle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_reply_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_send_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_tune_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_round_unknown_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_rounded_corner_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings_backup_restore_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shutter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shutter_focused.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shutter_normal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shutter_pressed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_slider_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_star_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_star_check_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sticker_curved_outlines.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_story_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_story_viewer_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_submit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_suggested_users.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_unread_indicator_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_video_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_view_agenda_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_view_grid_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_off_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_off_24_a50.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_off_24_states.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_up_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_up_24_a50.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_up_24_states.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_warning.xml ================================================ ================================================ FILE: app/src/main/res/drawable/launch.xml ================================================ ================================================ FILE: app/src/main/res/drawable/launch_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/launch_screen.xml ================================================ ================================================ FILE: app/src/main/res/drawable/lock.xml ================================================ ================================================ FILE: app/src/main/res/drawable/popup_background_exoplayer.xml ================================================ ================================================ FILE: app/src/main/res/drawable/pref_list_divider_material.xml ================================================ ================================================ FILE: app/src/main/res/drawable/recv_basket_animated.xml ================================================ ================================================ FILE: app/src/main/res/drawable/recv_ic_arrow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/recv_ic_delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/recv_ic_mic.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rounder_corner_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rounder_corner_semi_black_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_oval_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sl_favourite_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/speed_text_color_states.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_camera.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_crash_error.xml ================================================